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

270 lines
8.1 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
/**
* CashAddress encoding the fork's replacement for the email address.
*
* Wherever WordPress core would have stored, validated or compared a
* `user_email`, Sirius Press stores, validates and compares one of these. The
* format is the Bitcoin Cash address spec: a human-readable prefix, a colon,
* and base32 of [version byte | hash160] with a 40-bit BCH checksum.
*
* Two prefixes matter in practice: `bitcoincash` for mainnet names and
* `bchtest` for chipnet, which is what the test instances and the registrar's
* staging flow use. Token-aware forms (types 2 and 3) decode too, because a
* name certificate lives at a token address and users copy whichever form
* their wallet showed them but they normalise to the same hash160, so a
* user who signs up with one form and signs in with the other is the same
* person as far as the auth plugin is concerned.
*
* @package SiriusPress
*/
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
final class SP_CashAddr {
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const TYPE_P2PKH = 0;
const TYPE_P2SH = 1;
const TYPE_P2PKH_TOKEN = 2;
const TYPE_P2SH_TOKEN = 3;
/**
* Encode a 20-byte hash160 as a CashAddress.
*
* @param string $prefix e.g. 'bitcoincash' or 'bchtest'.
* @param string $hash 20 raw bytes.
* @param int $type One of the TYPE_* constants.
* @return string 'prefix:payload', or '' on bad input.
*/
public static function encode( $prefix, $hash, $type = self::TYPE_P2PKH ) {
$prefix = strtolower( (string) $prefix );
if ( 20 !== strlen( $hash ) || ! preg_match( '/^[a-z0-9]{1,60}$/', $prefix ) ) {
return '';
}
// Version byte: type in the high nibble, size code in the low bits.
// 20-byte hashes are size code 0, which is every address we mint.
$payload = chr( ( $type << 3 ) | 0 ) . $hash;
$data = self::convert_bits( self::bytes_to_array( $payload ), 8, 5, true );
if ( null === $data ) {
return '';
}
$checksum = self::checksum( $prefix, array_merge( $data, array_fill( 0, 8, 0 ) ) );
$out = '';
foreach ( array_merge( $data, $checksum ) as $v ) {
$out .= self::CHARSET[ $v ];
}
return $prefix . ':' . $out;
}
/**
* Decode a CashAddress.
*
* Accepts the address with or without its prefix; a bare payload is tried
* against both known prefixes, because half the UIs in the ecosystem strip
* the prefix when they display an address and users paste what they see.
*
* @param string $addr
* @return array{prefix:string,type:int,hash:string}|null
*/
public static function decode( $addr ) {
$addr = trim( (string) $addr );
if ( '' === $addr ) {
return null;
}
if ( false !== strpos( $addr, ':' ) ) {
list( $prefix, $payload ) = explode( ':', strtolower( $addr ), 2 );
return self::decode_parts( $prefix, $payload );
}
foreach ( array( 'bitcoincash', 'bchtest', 'bchreg' ) as $prefix ) {
$r = self::decode_parts( $prefix, strtolower( $addr ) );
if ( null !== $r ) {
return $r;
}
}
return null;
}
private static function decode_parts( $prefix, $payload ) {
if ( ! preg_match( '/^[a-z0-9]{1,60}$/', $prefix ) || ! preg_match( '/^[' . self::CHARSET . ']{8,124}$/', $payload ) ) {
return null;
}
$values = array();
$len = strlen( $payload );
for ( $i = 0; $i < $len; $i++ ) {
$pos = strpos( self::CHARSET, $payload[ $i ] );
if ( false === $pos ) {
return null;
}
$values[] = $pos;
}
$sum = self::poly_mod( array_merge( self::expand_prefix( $prefix ), $values ) );
if ( 0 !== $sum ) {
return null;
}
$data = self::convert_bits( array_slice( $values, 0, count( $values ) - 8 ), 5, 8, false );
if ( null === $data || count( $data ) < 21 ) {
return null;
}
$version = $data[0];
$hash = self::array_to_bytes( array_slice( $data, 1 ) );
// Only the 20-byte size code is ever produced by a wallet in this
// ecosystem; anything else is a paste of something that is not an
// address we can check a signature against.
if ( 0 !== ( $version & 0x07 ) || 20 !== strlen( $hash ) ) {
return null;
}
return array(
'prefix' => $prefix,
'type' => ( $version >> 3 ) & 0x0f,
'hash' => $hash,
);
}
/** True when `$addr` is a well-formed CashAddress with a valid checksum. */
public static function is_valid( $addr ) {
return null !== self::decode( $addr );
}
/**
* Canonical form for storage and comparison: lowercase, prefixed, and
* always the non-token p2pkh spelling.
*
* Two spellings of the same key must not become two accounts, so every
* address is squashed to this before it is written to or compared against
* the users table.
*
* @return string '' when the input is not an address.
*/
public static function normalize( $addr ) {
$d = self::decode( $addr );
if ( null === $d ) {
return '';
}
$type = ( self::TYPE_P2SH === $d['type'] || self::TYPE_P2SH_TOKEN === $d['type'] )
? self::TYPE_P2SH
: self::TYPE_P2PKH;
return self::encode( $d['prefix'], $d['hash'], $type );
}
/** The address a compressed public key controls. */
public static function from_public_key( $pubkey_bin, $prefix = 'bitcoincash' ) {
if ( 33 !== strlen( $pubkey_bin ) && 65 !== strlen( $pubkey_bin ) ) {
return '';
}
$hash = hash( 'ripemd160', hash( 'sha256', $pubkey_bin, true ), true );
return self::encode( $prefix, $hash, self::TYPE_P2PKH );
}
/** A short, human-checkable form for UI: `bchtest:qr4…8ktm`. */
public static function shorten( $addr, $head = 8, $tail = 6 ) {
$addr = (string) $addr;
$pos = strpos( $addr, ':' );
$body = false === $pos ? $addr : substr( $addr, $pos + 1 );
$pre = false === $pos ? '' : substr( $addr, 0, $pos + 1 );
if ( strlen( $body ) <= $head + $tail + 1 ) {
return $addr;
}
return $pre . substr( $body, 0, $head ) . '…' . substr( $body, -$tail );
}
// --------------------------------------------------------------- checksum
private static function expand_prefix( $prefix ) {
$out = array();
$len = strlen( $prefix );
for ( $i = 0; $i < $len; $i++ ) {
$out[] = ord( $prefix[ $i ] ) & 0x1f;
}
$out[] = 0;
return $out;
}
private static function checksum( $prefix, $data ) {
$mod = self::poly_mod( array_merge( self::expand_prefix( $prefix ), $data ) );
$out = array();
for ( $i = 0; $i < 8; $i++ ) {
$out[] = ( $mod >> ( 5 * ( 7 - $i ) ) ) & 0x1f;
}
return $out;
}
/**
* The BCH checksum polynomial over GF(32).
*
* Works on 40-bit accumulators, so this needs a 64-bit PHP build. 32-bit
* PHP has been unsupported by WordPress-adjacent hosting for years; the
* core plugin's requirements check says so explicitly rather than letting
* addresses silently fail to validate here.
*/
private static function poly_mod( $values ) {
$c = 1;
foreach ( $values as $d ) {
$c0 = ( $c >> 35 ) & 0xff;
$c = ( ( $c & 0x07ffffffff ) << 5 ) ^ $d;
if ( $c0 & 0x01 ) {
$c ^= 0x98f2bc8e61;
}
if ( $c0 & 0x02 ) {
$c ^= 0x79b76d99e2;
}
if ( $c0 & 0x04 ) {
$c ^= 0xf33e5fb3c4;
}
if ( $c0 & 0x08 ) {
$c ^= 0xae2eabe2a8;
}
if ( $c0 & 0x10 ) {
$c ^= 0x1e4f43e470;
}
}
return $c ^ 1;
}
// ------------------------------------------------------------- bit fiddling
private static function bytes_to_array( $bin ) {
return array_values( unpack( 'C*', $bin ) );
}
private static function array_to_bytes( $arr ) {
$out = '';
foreach ( $arr as $v ) {
$out .= chr( $v & 0xff );
}
return $out;
}
/**
* Regroup a list of values from `$from` bits each to `$to` bits each.
*
* @param bool $pad Pad the tail (encoding) or require it to be zero (decoding).
* @return array|null
*/
private static function convert_bits( $values, $from, $to, $pad ) {
$acc = 0;
$bits = 0;
$out = array();
$max = ( 1 << $to ) - 1;
foreach ( $values as $v ) {
if ( $v < 0 || ( $v >> $from ) !== 0 ) {
return null;
}
$acc = ( $acc << $from ) | $v;
$bits += $from;
while ( $bits >= $to ) {
$bits -= $to;
$out[] = ( $acc >> $bits ) & $max;
}
}
if ( $pad ) {
if ( $bits > 0 ) {
$out[] = ( $acc << ( $to - $bits ) ) & $max;
}
} elseif ( $bits >= $from || ( ( $acc << ( $to - $bits ) ) & $max ) ) {
return null;
}
return $out;
}
}