376 lines
13 KiB
PHP
376 lines
13 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* secp256k1 in pure PHP: recoverable message signatures, nothing else.
|
||
|
|
*
|
||
|
|
* Sirius Press replaces "prove you can read this mailbox" with "prove you hold
|
||
|
|
* this key", so signature verification sits on the login path and cannot be
|
||
|
|
* optional. Requiring a native secp256k1 extension would rule out most of the
|
||
|
|
* shared hosts this fork is meant to run on, so the curve lives here.
|
||
|
|
*
|
||
|
|
* Scope is deliberately narrow — the two operations the fork actually needs:
|
||
|
|
*
|
||
|
|
* sign_recoverable() produce the 65-byte [flag|r|s] blob the BNS gateway
|
||
|
|
* and the Sirius portal already speak.
|
||
|
|
* recover() pull the compressed public key back out of one, so a
|
||
|
|
* login signature can be turned into a CashAddress and
|
||
|
|
* compared to the user's stored address.
|
||
|
|
*
|
||
|
|
* Point arithmetic is Jacobian, so a scalar multiply costs exactly one modular
|
||
|
|
* inversion at the end instead of one per bit. That is what keeps the BCMath
|
||
|
|
* fallback in the "noticeable pause" range rather than the "gateway timeout"
|
||
|
|
* range.
|
||
|
|
*
|
||
|
|
* Signature format matches Argus/src/lib/wallet-web.js BuiltInWallet::signMessage
|
||
|
|
* byte for byte: flag = 27 + 4 + recovery_id, then r, then s, all big-endian.
|
||
|
|
* The +4 says "the public key was compressed"; the gateway recovers the id
|
||
|
|
* back out with (flag - 27) & 3.
|
||
|
|
*
|
||
|
|
* @package SiriusPress
|
||
|
|
*/
|
||
|
|
|
||
|
|
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||
|
|
|
||
|
|
require_once __DIR__ . '/class-sp-bn.php';
|
||
|
|
|
||
|
|
final class SP_Secp256k1 {
|
||
|
|
|
||
|
|
const P = 'fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f';
|
||
|
|
const N = 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141';
|
||
|
|
const GX = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
|
||
|
|
const GY = '483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8';
|
||
|
|
|
||
|
|
/** Memoised curve constants, per request. */
|
||
|
|
private static $c = null;
|
||
|
|
|
||
|
|
private static function consts() {
|
||
|
|
if ( null === self::$c ) {
|
||
|
|
self::$c = array(
|
||
|
|
'p' => SP_BN::from_hex( self::P ),
|
||
|
|
'n' => SP_BN::from_hex( self::N ),
|
||
|
|
'gx' => SP_BN::from_hex( self::GX ),
|
||
|
|
'gy' => SP_BN::from_hex( self::GY ),
|
||
|
|
'zero' => SP_BN::from_int( 0 ),
|
||
|
|
'one' => SP_BN::from_int( 1 ),
|
||
|
|
'two' => SP_BN::from_int( 2 ),
|
||
|
|
'three' => SP_BN::from_int( 3 ),
|
||
|
|
'four' => SP_BN::from_int( 4 ),
|
||
|
|
'seven' => SP_BN::from_int( 7 ),
|
||
|
|
'eight' => SP_BN::from_int( 8 ),
|
||
|
|
);
|
||
|
|
// n/2, the low-S boundary.
|
||
|
|
self::$c['half_n'] = SP_BN::from_hex( '7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0' );
|
||
|
|
}
|
||
|
|
return self::$c;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------- field ops
|
||
|
|
|
||
|
|
private static function fadd( $a, $b ) {
|
||
|
|
$c = self::consts();
|
||
|
|
return SP_BN::mod( SP_BN::add( $a, $b ), $c['p'] );
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function fsub( $a, $b ) {
|
||
|
|
$c = self::consts();
|
||
|
|
return SP_BN::mod( SP_BN::sub( $a, $b ), $c['p'] );
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function fmul( $a, $b ) {
|
||
|
|
$c = self::consts();
|
||
|
|
return SP_BN::mod( SP_BN::mul( $a, $b ), $c['p'] );
|
||
|
|
}
|
||
|
|
|
||
|
|
// -------------------------------------------------- Jacobian point maths
|
||
|
|
|
||
|
|
/** The point at infinity, in Jacobian coordinates (Z = 0). */
|
||
|
|
private static function jinf() {
|
||
|
|
$c = self::consts();
|
||
|
|
return array( $c['one'], $c['one'], $c['zero'] );
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function is_inf( $pt ) {
|
||
|
|
return SP_BN::is_zero( $pt[2] );
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Affine (x, y) into Jacobian with Z = 1. */
|
||
|
|
private static function to_jacobian( $x, $y ) {
|
||
|
|
$c = self::consts();
|
||
|
|
return array( $x, $y, $c['one'] );
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Jacobian back to affine: (X/Z^2, Y/Z^3). The one inversion. */
|
||
|
|
private static function to_affine( $pt ) {
|
||
|
|
$c = self::consts();
|
||
|
|
if ( self::is_inf( $pt ) ) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
$zi = SP_BN::inv_mod( $pt[2], $c['p'] );
|
||
|
|
$zi2 = self::fmul( $zi, $zi );
|
||
|
|
$zi3 = self::fmul( $zi2, $zi );
|
||
|
|
return array( self::fmul( $pt[0], $zi2 ), self::fmul( $pt[1], $zi3 ) );
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Point doubling, a = 0 case (dbl-2009-l). */
|
||
|
|
private static function jdouble( $pt ) {
|
||
|
|
$c = self::consts();
|
||
|
|
list( $x, $y, $z ) = $pt;
|
||
|
|
if ( SP_BN::is_zero( $z ) || SP_BN::is_zero( $y ) ) {
|
||
|
|
return self::jinf();
|
||
|
|
}
|
||
|
|
$a = self::fmul( $x, $x ); // X^2
|
||
|
|
$b = self::fmul( $y, $y ); // Y^2
|
||
|
|
$cc = self::fmul( $b, $b ); // Y^4
|
||
|
|
$d = self::fmul( $c['two'], self::fsub( self::fmul( self::fadd( $x, $b ), self::fadd( $x, $b ) ), self::fadd( $a, $cc ) ) );
|
||
|
|
$e = self::fmul( $c['three'], $a );
|
||
|
|
$f = self::fmul( $e, $e );
|
||
|
|
$x3 = self::fsub( $f, self::fmul( $c['two'], $d ) );
|
||
|
|
$y3 = self::fsub( self::fmul( $e, self::fsub( $d, $x3 ) ), self::fmul( $c['eight'], $cc ) );
|
||
|
|
$z3 = self::fmul( self::fmul( $c['two'], $y ), $z );
|
||
|
|
return array( $x3, $y3, $z3 );
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Point addition, Jacobian + Jacobian (add-2007-bl). */
|
||
|
|
private static function jadd( $p1, $p2 ) {
|
||
|
|
$c = self::consts();
|
||
|
|
if ( self::is_inf( $p1 ) ) {
|
||
|
|
return $p2;
|
||
|
|
}
|
||
|
|
if ( self::is_inf( $p2 ) ) {
|
||
|
|
return $p1;
|
||
|
|
}
|
||
|
|
list( $x1, $y1, $z1 ) = $p1;
|
||
|
|
list( $x2, $y2, $z2 ) = $p2;
|
||
|
|
|
||
|
|
$z1z1 = self::fmul( $z1, $z1 );
|
||
|
|
$z2z2 = self::fmul( $z2, $z2 );
|
||
|
|
$u1 = self::fmul( $x1, $z2z2 );
|
||
|
|
$u2 = self::fmul( $x2, $z1z1 );
|
||
|
|
$s1 = self::fmul( $y1, self::fmul( $z2, $z2z2 ) );
|
||
|
|
$s2 = self::fmul( $y2, self::fmul( $z1, $z1z1 ) );
|
||
|
|
|
||
|
|
if ( 0 === SP_BN::cmp( $u1, $u2 ) ) {
|
||
|
|
return 0 === SP_BN::cmp( $s1, $s2 ) ? self::jdouble( $p1 ) : self::jinf();
|
||
|
|
}
|
||
|
|
|
||
|
|
$h = self::fsub( $u2, $u1 );
|
||
|
|
$i = self::fmul( self::fmul( $c['two'], $h ), self::fmul( $c['two'], $h ) );
|
||
|
|
$j = self::fmul( $h, $i );
|
||
|
|
$r = self::fmul( $c['two'], self::fsub( $s2, $s1 ) );
|
||
|
|
$v = self::fmul( $u1, $i );
|
||
|
|
$x3 = self::fsub( self::fsub( self::fmul( $r, $r ), $j ), self::fmul( $c['two'], $v ) );
|
||
|
|
$y3 = self::fsub( self::fmul( $r, self::fsub( $v, $x3 ) ), self::fmul( $c['two'], self::fmul( $s1, $j ) ) );
|
||
|
|
$z3 = self::fmul( self::fsub( self::fmul( self::fadd( $z1, $z2 ), self::fadd( $z1, $z2 ) ), self::fadd( $z1z1, $z2z2 ) ), $h );
|
||
|
|
return array( $x3, $y3, $z3 );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Scalar multiply, left-to-right double-and-add.
|
||
|
|
*
|
||
|
|
* Not constant time. It does not need to be: every scalar multiplied here
|
||
|
|
* is either a public value (recovery) or a per-signature nonce on a host
|
||
|
|
* where the attacker able to time it can already read the private key off
|
||
|
|
* disk.
|
||
|
|
*
|
||
|
|
* @return array{0:mixed,1:mixed}|null Affine point, or null for infinity.
|
||
|
|
*/
|
||
|
|
private static function mul_point( $k, $px, $py ) {
|
||
|
|
$c = self::consts();
|
||
|
|
$k = SP_BN::mod( $k, $c['n'] );
|
||
|
|
if ( SP_BN::is_zero( $k ) ) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
$base = self::to_jacobian( $px, $py );
|
||
|
|
$acc = self::jinf();
|
||
|
|
$bits = SP_BN::bits( $k );
|
||
|
|
$len = strlen( $bits );
|
||
|
|
for ( $i = 0; $i < $len; $i++ ) {
|
||
|
|
$acc = self::jdouble( $acc );
|
||
|
|
if ( '1' === $bits[ $i ] ) {
|
||
|
|
$acc = self::jadd( $acc, $base );
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return self::to_affine( $acc );
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------ public API
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Compressed public key for a private key.
|
||
|
|
*
|
||
|
|
* @param string $priv_bin 32 raw bytes.
|
||
|
|
* @return string 33 raw bytes, or '' if the key is out of range.
|
||
|
|
*/
|
||
|
|
public static function public_key( $priv_bin ) {
|
||
|
|
$c = self::consts();
|
||
|
|
$d = SP_BN::from_bin( $priv_bin );
|
||
|
|
if ( SP_BN::is_zero( $d ) || SP_BN::cmp( $d, $c['n'] ) >= 0 ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$pt = self::mul_point( $d, $c['gx'], $c['gy'] );
|
||
|
|
return self::compress( $pt );
|
||
|
|
}
|
||
|
|
|
||
|
|
/** @param array|null $pt Affine point. */
|
||
|
|
private static function compress( $pt ) {
|
||
|
|
if ( null === $pt ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
return chr( SP_BN::is_odd( $pt[1] ) ? 0x03 : 0x02 ) . SP_BN::to_bin( $pt[0], 32 );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sign a 32-byte digest, producing the 65-byte recoverable blob.
|
||
|
|
*
|
||
|
|
* Low-S normalised, so the same message and key always yield the same
|
||
|
|
* bytes and nothing downstream has to worry about malleability.
|
||
|
|
*
|
||
|
|
* @param string $digest 32 raw bytes (already hashed).
|
||
|
|
* @param string $priv_bin 32 raw bytes.
|
||
|
|
* @return string 65 raw bytes.
|
||
|
|
* @throws Exception When the key is out of range or the host has no bignum extension.
|
||
|
|
*/
|
||
|
|
public static function sign_recoverable( $digest, $priv_bin ) {
|
||
|
|
if ( ! SP_BN::available() ) {
|
||
|
|
throw new Exception( 'Sirius Press needs either the GMP or the BCMath PHP extension to sign.' );
|
||
|
|
}
|
||
|
|
if ( 32 !== strlen( $digest ) || 32 !== strlen( $priv_bin ) ) {
|
||
|
|
throw new Exception( 'sign_recoverable expects a 32-byte digest and a 32-byte private key.' );
|
||
|
|
}
|
||
|
|
$c = self::consts();
|
||
|
|
$d = SP_BN::from_bin( $priv_bin );
|
||
|
|
if ( SP_BN::is_zero( $d ) || SP_BN::cmp( $d, $c['n'] ) >= 0 ) {
|
||
|
|
throw new Exception( 'private key out of range' );
|
||
|
|
}
|
||
|
|
$z = SP_BN::mod( SP_BN::from_bin( $digest ), $c['n'] );
|
||
|
|
|
||
|
|
foreach ( self::rfc6979_nonces( $digest, $priv_bin ) as $k ) {
|
||
|
|
$pt = self::mul_point( $k, $c['gx'], $c['gy'] );
|
||
|
|
if ( null === $pt ) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
$r = SP_BN::mod( $pt[0], $c['n'] );
|
||
|
|
if ( SP_BN::is_zero( $r ) ) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
$s = SP_BN::mod(
|
||
|
|
SP_BN::mul( SP_BN::inv_mod( $k, $c['n'] ), SP_BN::add( $z, SP_BN::mul( $r, $d ) ) ),
|
||
|
|
$c['n']
|
||
|
|
);
|
||
|
|
if ( SP_BN::is_zero( $s ) ) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
$recid = ( SP_BN::is_odd( $pt[1] ) ? 1 : 0 ) | ( SP_BN::cmp( $pt[0], $c['n'] ) >= 0 ? 2 : 0 );
|
||
|
|
if ( SP_BN::cmp( $s, $c['half_n'] ) > 0 ) {
|
||
|
|
$s = SP_BN::sub( $c['n'], $s );
|
||
|
|
$recid ^= 1;
|
||
|
|
}
|
||
|
|
return chr( 27 + 4 + $recid ) . SP_BN::to_bin( $r, 32 ) . SP_BN::to_bin( $s, 32 );
|
||
|
|
}
|
||
|
|
throw new Exception( 'could not find a valid signature nonce' );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Recover the compressed public key that produced a 65-byte signature.
|
||
|
|
*
|
||
|
|
* @param string $sig 65 raw bytes, [flag|r|s].
|
||
|
|
* @param string $digest 32 raw bytes.
|
||
|
|
* @return string 33 raw bytes, or '' when the signature is malformed or
|
||
|
|
* does not correspond to any point on the curve.
|
||
|
|
*/
|
||
|
|
public static function recover( $sig, $digest ) {
|
||
|
|
if ( ! SP_BN::available() || 65 !== strlen( $sig ) || 32 !== strlen( $digest ) ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$c = self::consts();
|
||
|
|
$flag = ord( $sig[0] );
|
||
|
|
$recid = ( $flag - 27 ) & 3;
|
||
|
|
$r = SP_BN::from_bin( substr( $sig, 1, 32 ) );
|
||
|
|
$s = SP_BN::from_bin( substr( $sig, 33, 32 ) );
|
||
|
|
if ( SP_BN::is_zero( $r ) || SP_BN::is_zero( $s )
|
||
|
|
|| SP_BN::cmp( $r, $c['n'] ) >= 0 || SP_BN::cmp( $s, $c['n'] ) >= 0 ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
// x = r + (recid >> 1) * n, which is only on the curve for the
|
||
|
|
// overflow cases the signer flagged.
|
||
|
|
$x = $r;
|
||
|
|
if ( $recid >= 2 ) {
|
||
|
|
$x = SP_BN::add( $r, $c['n'] );
|
||
|
|
if ( SP_BN::cmp( $x, $c['p'] ) >= 0 ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// y^2 = x^3 + 7; p ≡ 3 (mod 4) so the square root is a single powmod.
|
||
|
|
$alpha = self::fadd( self::fmul( self::fmul( $x, $x ), $x ), $c['seven'] );
|
||
|
|
$exp = self::sqrt_exponent();
|
||
|
|
$beta = SP_BN::pow_mod( $alpha, $exp, $c['p'] );
|
||
|
|
// Reject non-residues: if beta^2 != alpha there is no such point.
|
||
|
|
if ( 0 !== SP_BN::cmp( self::fmul( $beta, $beta ), $alpha ) ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$want_odd = ( $recid & 1 ) === 1;
|
||
|
|
$y = ( SP_BN::is_odd( $beta ) === $want_odd ) ? $beta : SP_BN::sub( $c['p'], $beta );
|
||
|
|
|
||
|
|
// Q = r^-1 (sR - zG)
|
||
|
|
$z = SP_BN::mod( SP_BN::from_bin( $digest ), $c['n'] );
|
||
|
|
$rinv = SP_BN::inv_mod( $r, $c['n'] );
|
||
|
|
|
||
|
|
$sr = self::mul_point( $s, $x, $y );
|
||
|
|
$zg = self::mul_point( SP_BN::mod( SP_BN::sub( $c['n'], $z ), $c['n'] ), $c['gx'], $c['gy'] );
|
||
|
|
if ( null === $sr ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$sum = null === $zg
|
||
|
|
? self::to_jacobian( $sr[0], $sr[1] )
|
||
|
|
: self::jadd( self::to_jacobian( $sr[0], $sr[1] ), self::to_jacobian( $zg[0], $zg[1] ) );
|
||
|
|
$sum = self::to_affine( $sum );
|
||
|
|
if ( null === $sum ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$q = self::mul_point( $rinv, $sum[0], $sum[1] );
|
||
|
|
return self::compress( $q );
|
||
|
|
}
|
||
|
|
|
||
|
|
/** (p + 1) / 4, precomputed as hex — the square-root exponent. */
|
||
|
|
private static function sqrt_exponent() {
|
||
|
|
return SP_BN::from_hex( '3fffffffffffffffffffffffffffffffffffffffffffffffffffffffbfffff0c' );
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------- nonces
|
||
|
|
|
||
|
|
/**
|
||
|
|
* RFC 6979 deterministic nonces, yielded one at a time.
|
||
|
|
*
|
||
|
|
* Determinism matters here beyond tidiness: a PHP process on a shared host
|
||
|
|
* is exactly the place where a weak random source silently leaks the key
|
||
|
|
* across two signatures. Nothing in this path touches the system RNG.
|
||
|
|
*
|
||
|
|
* @return Generator
|
||
|
|
*/
|
||
|
|
private static function rfc6979_nonces( $digest, $priv_bin ) {
|
||
|
|
$c = self::consts();
|
||
|
|
$h1 = $digest;
|
||
|
|
// bits2octets(h1): reduce the digest mod n, re-pad to 32 bytes.
|
||
|
|
$h1_oct = SP_BN::to_bin( SP_BN::mod( SP_BN::from_bin( $h1 ), $c['n'] ), 32 );
|
||
|
|
|
||
|
|
$v = str_repeat( "\x01", 32 );
|
||
|
|
$k = str_repeat( "\x00", 32 );
|
||
|
|
|
||
|
|
$k = hash_hmac( 'sha256', $v . "\x00" . $priv_bin . $h1_oct, $k, true );
|
||
|
|
$v = hash_hmac( 'sha256', $v, $k, true );
|
||
|
|
$k = hash_hmac( 'sha256', $v . "\x01" . $priv_bin . $h1_oct, $k, true );
|
||
|
|
$v = hash_hmac( 'sha256', $v, $k, true );
|
||
|
|
|
||
|
|
for ( $attempt = 0; $attempt < 64; $attempt++ ) {
|
||
|
|
$v = hash_hmac( 'sha256', $v, $k, true );
|
||
|
|
$cand = SP_BN::from_bin( $v );
|
||
|
|
if ( ! SP_BN::is_zero( $cand ) && SP_BN::cmp( $cand, $c['n'] ) < 0 ) {
|
||
|
|
yield $cand;
|
||
|
|
}
|
||
|
|
$k = hash_hmac( 'sha256', $v . "\x00", $k, true );
|
||
|
|
$v = hash_hmac( 'sha256', $v, $k, true );
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|