160 lines
5.1 KiB
PHP
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;
|
|
}
|
|
}
|