180 lines
6.1 KiB
PHP
180 lines
6.1 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* BIP39 + BIP32, enough of them to turn a recovery phrase into the one key
|
||
|
|
* that matters.
|
||
|
|
*
|
||
|
|
* The publishing wallet is not a new secret invented by the CMS — it is the
|
||
|
|
* same wallet that holds the name's NFT certificate, because the BNS gateway
|
||
|
|
* only accepts writes signed by the current on-chain owner. So Sirius Press
|
||
|
|
* has to walk the identical derivation the portal wallet walks
|
||
|
|
* (Argus/src/lib/wallet-web.js): BIP39 phrase to seed, then m/44'/145'/0'/0/0.
|
||
|
|
*
|
||
|
|
* A different path here would produce a different address, the gateway would
|
||
|
|
* return 403, and the failure would look like a signing bug rather than a
|
||
|
|
* derivation mismatch — so the path is a constant, not a setting, and the
|
||
|
|
* tests pin it against libauth.
|
||
|
|
*
|
||
|
|
* @package SiriusPress
|
||
|
|
*/
|
||
|
|
|
||
|
|
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||
|
|
|
||
|
|
require_once __DIR__ . '/class-sp-bn.php';
|
||
|
|
require_once __DIR__ . '/class-sp-secp256k1.php';
|
||
|
|
require_once __DIR__ . '/class-sp-cashaddr.php';
|
||
|
|
|
||
|
|
final class SP_HD {
|
||
|
|
|
||
|
|
/** BCH coin type 145, account 0, external branch, first address. */
|
||
|
|
const DEFAULT_PATH = "m/44'/145'/0'/0/0";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* BIP39 phrase to 64-byte seed.
|
||
|
|
*
|
||
|
|
* @param string $mnemonic Space-separated words. Normalised the same way
|
||
|
|
* the portal normalises: trimmed, lowercased,
|
||
|
|
* runs of whitespace collapsed.
|
||
|
|
* @param string $passphrase Optional BIP39 passphrase ("25th word").
|
||
|
|
*/
|
||
|
|
public static function seed_from_mnemonic( $mnemonic, $passphrase = '' ) {
|
||
|
|
$clean = self::normalize_mnemonic( $mnemonic );
|
||
|
|
return hash_pbkdf2( 'sha512', $clean, 'mnemonic' . $passphrase, 2048, 64, true );
|
||
|
|
}
|
||
|
|
|
||
|
|
public static function normalize_mnemonic( $mnemonic ) {
|
||
|
|
return trim( preg_replace( '/\s+/u', ' ', strtolower( (string) $mnemonic ) ) );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Master key from a seed.
|
||
|
|
*
|
||
|
|
* @return array{key:string,chain:string} 32 raw bytes each.
|
||
|
|
*/
|
||
|
|
public static function master( $seed ) {
|
||
|
|
$i = hash_hmac( 'sha512', $seed, 'Bitcoin seed', true );
|
||
|
|
return array(
|
||
|
|
'key' => substr( $i, 0, 32 ),
|
||
|
|
'chain' => substr( $i, 32, 32 ),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* One CKDpriv step.
|
||
|
|
*
|
||
|
|
* @param array $node {key, chain}.
|
||
|
|
* @param int $index Child index; add 0x80000000 for a hardened step.
|
||
|
|
* @return array{key:string,chain:string}
|
||
|
|
* @throws Exception If the derived key is invalid (probability ~2^-127).
|
||
|
|
*/
|
||
|
|
public static function derive_child( $node, $index ) {
|
||
|
|
$hardened = $index >= 0x80000000;
|
||
|
|
if ( $hardened ) {
|
||
|
|
$data = "\x00" . $node['key'];
|
||
|
|
} else {
|
||
|
|
$pub = SP_Secp256k1::public_key( $node['key'] );
|
||
|
|
if ( '' === $pub ) {
|
||
|
|
throw new Exception( 'cannot derive public key for a hardened-only node' );
|
||
|
|
}
|
||
|
|
$data = $pub;
|
||
|
|
}
|
||
|
|
$data .= pack( 'N', $index );
|
||
|
|
|
||
|
|
$i = hash_hmac( 'sha512', $data, $node['chain'], true );
|
||
|
|
$il = substr( $i, 0, 32 );
|
||
|
|
$ir = substr( $i, 32, 32 );
|
||
|
|
|
||
|
|
$n = SP_BN::from_hex( SP_Secp256k1::N );
|
||
|
|
$tweak = SP_BN::from_bin( $il );
|
||
|
|
if ( SP_BN::cmp( $tweak, $n ) >= 0 ) {
|
||
|
|
throw new Exception( 'derived tweak out of range — pick the next index' );
|
||
|
|
}
|
||
|
|
$child = SP_BN::mod( SP_BN::add( $tweak, SP_BN::from_bin( $node['key'] ) ), $n );
|
||
|
|
if ( SP_BN::is_zero( $child ) ) {
|
||
|
|
throw new Exception( 'derived key is zero — pick the next index' );
|
||
|
|
}
|
||
|
|
return array(
|
||
|
|
'key' => SP_BN::to_bin( $child, 32 ),
|
||
|
|
'chain' => $ir,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Walk a full path such as "m/44'/145'/0'/0/0".
|
||
|
|
*
|
||
|
|
* @return array{key:string,chain:string}
|
||
|
|
* @throws Exception On a malformed path.
|
||
|
|
*/
|
||
|
|
public static function derive_path( $seed, $path = self::DEFAULT_PATH ) {
|
||
|
|
$node = self::master( $seed );
|
||
|
|
$parts = preg_split( '#/#', trim( (string) $path ) );
|
||
|
|
if ( ! $parts || 'm' !== strtolower( $parts[0] ) ) {
|
||
|
|
throw new Exception( 'derivation path must start with "m"' );
|
||
|
|
}
|
||
|
|
foreach ( array_slice( $parts, 1 ) as $part ) {
|
||
|
|
if ( '' === $part ) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
$hardened = ( "'" === substr( $part, -1 ) || 'h' === strtolower( substr( $part, -1 ) ) );
|
||
|
|
$num = (int) rtrim( $part, "'hH" );
|
||
|
|
if ( $num < 0 || $num > 0x7fffffff ) {
|
||
|
|
throw new Exception( "derivation index out of range: {$part}" );
|
||
|
|
}
|
||
|
|
$node = self::derive_child( $node, $hardened ? $num + 0x80000000 : $num );
|
||
|
|
}
|
||
|
|
return $node;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The private key and address a phrase publishes with.
|
||
|
|
*
|
||
|
|
* @param string $mnemonic
|
||
|
|
* @param string $prefix 'bitcoincash' or 'bchtest'.
|
||
|
|
* @param string $path Override only for wallets minted on another path
|
||
|
|
* (operator wallets were created on BTC coin type).
|
||
|
|
* @return array{private:string,public:string,address:string}
|
||
|
|
* @throws Exception
|
||
|
|
*/
|
||
|
|
public static function publishing_key( $mnemonic, $prefix = 'bitcoincash', $path = self::DEFAULT_PATH ) {
|
||
|
|
$node = self::derive_path( self::seed_from_mnemonic( $mnemonic ), $path );
|
||
|
|
$pub = SP_Secp256k1::public_key( $node['key'] );
|
||
|
|
if ( '' === $pub ) {
|
||
|
|
throw new Exception( 'derivation produced an unusable key' );
|
||
|
|
}
|
||
|
|
return array(
|
||
|
|
'private' => $node['key'],
|
||
|
|
'public' => $pub,
|
||
|
|
'address' => SP_CashAddr::from_public_key( $pub, $prefix ),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sanity-check a phrase before storing it.
|
||
|
|
*
|
||
|
|
* Deliberately structural rather than a full BIP39 checksum test: this
|
||
|
|
* fork must accept phrases from every wallet a name owner might already
|
||
|
|
* use, and refusing one over a wordlist we shipped a year ago is a worse
|
||
|
|
* failure than accepting one that later derives the wrong address. The
|
||
|
|
* real check is the address comparison the settings screen does right
|
||
|
|
* after: derive, show the address, and let the owner confirm it is theirs.
|
||
|
|
*
|
||
|
|
* @return string '' when acceptable, otherwise a human-readable reason.
|
||
|
|
*/
|
||
|
|
public static function phrase_problem( $mnemonic ) {
|
||
|
|
$clean = self::normalize_mnemonic( $mnemonic );
|
||
|
|
if ( '' === $clean ) {
|
||
|
|
return 'The recovery phrase is empty.';
|
||
|
|
}
|
||
|
|
$words = explode( ' ', $clean );
|
||
|
|
$count = count( $words );
|
||
|
|
if ( ! in_array( $count, array( 12, 15, 18, 21, 24 ), true ) ) {
|
||
|
|
return sprintf( 'A recovery phrase has 12, 15, 18, 21 or 24 words — this one has %d.', $count );
|
||
|
|
}
|
||
|
|
foreach ( $words as $w ) {
|
||
|
|
if ( ! preg_match( '/^[a-z]{3,8}$/', $w ) ) {
|
||
|
|
return sprintf( '"%s" does not look like a recovery-phrase word.', $w );
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
}
|