sirius-press/plugins/sirius-press-core/includes/class-sp-secp256k1.php

376 lines
13 KiB
PHP
Raw Normal View History

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