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' => '屌月',
+ ],
+ ];
+ }
}