'0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', '0000000000000000000000000000000000000000000000000000000000000002' => '02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5', '0000000000000000000000000000000000000000000000000000000000000003' => '02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9', // n-1 gives -G: the same x as G, the other y, so the parity byte flips // from 02 to 03. A good check that compression reads y and not something // that merely correlates with it. 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140' => '0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', ); foreach ( $vectors as $priv => $expected ) { T::is( bin2hex( SP_Secp256k1::public_key( hex2bin( $priv ) ) ), $expected, 'public key for ' . substr( $priv, -8 ) ); } T::is( SP_Secp256k1::public_key( str_repeat( "\x00", 32 ) ), '', 'a zero private key is refused' ); T::is( SP_Secp256k1::public_key( hex2bin( 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141' ) ), '', 'a private key equal to the group order is refused' ); // Sign, then recover: the public key must come back out. $priv = hex2bin( str_repeat( '0', 63 ) . '1' ); $digest = hash( 'sha256', 'hello sirius', true ); $sig = SP_Secp256k1::sign_recoverable( $digest, $priv ); T::is( strlen( $sig ), 65, 'a signature is 65 bytes' ); T::is( bin2hex( $sig ), '1f5ec23ce0b5572febc59f09cb5b93e73e7b8ced06dbffa5cae138faf0cc4d06236093c3e0296d6d76cd811eb29c7e4b6ba30c430313e8e2e18666a3edc277a5c8', 'the signature is byte-identical to libauth for the same key and digest' ); T::is( bin2hex( SP_Secp256k1::recover( $sig, $digest ) ), bin2hex( SP_Secp256k1::public_key( $priv ) ), 'recovery returns the signing key' ); T::ok( SP_Secp256k1::recover( $sig, hash( 'sha256', 'a different message', true ) ) !== SP_Secp256k1::public_key( $priv ), 'a tampered digest does not recover the signing key' ); // Determinism: RFC 6979 means the same inputs always give the same bytes. T::is( bin2hex( SP_Secp256k1::sign_recoverable( $digest, $priv ) ), bin2hex( $sig ), 'signing is deterministic' ); // Malformed input must be refused rather than half-processed. T::is( SP_Secp256k1::recover( 'short', $digest ), '', 'a short signature is refused' ); T::is( SP_Secp256k1::recover( str_repeat( "\x00", 65 ), $digest ), '', 'an all-zero signature is refused' ); T::group( 'low-S normalisation' ); /* * Every signature must have S in the lower half of the order. A high-S * signature is still valid arithmetic but is the malleable twin of the low-S * one, and tooling across the ecosystem rejects it. */ $half_n = SP_BN::from_hex( '7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0' ); $all_low = true; for ( $i = 1; $i <= 8; $i++ ) { $k = hash( 'sha256', "key {$i}", true ); $d = hash( 'sha256', "message {$i}", true ); $s = SP_BN::from_bin( substr( SP_Secp256k1::sign_recoverable( $d, $k ), 33, 32 ) ); if ( SP_BN::cmp( $s, $half_n ) > 0 ) { $all_low = false; } } T::ok( $all_low, 'S is in the lower half of the order across 8 signatures' ); T::group( 'many round trips' ); $round_trips = true; for ( $i = 1; $i <= 12; $i++ ) { $k = hash( 'sha256', "roundtrip key {$i}", true ); $d = hash( 'sha256', "roundtrip message {$i}", true ); $s = SP_Secp256k1::sign_recoverable( $d, $k ); if ( SP_Secp256k1::recover( $s, $d ) !== SP_Secp256k1::public_key( $k ) ) { $round_trips = false; echo " failed at iteration {$i}\n"; } } // Twelve is enough to catch the recovery-id bug that only shows on the // signatures where R.y happens to be odd — which is half of them. T::ok( $round_trips, '12 sign-and-recover round trips, covering both recovery ids' ); exit( T::summary() );