sirius-press/tests/test-identity.php

198 lines
8.7 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
/**
* Addresses, derivation and the two signing envelopes.
*
* These are the pieces that decide who a user is. A bug here does not produce
* a crash it produces an account attached to the wrong key, or a login that
* accepts a signature it should not. So the checks lean hard on cases where
* a plausible-looking shortcut would be wrong: a flipped checksum character, a
* token-aware address spelling, a signature over a slightly different message.
*
* Addresses and the derived wallet were produced with libauth, the same
* library the Sirius portal uses.
*
* @package SiriusPress
*/
require_once __DIR__ . '/bootstrap.php';
T::group( 'CashAddress' );
$mainnet = 'bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h';
$chipnet = 'bchtest:qp63uahgrxged4z5jswyt5dn5v3lzsem6cq85x00dt';
$pub_one = SP_Secp256k1::public_key( hex2bin( str_repeat( '0', 63 ) . '1' ) );
T::is( SP_CashAddr::from_public_key( $pub_one, 'bitcoincash' ), $mainnet, 'mainnet address for key 1' );
T::is( SP_CashAddr::from_public_key( $pub_one, 'bchtest' ), $chipnet, 'chipnet address for the same key' );
T::ok( SP_CashAddr::is_valid( $mainnet ), 'a good address validates' );
T::ok( ! SP_CashAddr::is_valid( substr( $mainnet, 0, -1 ) . 'q' ), 'a corrupted checksum is rejected' );
T::ok( ! SP_CashAddr::is_valid( 'bitcoincash:notanaddressatall' ), 'nonsense is rejected' );
T::ok( ! SP_CashAddr::is_valid( '' ), 'an empty string is rejected' );
T::ok( ! SP_CashAddr::is_valid( 'walerikus@example.com' ), 'an email address is not an address' );
// The prefix is part of the checksum, so a mainnet payload under a testnet
// prefix must not validate. Without this, a chipnet signature could
// authenticate a mainnet account.
$payload = substr( $mainnet, strlen( 'bitcoincash:' ) );
T::ok( ! SP_CashAddr::is_valid( 'bchtest:' . $payload ), 'a mainnet payload does not validate under the chipnet prefix' );
$decoded = SP_CashAddr::decode( $mainnet );
T::is( $decoded['prefix'], 'bitcoincash', 'decode reports the prefix' );
T::is( $decoded['type'], SP_CashAddr::TYPE_P2PKH, 'decode reports the type' );
T::is( strlen( $decoded['hash'] ), 20, 'decode returns a 20-byte hash' );
// Token-aware and plain spellings of one key must resolve to one account.
$token = SP_CashAddr::encode( 'bitcoincash', $decoded['hash'], SP_CashAddr::TYPE_P2PKH_TOKEN );
T::ok( $token !== $mainnet, 'the token-aware spelling differs' );
T::is( SP_CashAddr::normalize( $token ), $mainnet, 'the token-aware spelling normalises to the plain one' );
T::is( SP_CashAddr::normalize( strtoupper( $mainnet ) ), $mainnet, 'case does not create a second identity' );
T::is( SP_CashAddr::normalize( 'nonsense' ), '', 'normalising a non-address gives an empty string' );
// A bare payload, which is how half the ecosystem displays an address.
T::is( SP_CashAddr::normalize( $payload ), $mainnet, 'a prefix-less address is understood' );
T::group( 'BIP-39 and BIP-32' );
// Generated by libauth's BuiltInWallet; the address is what the Sirius portal
// shows for this phrase.
$phrase = 'trash key flip dawn impulse float medal rain sell hand neither hub';
$key = SP_HD::publishing_key( $phrase, 'bchtest' );
T::is( $key['address'], 'bchtest:qrq05hk8hurcsjx0slw4yjknmlujfzme3vxjhtwpwy', 'derives the address the portal wallet derives' );
T::is( strlen( $key['private'] ), 32, 'the private key is 32 bytes' );
T::is( strlen( $key['public'] ), 33, 'the public key is compressed' );
// Normalisation must not change the key: a phrase pasted with odd spacing or
// capitals is the same wallet, and treating it as a different one would look
// to the user like their funds had vanished.
T::is(
SP_HD::publishing_key( " Trash KEY flip dawn impulse\tfloat medal rain sell hand neither hub \n", 'bchtest' )['address'],
$key['address'],
'spacing and capitals do not change the wallet'
);
// A different path is a different wallet — this is the single most likely
// cause of an unexplained 403 from the gateway.
T::ok(
SP_HD::publishing_key( $phrase, 'bchtest', "m/44'/0'/0'/0/0" )['address'] !== $key['address'],
'a different derivation path gives a different address'
);
T::is( SP_HD::phrase_problem( $phrase ), '', 'a good phrase has no problem' );
T::ok( '' !== SP_HD::phrase_problem( 'too few words' ), 'a short phrase is reported' );
T::ok( '' !== SP_HD::phrase_problem( '' ), 'an empty phrase is reported' );
T::group( 'BIP-137 message signatures' );
$message = "Sign in to Example\n\nSite: https://example.test/\nNonce: 1.2.3";
// The digest a wallet computes. Checked against the Theseus wallet's own
// implementation (bundled-addons/aegis/lib/chain-bch.js).
T::is(
bin2hex( SP_Message::bip137_digest( 'SIRIUS-PRESS-LOGIN1' . "\n" . 'https://example.bch' . "\n" . 'bchtest:qrq05hk8hurcsjx0slw4yjknmlujfzme3vxjhtwpwy' . "\n" . 'abc123' . "\n" . '1758412800000' ) ),
'98d1b0e89586b933f1117fcd0dd167fe126ec5bb8e34f28685890f6030befe19',
'the message digest matches the Theseus wallet'
);
$signature = SP_Message::sign( $message, $key['private'] );
T::ok( SP_Message::verify( $message, $signature, $key['address'] ), 'a signature verifies against its own address' );
T::ok( ! SP_Message::verify( $message . ' ', $signature, $key['address'] ), 'a trailing space breaks verification' );
T::ok( ! SP_Message::verify( $message, $signature, $mainnet ), 'the signature does not verify against another address' );
T::ok( ! SP_Message::verify( $message, 'not base64 at all!!', $key['address'] ), 'garbage is not a signature' );
T::ok( ! SP_Message::verify( $message, base64_encode( 'short' ), $key['address'] ), 'a short signature is refused' );
T::ok( ! SP_Message::verify( $message, $signature, 'bitcoincash:notanaddress' ), 'an invalid claimed address is refused' );
// A token-aware address must still verify — a user may present either form.
T::ok(
SP_Message::verify(
$message,
$signature,
SP_CashAddr::encode( 'bchtest', SP_CashAddr::decode( $key['address'] )['hash'], SP_CashAddr::TYPE_P2PKH_TOKEN )
),
'the token-aware spelling of the signer verifies'
);
// A message longer than 252 bytes crosses the varint boundary in the BIP-137
// payload. Getting that wrong produces signatures no other wallet accepts.
$long = str_repeat( 'x', 300 );
T::ok(
SP_Message::verify( $long, SP_Message::sign( $long, $key['private'] ), $key['address'] ),
'a 300-byte message signs and verifies across the varint boundary'
);
T::group( 'BNS-SITE1 upload envelope' );
T::is(
bin2hex( SP_Message::site_digest( 'example.bch', 'index.html', '<h1>hi</h1>', 1758412800000 ) ),
hash( 'sha256', "BNS-SITE1\nexample.bch\nindex.html\n" . hash( 'sha256', '<h1>hi</h1>' ) . "\n1758412800000" ),
'the upload digest matches the gateway formula'
);
$headers = SP_Message::site_headers( 'example.bch', 'index.html', '<h1>hi</h1>', $key['private'], 1758412800000 );
T::is( $headers['x-bns-ts'], '1758412800000', 'the timestamp header is the one signed' );
T::is( strlen( base64_decode( $headers['x-bns-sig'], true ) ), 65, 'the signature header decodes to 65 bytes' );
// The gateway recovers the signer and compares it to the on-chain owner.
// Reproduce that here so a change to either side is caught.
T::is(
SP_CashAddr::from_public_key(
SP_Secp256k1::recover(
base64_decode( $headers['x-bns-sig'], true ),
SP_Message::site_digest( 'example.bch', 'index.html', '<h1>hi</h1>', 1758412800000 )
),
'bchtest'
),
$key['address'],
'the gateway would recover the publishing address from the header'
);
// Changing any signed component must invalidate it.
T::ok(
SP_CashAddr::from_public_key(
SP_Secp256k1::recover(
base64_decode( $headers['x-bns-sig'], true ),
SP_Message::site_digest( 'example.bch', 'index.html', '<h1>TAMPERED</h1>', 1758412800000 )
),
'bchtest'
) !== $key['address'],
'altered upload bytes no longer recover the publishing address'
);
T::group( 'canonical JSON' );
T::is(
SP_Message::canonical_json( array( 'b' => 1, 'a' => 2 ) ),
'{"a":2,"b":1}',
'keys are sorted'
);
T::is(
SP_Message::canonical_json( array( 'a' => null, 'b' => 1 ) ),
'{"b":1}',
'null members are dropped'
);
T::is(
SP_Message::canonical_json( array( 'z' => array( 'y' => 1, 'x' => 2 ) ) ),
'{"z":{"x":2,"y":1}}',
'nested keys are sorted too'
);
T::is(
SP_Message::canonical_json( array( 'url' => 'https://a/b' ) ),
'{"url":"https://a/b"}',
'slashes are not escaped, matching JSON.stringify'
);
// Written as a concatenation so the expected value cannot itself be stored as
// a literal e-acute, which would make this assertion test nothing.
T::is(
SP_Message::canonical_json( array( 'note' => 'caf' . chr( 0xc3 ) . chr( 0xa9 ) ) ),
'{"note":"caf' . chr( 92 ) . 'u00e9"}',
'non-ASCII is backslash-u escaped'
);
T::is(
SP_Message::canonical_json( array( 'list' => array( 3, 1, 2 ) ) ),
'{"list":[3,1,2]}',
'array order is preserved'
);
exit( T::summary() );