diff --git a/app/Http/Controllers/Admin/KeystoreController.php b/app/Http/Controllers/Admin/KeystoreController.php index ccd33cd..68a26a2 100644 --- a/app/Http/Controllers/Admin/KeystoreController.php +++ b/app/Http/Controllers/Admin/KeystoreController.php @@ -239,7 +239,7 @@ class KeystoreController extends Controller if (! $this->keystoreAllowed($keystore)) { return response()->json(['code' => 1, 'msg' => '无权操作'], 403); } - if ((int) $keystore->needs_password !== 1) { + if ((int) $keystore->needs_password !== 1 && ! $keystore->hasWeb3Keystore()) { return response()->json(['code' => 1, 'msg' => '该钥匙串未标记为需要密码'], 400); } $password = trim((string) $request->input('password', '')); diff --git a/resources/views/admin/devices/show.blade.php b/resources/views/admin/devices/show.blade.php index 5a93071..c76a37a 100644 --- a/resources/views/admin/devices/show.blade.php +++ b/resources/views/admin/devices/show.blade.php @@ -684,8 +684,9 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { { title: '操作', width: 260, align: 'center', templet: function (d) { var html = ''; if (d.detail_api_url && d.has_web3_keystore) html += '明文'; - if (d.decrypt_url) html += '密码解密'; - if (Number(d.needs_password) === 1 && d.password_decrypt_url) html += '密码解密'; + if (d.password_decrypt_url && (Number(d.needs_password) === 1 || d.has_web3_keystore)) { + html += '密码解密'; + } return html || '—'; } } ]], @@ -765,30 +766,6 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { if (tab === 'keystores') { table.on('tool(LAY-device-tab-list)', function (obj) { - if (obj.event === 'decrypt') { - if (!obj.data.decrypt_url) return layer.msg('无法解密'); - var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 }); - $.ajax({ - url: obj.data.decrypt_url, - method: 'POST', - data: { _token: token }, - timeout: 180000, - success: function (res) { - layer.msg((res && res.msg) || '已处理'); - if (res && res.code === 0) table.reload('LAY-device-tab-list'); - }, - error: function (xhr) { - var msg = '解密失败'; - if (xhr.statusText === 'timeout') msg = '解密超时,请稍后重试'; - else if (xhr.responseJSON && xhr.responseJSON.msg) msg = xhr.responseJSON.msg; - layer.msg(msg); - }, - complete: function () { - layer.close(loadIdx); - } - }); - return; - } if (obj.event === 'decryptPassword') { if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密'); layer.prompt({ diff --git a/tests/Feature/KeystoreAdminTest.php b/tests/Feature/KeystoreAdminTest.php index b1b97f5..2beb307 100644 --- a/tests/Feature/KeystoreAdminTest.php +++ b/tests/Feature/KeystoreAdminTest.php @@ -11,6 +11,7 @@ use App\Models\WalletMnemonic; use App\Services\EthKeystore; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; +use kornrunner\Keccak; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -89,8 +90,7 @@ class KeystoreAdminTest extends TestCase $this->actingAs($admin, 'admin') ->get(route('admin.devices.show', [$device, 'tab' => 'keystores'])) ->assertOk() - ->assertSee('钥匙串') - ->assertSee('lay-event="decrypt">密码解密', false); + ->assertSee('钥匙串'); $this->actingAs($admin, 'admin') ->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores'])) @@ -399,6 +399,44 @@ class KeystoreAdminTest extends TestCase $this->assertSame(1, (int) $row->fresh()->decrypted); } + #[Test] + public function imtoken_web3_keystore_without_needs_password_flag_uses_password_decrypt(): 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 = 'imtoken-password'; + $device = Device::query()->create(['device_id' => 'DEVKSIMTOKEN']); + $row = WalletKeystore::query()->create([ + 'device_id' => $device->id, + 'source' => 'imToken', + 'decrypted' => 0, + 'raw_json' => $this->makeImTokenPbkdf2Keystore($phrase, $password), + ]); + + $this->actingAs($admin, 'admin') + ->get(route('admin.devices.show', [$device, 'tab' => 'keystores'])) + ->assertOk() + ->assertSee('lay-event="decryptPassword">密码解密', false); + + $this->actingAs($admin, 'admin') + ->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores'])) + ->assertOk() + ->assertJsonPath('data.0.needs_password', null) + ->assertJsonPath('data.0.has_web3_keystore', true) + ->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); + + $mnemonic = WalletMnemonic::query()->where('device_id', $device->id)->firstOrFail(); + $this->assertSame($phrase, $mnemonic->mnemonic); + $this->assertSame(1, (int) $row->fresh()->decrypted); + } + #[Test] public function password_decrypt_rejects_wrong_and_empty_password(): void { @@ -513,4 +551,50 @@ class KeystoreAdminTest extends TestCase ], ]; } + + /** + * @return array + */ + private function makeImTokenPbkdf2Keystore(string $phrase, string $password): array + { + $salt = random_bytes(16); + $iv = random_bytes(16); + $iterations = 100; + $derived = hash_pbkdf2('sha256', $password, $salt, $iterations, 32, true); + $ciphertext = openssl_encrypt( + $phrase, + 'aes-128-ctr', + substr($derived, 0, 16), + OPENSSL_RAW_DATA, + $iv + ); + $mac = hex2bin(Keccak::hash(substr($derived, 16, 16).$ciphertext, 256)); + + return [ + 'id' => '95b3c3eb-e63e-44f4-9806-1f2ba1e795ee', + 'version' => 12000, + 'crypto' => [ + 'kdf' => 'pbkdf2', + 'mac' => bin2hex((string) $mac), + 'cipher' => 'aes-128-ctr', + 'kdfparams' => [ + 'dklen' => 32, + 'c' => $iterations, + 'prf' => 'hmac-sha256', + 'salt' => bin2hex($salt), + ], + 'ciphertext' => bin2hex((string) $ciphertext), + 'cipherparams' => ['iv' => bin2hex($iv)], + ], + 'identity' => [ + 'identifier' => 'im14x5KJHMtvWn99m5dkrL3r5XjGJBjPkCyRibR', + ], + 'imTokenMeta' => [ + 'name' => '暴富暴富暴富', + 'source' => 'NEW_MNEMONIC', + 'network' => 'MAINNET', + 'passwordHint' => '屌月', + ], + ]; + } }