284 lines
8.6 KiB
PHP
284 lines
8.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Support\Scrypt;
|
|
use kornrunner\Keccak;
|
|
|
|
/**
|
|
* 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;
|
|
if (! is_array($crypto)) {
|
|
return null;
|
|
}
|
|
$cipher = strtolower((string) ($crypto['cipher'] ?? ''));
|
|
$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);
|
|
$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) < $keyLen) {
|
|
return null;
|
|
}
|
|
$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, $cipher, substr($derived, 0, $keyLen), OPENSSL_RAW_DATA, $iv);
|
|
if (! is_string($plain) || $plain === '') {
|
|
return null;
|
|
}
|
|
|
|
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>
|
|
*/
|
|
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'] ?? '') ?? '';
|
|
if ($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
|
|
{
|
|
$cacheKey = hash('sha256', $password."\0".$salt."\0{$n}\0{$r}\0{$p}\0{$dklen}");
|
|
if (isset(self::$kdfCache[$cacheKey])) {
|
|
return self::$kdfCache[$cacheKey];
|
|
}
|
|
$out = null;
|
|
if ($n >= 256) {
|
|
$out = self::scryptPython($password, $salt, $n, $r, $p, $dklen);
|
|
} else {
|
|
try {
|
|
$out = Scrypt::hash($password, $salt, $n, $r, $p, $dklen);
|
|
} catch (\Throwable) {
|
|
$out = null;
|
|
}
|
|
}
|
|
if ($out !== null) {
|
|
self::$kdfCache[$cacheKey] = $out;
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
private static function scryptPython(string $password, string $salt, int $n, int $r, int $p, int $dklen): ?string
|
|
{
|
|
$python = self::pythonBinary();
|
|
if ($python === null) {
|
|
return null;
|
|
}
|
|
$code = <<<'PY'
|
|
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))
|
|
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,
|
|
'-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 pythonBinary(): ?string
|
|
{
|
|
$configured = [
|
|
trim((string) config('coruna.channel_builder.python', '')),
|
|
trim((string) config('coruna.channel_builder_new.python', '')),
|
|
];
|
|
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 '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
|
|
{
|
|
return hex2bin(Keccak::hash($data, 256)) ?: '';
|
|
}
|
|
|
|
private static function fromHex(mixed $value): ?string
|
|
{
|
|
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;
|
|
}
|
|
$bin = @hex2bin($hex);
|
|
|
|
return is_string($bin) ? $bin : null;
|
|
}
|
|
}
|