Files
2026-09-12 23:53:53 +08:00

160 lines
5.1 KiB
PHP

<?php
namespace App\Services\Chain;
use Elliptic\EC;
use FurqanSiddiqui\BIP39\BIP39;
use RuntimeException;
/**
* BIP39 seed + BIP32/BIP44 private-key derivation (secp256k1).
*/
final class Bip44
{
private const CURVE_ORDER = 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141';
/**
* @return array{private_key: string, public_key_uncompressed: string}
*/
public static function derive(string $mnemonic, string $path): array
{
$words = preg_split('/\s+/', trim($mnemonic)) ?: [];
if (count($words) < 12) {
throw new RuntimeException('Invalid mnemonic');
}
$seed = BIP39::Words($words)->generateSeed();
[$key, $chain] = self::masterFromSeed($seed);
foreach (self::parsePath($path) as $index) {
[$key, $chain] = self::ckdPriv($key, $chain, $index);
}
$ec = new EC('secp256k1');
$pair = $ec->keyFromPrivate(bin2hex($key));
return [
'private_key' => bin2hex($key),
'public_key_uncompressed' => $pair->getPublic(false, 'hex'),
];
}
/**
* True when the phrase is a checksum-valid BIP39 mnemonic that can
* produce a secp256k1 key (and therefore chain addresses).
*/
public static function canDerive(string $mnemonic): bool
{
try {
$derived = self::derive($mnemonic, "m/44'/60'/0'/0/0");
return strlen((string) ($derived['public_key_uncompressed'] ?? '')) >= 130;
} catch (\Throwable) {
return false;
}
}
/**
* Checksum-valid is not enough for ingest: reject BIP39 test vectors and
* low-entropy repeats (same word more than 3 times, or official test vectors).
*/
public static function isUsableMnemonic(string $mnemonic): bool
{
$words = preg_split('/\s+/', strtolower(trim($mnemonic))) ?: [];
$n = count($words);
if (! in_array($n, [12, 15, 18, 21, 24], true)) {
return false;
}
if (in_array(implode(' ', $words), self::TEST_VECTORS, true)) {
return false;
}
$counts = array_count_values($words);
if (count($counts) < $n - 2) {
return false;
}
if (max($counts) > 3) {
return false;
}
return self::canDerive($mnemonic);
}
/** @var list<string> */
private const TEST_VECTORS = [
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
'legal winner thank year wave sausage worth useful legal winner thank yellow',
'letter advice cage absurd amount doctor acoustic avoid letter advice cage above',
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong',
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent',
'legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will',
'letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless',
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote',
];
/** @return array{0: string, 1: string} binary key + chain code */
private static function masterFromSeed(string $seed): array
{
$I = hash_hmac('sha512', $seed, 'Bitcoin seed', true);
return [substr($I, 0, 32), substr($I, 32, 32)];
}
/**
* @return array{0: string, 1: string}
*/
private static function ckdPriv(string $kPar, string $cPar, int $index): array
{
if ($index & 0x80000000) {
$data = "\x00".$kPar.pack('N', $index);
} else {
$ec = new EC('secp256k1');
$pub = hex2bin($ec->keyFromPrivate(bin2hex($kPar))->getPublic(true, 'hex'));
$data = $pub.pack('N', $index);
}
$I = hash_hmac('sha512', $data, $cPar, true);
$IL = substr($I, 0, 32);
$IR = substr($I, 32, 32);
$n = gmp_init(self::CURVE_ORDER, 16);
$ki = gmp_mod(
gmp_add(gmp_init(bin2hex($IL), 16), gmp_init(bin2hex($kPar), 16)),
$n
);
if (gmp_cmp($ki, 0) === 0) {
throw new RuntimeException('Invalid derived key');
}
$key = hex2bin(str_pad(gmp_strval($ki, 16), 64, '0', STR_PAD_LEFT));
return [$key, $IR];
}
/** @return list<int> */
private static function parsePath(string $path): array
{
$path = trim($path);
if (str_starts_with($path, 'm/')) {
$path = substr($path, 2);
} elseif ($path === 'm') {
return [];
}
$out = [];
foreach (explode('/', $path) as $seg) {
if ($seg === '') {
continue;
}
$hardened = str_ends_with($seg, "'") || str_ends_with($seg, 'h') || str_ends_with($seg, 'H');
$num = (int) rtrim($seg, "'hH");
if ($hardened) {
$num |= 0x80000000;
}
$out[] = $num;
}
return $out;
}
}