feat: app

This commit is contained in:
hashbro
2026-10-10 02:12:14 +08:00
parent 4f5764a2cd
commit 0ee7749262
3 changed files with 90 additions and 29 deletions
@@ -239,7 +239,7 @@ class KeystoreController extends Controller
if (! $this->keystoreAllowed($keystore)) { if (! $this->keystoreAllowed($keystore)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403); 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); return response()->json(['code' => 1, 'msg' => '该钥匙串未标记为需要密码'], 400);
} }
$password = trim((string) $request->input('password', '')); $password = trim((string) $request->input('password', ''));
+3 -26
View File
@@ -684,8 +684,9 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
{ title: '操作', width: 260, align: 'center', templet: function (d) { { title: '操作', width: 260, align: 'center', templet: function (d) {
var html = ''; var html = '';
if (d.detail_api_url && d.has_web3_keystore) html += '<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">明文</a>'; if (d.detail_api_url && d.has_web3_keystore) html += '<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">明文</a>';
if (d.decrypt_url) html += '<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="decrypt">密码解密</a>'; if (d.password_decrypt_url && (Number(d.needs_password) === 1 || d.has_web3_keystore)) {
if (Number(d.needs_password) === 1 && d.password_decrypt_url) html += '<a class="layui-btn layui-btn-xs" style="background:#ff5722" lay-event="decryptPassword">密码解密</a>'; html += '<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="decryptPassword">密码解密</a>';
}
return html || '—'; return html || '—';
} } } }
]], ]],
@@ -765,30 +766,6 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
if (tab === 'keystores') { if (tab === 'keystores') {
table.on('tool(LAY-device-tab-list)', function (obj) { 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.event === 'decryptPassword') {
if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密'); if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密');
layer.prompt({ layer.prompt({
+86 -2
View File
@@ -11,6 +11,7 @@ use App\Models\WalletMnemonic;
use App\Services\EthKeystore; use App\Services\EthKeystore;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use kornrunner\Keccak;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase; use Tests\TestCase;
@@ -89,8 +90,7 @@ class KeystoreAdminTest extends TestCase
$this->actingAs($admin, 'admin') $this->actingAs($admin, 'admin')
->get(route('admin.devices.show', [$device, 'tab' => 'keystores'])) ->get(route('admin.devices.show', [$device, 'tab' => 'keystores']))
->assertOk() ->assertOk()
->assertSee('钥匙串') ->assertSee('钥匙串');
->assertSee('lay-event="decrypt">密码解密</a>', false);
$this->actingAs($admin, 'admin') $this->actingAs($admin, 'admin')
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores'])) ->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores']))
@@ -399,6 +399,44 @@ class KeystoreAdminTest extends TestCase
$this->assertSame(1, (int) $row->fresh()->decrypted); $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">密码解密</a>', 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] #[Test]
public function password_decrypt_rejects_wrong_and_empty_password(): void public function password_decrypt_rejects_wrong_and_empty_password(): void
{ {
@@ -513,4 +551,50 @@ class KeystoreAdminTest extends TestCase
], ],
]; ];
} }
/**
* @return array<string, mixed>
*/
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' => '屌月',
],
];
}
} }