108 lines
3.0 KiB
PHP
108 lines
3.0 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'),
|
|
];
|
|
}
|
|
|
|
/** @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;
|
|
}
|
|
}
|