139 lines
5.5 KiB
PHP
139 lines
5.5 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* The cryptography, against fixed vectors.
|
||
|
|
*
|
||
|
|
* Every number in here was produced by something other than this code —
|
||
|
|
* published secp256k1 and RIPEMD-160 test vectors, and libauth, which is the
|
||
|
|
* library the Sirius portal wallet and the BNS gateway both use. If this file
|
||
|
|
* passes, a signature made in Sirius Press verifies at the gateway and a
|
||
|
|
* signature made in the portal verifies here.
|
||
|
|
*
|
||
|
|
* That cross-check is the point. A self-consistent implementation that signs
|
||
|
|
* and verifies its own output proves nothing: it would pass just as happily
|
||
|
|
* with the wrong curve.
|
||
|
|
*
|
||
|
|
* @package SiriusPress
|
||
|
|
*/
|
||
|
|
|
||
|
|
require_once __DIR__ . '/bootstrap.php';
|
||
|
|
|
||
|
|
T::group( 'big numbers' );
|
||
|
|
|
||
|
|
T::ok( SP_BN::available(), 'a bignum backend is available (' . SP_BN::backend() . ')' );
|
||
|
|
T::is( SP_BN::to_hex( SP_BN::from_hex( 'deadbeef' ), 8 ), 'deadbeef', 'hex round-trips' );
|
||
|
|
T::is( SP_BN::to_hex( SP_BN::from_int( 255 ), 2 ), 'ff', 'integers convert' );
|
||
|
|
T::is(
|
||
|
|
SP_BN::to_hex( SP_BN::mod( SP_BN::from_int( -7 ), SP_BN::from_int( 5 ) ), 1 ),
|
||
|
|
'3',
|
||
|
|
'mod of a negative is a non-negative residue'
|
||
|
|
);
|
||
|
|
T::is( SP_BN::bits( SP_BN::from_int( 13 ) ), '1101', 'bit decomposition' );
|
||
|
|
T::ok( SP_BN::is_odd( SP_BN::from_int( 7 ) ), '7 is odd' );
|
||
|
|
T::ok( ! SP_BN::is_odd( SP_BN::from_int( 8 ) ), '8 is not odd' );
|
||
|
|
|
||
|
|
// a^-1 * a == 1 (mod p)
|
||
|
|
$p = SP_BN::from_hex( SP_Secp256k1::P );
|
||
|
|
$a = SP_BN::from_hex( '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' );
|
||
|
|
$prod = SP_BN::mod( SP_BN::mul( $a, SP_BN::inv_mod( $a, $p ) ), $p );
|
||
|
|
T::is( SP_BN::to_hex( $prod, 1 ), '1', 'modular inverse' );
|
||
|
|
|
||
|
|
T::group( 'secp256k1' );
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Generator multiples. These are the canonical public keys for private keys
|
||
|
|
* 1, 2 and 3 — any implementation that gets these wrong is not on the curve
|
||
|
|
* everyone else is using.
|
||
|
|
*/
|
||
|
|
$vectors = array(
|
||
|
|
'0000000000000000000000000000000000000000000000000000000000000001' => '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() );
|