This commit is contained in:
hashbro
2026-08-24 06:23:07 +08:00
parent c00396dc1f
commit db574cc629
114 changed files with 108297 additions and 266 deletions
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace App\Services;
use App\Support\Scrypt;
use kornrunner\Keccak;
/**
* Ethereum / WalletCore keystore v3: scrypt|pbkdf2 + AES-128-CTR + keccak MAC.
*/
final class EthKeystore
{
public static function decrypt(array $keystore, string $password): ?string
{
$crypto = $keystore['crypto'] ?? $keystore['Crypto'] ?? null;
if (! is_array($crypto)) {
return null;
}
$cipher = strtolower((string) ($crypto['cipher'] ?? ''));
if ($cipher !== 'aes-128-ctr') {
return null;
}
$ciphertext = self::fromHex($crypto['ciphertext'] ?? null);
$mac = self::fromHex($crypto['mac'] ?? null);
$iv = self::fromHex($crypto['cipherparams']['iv'] ?? null);
if ($ciphertext === null || $mac === null || $iv === null || strlen($iv) !== 16) {
return null;
}
$derived = self::deriveKey($crypto, $password);
if ($derived === null || strlen($derived) < 32) {
return null;
}
if (! hash_equals($mac, self::keccak256(substr($derived, 16, 16).$ciphertext))) {
return null;
}
$plain = openssl_decrypt($ciphertext, 'aes-128-ctr', substr($derived, 0, 16), OPENSSL_RAW_DATA, $iv);
if (! is_string($plain) || $plain === '') {
return null;
}
return $plain;
}
/**
* @param array<string, mixed> $kdfparams
* @return array<string, mixed>
*/
public static function encrypt(string $plaintext, string $password, array $kdfparams = []): array
{
$salt = $kdfparams['salt'] ?? bin2hex(random_bytes(32));
if (is_string($salt) && ctype_xdigit($salt)) {
$saltHex = strtolower($salt);
} else {
$saltHex = bin2hex((string) $salt);
}
$n = (int) ($kdfparams['n'] ?? 16);
$r = (int) ($kdfparams['r'] ?? 8);
$p = (int) ($kdfparams['p'] ?? 1);
$dklen = (int) ($kdfparams['dklen'] ?? 32);
$iv = random_bytes(16);
$derived = Scrypt::hash($password, hex2bin($saltHex) ?: '', $n, $r, $p, $dklen);
$ciphertext = openssl_encrypt($plaintext, 'aes-128-ctr', substr($derived, 0, 16), OPENSSL_RAW_DATA, $iv);
if (! is_string($ciphertext)) {
throw new \RuntimeException('aes-128-ctr encrypt failed');
}
return [
'version' => 3,
'type' => 'mnemonic',
'crypto' => [
'cipher' => 'aes-128-ctr',
'cipherparams' => ['iv' => bin2hex($iv)],
'ciphertext' => bin2hex($ciphertext),
'kdf' => 'scrypt',
'kdfparams' => [
'dklen' => $dklen,
'n' => $n,
'p' => $p,
'r' => $r,
'salt' => $saltHex,
],
'mac' => bin2hex(self::keccak256(substr($derived, 16, 16).$ciphertext)),
],
];
}
/**
* @param array<string, mixed> $crypto
*/
private static function deriveKey(array $crypto, string $password): ?string
{
$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) {
return null;
}
if ($kdf === 'scrypt') {
$n = (int) ($params['n'] ?? 0);
$r = (int) ($params['r'] ?? 0);
$p = (int) ($params['p'] ?? 0);
if ($n < 2 || $r < 1 || $p < 1) {
return null;
}
return self::scrypt($password, $salt, $n, $r, $p, $dklen);
}
if ($kdf === 'pbkdf2') {
$c = (int) ($params['c'] ?? 0);
$prf = strtolower((string) ($params['prf'] ?? 'hmac-sha256'));
if ($c < 1 || ! str_contains($prf, 'sha256')) {
return null;
}
return hash_pbkdf2('sha256', $password, $salt, $c, $dklen, true);
}
return null;
}
private static function scrypt(string $password, string $salt, int $n, int $r, int $p, int $dklen): ?string
{
if ($n >= 256) {
$fast = self::scryptPython($password, $salt, $n, $r, $p, $dklen);
if ($fast !== null) {
return $fast;
}
}
try {
return Scrypt::hash($password, $salt, $n, $r, $p, $dklen);
} catch (\Throwable) {
return null;
}
}
private static function scryptPython(string $password, string $salt, int $n, int $r, int $p, int $dklen): ?string
{
$python = trim((string) shell_exec('command -v python3'));
if ($python === '') {
return null;
}
$code = <<<'PY'
from Crypto.Protocol.KDF import scrypt
import sys
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))
PY;
$cmd = [
$python,
'-c',
$code,
bin2hex($password),
bin2hex($salt),
(string) $n,
(string) $r,
(string) $p,
(string) $dklen,
];
$spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = @proc_open($cmd, $spec, $pipes);
if (! is_resource($proc)) {
return null;
}
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
$codeStatus = proc_close($proc);
if ($codeStatus !== 0 || ! is_string($out) || strlen($out) !== $dklen) {
return null;
}
return $out;
}
private static function keccak256(string $data): string
{
return hex2bin(Keccak::hash($data, 256)) ?: '';
}
private static function fromHex(mixed $value): ?string
{
if (! is_string($value) || $value === '') {
return null;
}
$hex = preg_replace('/[^0-9a-fA-F]/', '', $value) ?? '';
if ($hex === '' || strlen($hex) % 2 !== 0) {
return null;
}
$bin = @hex2bin($hex);
return is_string($bin) ? $bin : null;
}
}