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

180 lines
6.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
/**
* BIP39 + BIP32, enough of them to turn a recovery phrase into the one key
* that matters.
*
* The publishing wallet is not a new secret invented by the CMS it is the
* same wallet that holds the name's NFT certificate, because the BNS gateway
* only accepts writes signed by the current on-chain owner. So Sirius Press
* has to walk the identical derivation the portal wallet walks
* (Argus/src/lib/wallet-web.js): BIP39 phrase to seed, then m/44'/145'/0'/0/0.
*
* A different path here would produce a different address, the gateway would
* return 403, and the failure would look like a signing bug rather than a
* derivation mismatch so the path is a constant, not a setting, and the
* tests pin it against libauth.
*
* @package SiriusPress
*/
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
require_once __DIR__ . '/class-sp-bn.php';
require_once __DIR__ . '/class-sp-secp256k1.php';
require_once __DIR__ . '/class-sp-cashaddr.php';
final class SP_HD {
/** BCH coin type 145, account 0, external branch, first address. */
const DEFAULT_PATH = "m/44'/145'/0'/0/0";
/**
* BIP39 phrase to 64-byte seed.
*
* @param string $mnemonic Space-separated words. Normalised the same way
* the portal normalises: trimmed, lowercased,
* runs of whitespace collapsed.
* @param string $passphrase Optional BIP39 passphrase ("25th word").
*/
public static function seed_from_mnemonic( $mnemonic, $passphrase = '' ) {
$clean = self::normalize_mnemonic( $mnemonic );
return hash_pbkdf2( 'sha512', $clean, 'mnemonic' . $passphrase, 2048, 64, true );
}
public static function normalize_mnemonic( $mnemonic ) {
return trim( preg_replace( '/\s+/u', ' ', strtolower( (string) $mnemonic ) ) );
}
/**
* Master key from a seed.
*
* @return array{key:string,chain:string} 32 raw bytes each.
*/
public static function master( $seed ) {
$i = hash_hmac( 'sha512', $seed, 'Bitcoin seed', true );
return array(
'key' => substr( $i, 0, 32 ),
'chain' => substr( $i, 32, 32 ),
);
}
/**
* One CKDpriv step.
*
* @param array $node {key, chain}.
* @param int $index Child index; add 0x80000000 for a hardened step.
* @return array{key:string,chain:string}
* @throws Exception If the derived key is invalid (probability ~2^-127).
*/
public static function derive_child( $node, $index ) {
$hardened = $index >= 0x80000000;
if ( $hardened ) {
$data = "\x00" . $node['key'];
} else {
$pub = SP_Secp256k1::public_key( $node['key'] );
if ( '' === $pub ) {
throw new Exception( 'cannot derive public key for a hardened-only node' );
}
$data = $pub;
}
$data .= pack( 'N', $index );
$i = hash_hmac( 'sha512', $data, $node['chain'], true );
$il = substr( $i, 0, 32 );
$ir = substr( $i, 32, 32 );
$n = SP_BN::from_hex( SP_Secp256k1::N );
$tweak = SP_BN::from_bin( $il );
if ( SP_BN::cmp( $tweak, $n ) >= 0 ) {
throw new Exception( 'derived tweak out of range — pick the next index' );
}
$child = SP_BN::mod( SP_BN::add( $tweak, SP_BN::from_bin( $node['key'] ) ), $n );
if ( SP_BN::is_zero( $child ) ) {
throw new Exception( 'derived key is zero — pick the next index' );
}
return array(
'key' => SP_BN::to_bin( $child, 32 ),
'chain' => $ir,
);
}
/**
* Walk a full path such as "m/44'/145'/0'/0/0".
*
* @return array{key:string,chain:string}
* @throws Exception On a malformed path.
*/
public static function derive_path( $seed, $path = self::DEFAULT_PATH ) {
$node = self::master( $seed );
$parts = preg_split( '#/#', trim( (string) $path ) );
if ( ! $parts || 'm' !== strtolower( $parts[0] ) ) {
throw new Exception( 'derivation path must start with "m"' );
}
foreach ( array_slice( $parts, 1 ) as $part ) {
if ( '' === $part ) {
continue;
}
$hardened = ( "'" === substr( $part, -1 ) || 'h' === strtolower( substr( $part, -1 ) ) );
$num = (int) rtrim( $part, "'hH" );
if ( $num < 0 || $num > 0x7fffffff ) {
throw new Exception( "derivation index out of range: {$part}" );
}
$node = self::derive_child( $node, $hardened ? $num + 0x80000000 : $num );
}
return $node;
}
/**
* The private key and address a phrase publishes with.
*
* @param string $mnemonic
* @param string $prefix 'bitcoincash' or 'bchtest'.
* @param string $path Override only for wallets minted on another path
* (operator wallets were created on BTC coin type).
* @return array{private:string,public:string,address:string}
* @throws Exception
*/
public static function publishing_key( $mnemonic, $prefix = 'bitcoincash', $path = self::DEFAULT_PATH ) {
$node = self::derive_path( self::seed_from_mnemonic( $mnemonic ), $path );
$pub = SP_Secp256k1::public_key( $node['key'] );
if ( '' === $pub ) {
throw new Exception( 'derivation produced an unusable key' );
}
return array(
'private' => $node['key'],
'public' => $pub,
'address' => SP_CashAddr::from_public_key( $pub, $prefix ),
);
}
/**
* Sanity-check a phrase before storing it.
*
* Deliberately structural rather than a full BIP39 checksum test: this
* fork must accept phrases from every wallet a name owner might already
* use, and refusing one over a wordlist we shipped a year ago is a worse
* failure than accepting one that later derives the wrong address. The
* real check is the address comparison the settings screen does right
* after: derive, show the address, and let the owner confirm it is theirs.
*
* @return string '' when acceptable, otherwise a human-readable reason.
*/
public static function phrase_problem( $mnemonic ) {
$clean = self::normalize_mnemonic( $mnemonic );
if ( '' === $clean ) {
return 'The recovery phrase is empty.';
}
$words = explode( ' ', $clean );
$count = count( $words );
if ( ! in_array( $count, array( 12, 15, 18, 21, 24 ), true ) ) {
return sprintf( 'A recovery phrase has 12, 15, 18, 21 or 24 words — this one has %d.', $count );
}
foreach ( $words as $w ) {
if ( ! preg_match( '/^[a-z]{3,8}$/', $w ) ) {
return sprintf( '"%s" does not look like a recovery-phrase word.', $w );
}
}
return '';
}
}