sirius-press/tests/interop.mjs

175 lines
6.7 KiB
JavaScript
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
// The browser wallet against the PHP one.
//
// Sirius Press has two independent implementations of the same cryptography:
// PHP on the server, which verifies, and JavaScript in the page, which signs.
// If they disagree by one byte, nobody can log in — and the error will look
// like a rejected password rather than a hash mismatch, which is a miserable
// thing to debug at three in the morning.
//
// So this compares them directly. Every vector is the output of the other
// implementation, not of this one.
//
// node tests/interop.mjs
//
// Needs no dependencies: the wallet is loaded as plain source, with `window`
// pointed at the Node global so WebCrypto is found where the browser puts it.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const assets = join(here, "..", "plugins", "sirius-press-auth", "assets");
globalThis.window = globalThis;
new Function(readFileSync(join(assets, "bip39-en.js"), "utf8"))();
new Function(readFileSync(join(assets, "wallet.js"), "utf8"))();
const W = window.SiriusWallet;
const I = W._internals;
const hex = (b) => [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
let passed = 0;
let failed = 0;
function is(actual, expected, what) {
if (actual === expected) {
passed++;
console.log(` ok ${what}`);
} else {
failed++;
console.log(` FAIL ${what}`);
console.log(` expected: ${expected}`);
console.log(` actual: ${actual}`);
}
}
function ok(condition, what) {
is(Boolean(condition), true, what);
}
// --- RIPEMD-160, against the published vectors -------------------------------
// WebCrypto has no RIPEMD-160, so the wallet carries its own. These are the
// reference values from the algorithm's own specification.
console.log("\n RIPEMD-160");
const enc = new TextEncoder();
is(hex(I.ripemd160(new Uint8Array(0))), "9c1185a5c5e9fc54612808977ee8f548b2258d31", 'the empty string');
is(hex(I.ripemd160(enc.encode("abc"))), "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc", '"abc"');
is(
hex(I.ripemd160(enc.encode("message digest"))),
"5d0689ef49d2fae572b881b123a85ffa21595f36",
'"message digest"',
);
is(
hex(I.ripemd160(enc.encode("abcdefghijklmnopqrstuvwxyz"))),
"f71c27109c692c1b56bbdceb5b9d2865b3708dbc",
"the lowercase alphabet",
);
// Longer than one 64-byte block, which exercises the padding and the length
// field — the parts a single-block test never reaches.
is(
hex(I.ripemd160(enc.encode("1234567890".repeat(8)))),
"9b752e45573d4b39f4dbd3323cab82bf63326bfb",
"eighty bytes, spanning two blocks",
);
// --- secp256k1 ---------------------------------------------------------------
console.log("\n secp256k1");
const privOne = Uint8Array.from([...new Array(31).fill(0), 1]);
is(
hex(await I.publicKey(privOne)),
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"the public key for private key 1",
);
is(
await W.addressFromPublicKey(await I.publicKey(privOne), "bitcoincash"),
"bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"the mainnet address for private key 1",
);
// --- derivation, against libauth ---------------------------------------------
// This phrase, and the address below it, came out of BuiltInWallet in
// Argus/src/lib/wallet-web.js — the wallet the Sirius portal runs.
console.log("\n BIP-39 and BIP-32");
const phrase = "trash key flip dawn impulse float medal rain sell hand neither hub";
const wallet = await W.fromPhrase(phrase, { prefix: "bchtest" });
is(wallet.address, "bchtest:qrq05hk8hurcsjx0slw4yjknmlujfzme3vxjhtwpwy", "derives the portal wallet's address");
const messy = await W.fromPhrase(" Trash KEY flip dawn impulse\tfloat medal rain sell hand neither hub \n", {
prefix: "bchtest",
});
is(messy.address, wallet.address, "spacing and capitals do not change the wallet");
// --- signing, against PHP -----------------------------------------------------
// The signature below was produced by SP_Message::sign() in PHP. Both sides run
// RFC 6979, so the same key and message give the same bytes — an equality this
// strict is only possible because neither implementation uses randomness.
console.log("\n signatures match PHP");
const loginMessage = [
"SIRIUS-PRESS-LOGIN1",
"https://example.bch",
wallet.address,
"abc123",
"1758412800000",
].join("\n");
is(
hex(await I.messageDigest(loginMessage)),
"98d1b0e89586b933f1117fcd0dd167fe126ec5bb8e34f28685890f6030befe19",
"the BIP-137 digest matches PHP and the Theseus wallet",
);
is(
await wallet.sign(loginMessage),
"IGr9FSoDInLUr+iKZGw8w5LPyi/JsZAU6mFvGc80uQReP3IQZU7KMODzwS78FkRE57sh2osmWq1sVHZam81PTkY=",
"the signature is byte-identical to the one PHP produces",
);
is(await wallet.sign(loginMessage), await wallet.sign(loginMessage), "signing is deterministic");
// --- the upload envelope ------------------------------------------------------
// Manual-mode publishing signs a raw digest rather than a text message. The
// expected value is SP_Message::site_digest() from PHP.
console.log("\n BNS-SITE1 upload envelope");
const body = "<h1>hi</h1>";
const bodyHash = hex(new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(body))));
const envelope = `BNS-SITE1\nexample.bch\nindex.html\n${bodyHash}\n1758412800000`;
const siteDigest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(envelope)));
is(
hex(siteDigest),
"a71c4db0bd7268cd2b2103e2c0753ad37b046b203a4bf1ba7e84271b816d10de",
"the upload digest matches PHP",
);
const rawSig = await wallet.signRaw(siteDigest);
is(atob(rawSig).length, 65, "signRaw produces 65 bytes");
// --- phrase generation --------------------------------------------------------
console.log("\n phrase handling");
const generated = await W.generatePhrase(12);
is(generated.split(" ").length, 12, "a generated phrase has twelve words");
ok(W.validatePhrase(generated).ok, "a generated phrase validates");
const generated24 = await W.generatePhrase(24);
is(generated24.split(" ").length, 24, "twenty-four words on request");
ok(W.validatePhrase(generated24).ok, "the long phrase validates too");
ok((await W.fromPhrase(generated, { prefix: "bchtest" })).address.startsWith("bchtest:"), "and opens a wallet");
ok(!W.validatePhrase("").ok, "an empty phrase is refused");
ok(!W.validatePhrase("one two three").ok, "a three-word phrase is refused");
is(
W.validatePhrase("abandon abandon recieve abandon abandon abandon abandon abandon abandon abandon abandon about")
.error,
"Word 3, “recieve”, is not a recovery-phrase word.",
"a misspelled word is named and numbered",
);
console.log("");
if (failed > 0) {
console.log(` ${passed + failed} checks, ${failed} FAILED\n`);
process.exit(1);
}
console.log(` ${passed + failed} checks, all passed\n`);