WordPress makes two assumptions this project cannot accept: that identity comes from an email address, and that a site lives at one server. Both are things somebody else can take away — a mailbox is rented from a provider who can close it or be compelled to open it, and a server is one seizure from being gone. Sirius Press replaces the first and hedges the second. Signing in means signing a challenge with the key that controls a CashAddress. The address is recovered from the signature, so nothing is typed but the signature itself, and the result is an ordinary WordPress session cookie — roles, capabilities, nonces and the REST API never learn the login was different. Three ways to produce one: a wallet the browser already exposes, a phrase used once in the page and wiped, or a signature pasted in from any BIP-137 wallet, which needs no JavaScript and lets the key stay on a machine that never touches the web. There is no password reset, and the recovery page says so plainly rather than offering a form that cannot work. A reset mechanism is by construction a way to take an account from its owner, and it is always easier to attack than the cryptography it bypasses. Publishing a post also exports it as static HTML to the name's storage on Sia, signed by the key that owns the name, so the site keeps answering when the server does not. Email as a feature is untouched. wp_mail() still works, SMTP still sends, and contact forms still deliver to addresses real people typed. Only mail to the site's own unroutable placeholder addresses is diverted to an in-app inbox. The objection was to email as identity, not to email. Core is pinned and patched rather than vendored. WordPress 7.1.1 is 149 MB and 5,008 files; the fork's entire core diff is 75 lines in wp-admin/install.php. Carrying the former to express the latter would bury the patch where nobody reviews it and make every clone of the monorepo pay for it. Upstream releases still merge through tools/update-wordpress.sh, which reapplies the series and says exactly which hunk needs a human. The cryptography is implemented twice — PHP on the server, JavaScript in the page — because the server must verify and the browser must sign. Both are pinned against libauth, the library the Sirius portal wallet and the BNS gateway already use, so a disagreement of one byte fails the test suite rather than presenting as a rejected login at three in the morning. 132 checks, no framework, about a second.
138 lines
5.5 KiB
PHP
138 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() );
|