sirius-press/plugins/sirius-press-core/includes/class-sp-secp256k1.php
Silent Mode 5465b65756 feat(sirius-press): a WordPress where the account is a key, not a mailbox
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.
2026-09-21 01:39:38 +02:00

375 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 );
}
}
}