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 ''; } }