sirius-press/tests/live.mjs
Silent Mode ddf49a5523 fix(sirius-press): recovering a key is not the same as verifying a signature
Running the fork against a live WordPress found a real hole in registration,
and it is the kind that only shows up when you actually try it.

ECDSA public-key recovery always succeeds. Given any well-formed signature
and any digest it returns a key — just not the signer's, unless the digest is
the one that was signed. The auth flow leaned on that as if a wrong message
would fail. It does not; it quietly yields a stranger's address.

At sign-in this was harmless, because the wrong address matches no account
and the attempt fails. Registration and wallet-linking were another matter:
both took the recovered address and bound it to an account, so a signature
over slightly different text — a challenge copied without its blank line, a
wallet that rewrote the text, a login signature replayed at the registration
form — created an account keyed to an address nobody could sign for. The
person would see "success" and discover the truth the next time they tried to
get in. Wallet-linking was worse still: it would move an existing account onto
a dead address and lock its owner out of their own site.

Both paths now require the address the signer claims and compare it to the
recovered one, which is what verification actually means. Sign-in accepts the
claim when the page sends it and uses it to turn "no account uses that wallet"
into the more useful "that signature is not over the text we asked for".

Also from running it:

URL rewriting mangled every link on a site whose URL carries a port. The
protocol-relative pass matched inside absolute URLs and gave each one a second
scheme, and matching the host without its port left the port stranded as
`//host:8760:8760/`. Local and staging installs would have exported a site of
broken links.

Plain permalinks silently collapse an entire site onto one exported file,
because every post's URL is `/?p=N` and its path is `/`. The queue looks
healthy the whole time. The Publishing screen now says so.

Translations loaded on `plugins_loaded`, which WordPress 6.7 warns about on
every request — the kind of noise that trains people to stop reading logs.

And one deletion: an `is_email()` filter written on the assumption that
WordPress rejects `.invalid` addresses. It does not — `is_email()` validates
syntax, not whether a domain could exist — so the filter never fired. A filter
that appears to relax a rule but does not is worse than no filter, because
someone later reasons from it. The documentation made the same claim and has
been corrected.

Verification added rather than asserted: tests/live.mjs drives a real instance
over HTTP (40 checks), and tests/mock-gateway.mjs answers uploads with the
signature check transcribed from the gateway's own source, so the publishing
path can be exercised without a registered name.
2026-09-21 02:35:32 +02:00

416 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// End-to-end checks against a running Sirius Press instance.
//
// The other suites prove the cryptography in isolation. This one proves the
// thing that actually matters: that a signature made in a browser gets a real
// WordPress session out of a real WordPress, and that the failure paths fail.
//
// node tests/live.mjs
//
// Expects:
// BASE the site's URL (default http://127.0.0.1:8760)
// WALLET a JSON file holding {"phrase": "...", "address": "bchtest:..."}
// for an existing administrator on that site
//
// docs/testing.md has the recipe for standing up a throwaway instance with
// SQLite and PHP's built-in server — no database server, no Docker.
//
// Nothing here is destructive except that it creates one account per run, in
// a site you were already willing to point a test at.
import { readFileSync } from "node:fs";
globalThis.window = globalThis;
const ASSETS = new URL("../plugins/sirius-press-auth/assets/", import.meta.url);
new Function(readFileSync(new URL("bip39-en.js", ASSETS), "utf8"))();
new Function(readFileSync(new URL("wallet.js", ASSETS), "utf8"))();
const BASE = (process.env.BASE || "http://127.0.0.1:8760").replace(/\/$/, "");
const WALLET = process.env.WALLET;
if (!WALLET) {
console.error("set WALLET to a JSON file with the administrator's phrase and address");
process.exit(2);
}
const admin = JSON.parse(readFileSync(WALLET, "utf8"));
const W = window.SiriusWallet;
/**
* Where the REST API lives.
*
* A site without pretty permalinks serves it at ?rest_route= rather than
* /wp-json/, and a fresh install has exactly that. Probing both is the
* difference between testing the API and testing the permalink setting.
*/
const restBase = await (async () => {
const pretty = await fetch(`${BASE}/wp-json/`).catch(() => null);
if (pretty && pretty.ok && (pretty.headers.get("content-type") || "").includes("json")) {
return (path) => `${BASE}/wp-json${path}`;
}
return (path) => `${BASE}/index.php?rest_route=${encodeURIComponent(path)}`;
})();
const restUrl = (path, query = "") => {
const url = restBase(path);
return query ? url + (url.includes("?") ? "&" : "?") + query : url;
};
let pass = 0;
let fail = 0;
function check(condition, what, extra = "") {
if (condition) {
pass++;
console.log(` ok ${what}`);
} else {
fail++;
console.log(` FAIL ${what}`);
if (extra) console.log(` ${extra}`);
}
}
function group(name) {
console.log(`\n ${name}`);
}
const decode = (s) =>
s
.replace(/&/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#0?39;/g, "'")
.replace(/&#8212;/g, "—")
.replace(/&#8217;/g, "");
/** A cookie jar and a fetch that uses it — one browser, in effect. */
function Session() {
const jar = new Map([["wordpress_test_cookie", "WP%20Cookie%20check"]]);
return {
loggedIn: () => [...jar.keys()].some((k) => k.startsWith("wordpress_logged_in_")),
async go(path, options = {}) {
const res = await fetch(BASE + path, {
redirect: "manual",
...options,
headers: {
cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "),
...(options.headers || {}),
},
});
for (const raw of res.headers.getSetCookie?.() ?? []) {
const pair = raw.split(";")[0];
const eq = pair.indexOf("=");
jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1));
}
return { res, body: await res.text() };
},
};
}
const post = (fields) => ({
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fields),
});
/** Fetch a page and pull out the challenge it is offering. */
async function challenge(session, path) {
const { body } = await session.go(path);
const message = /class="sirius-wallet__message"[^>]*>([\s\S]*?)<\/textarea>/.exec(body);
return {
body,
nonce: /name="sirius_nonce" value="([^"]+)"/.exec(body)?.[1],
message: message ? decode(message[1]) : null,
};
}
const errorIn = (html) =>
(/<div id="login_error">([\s\S]*?)<\/div>/.exec(html)?.[1] || "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
// ---------------------------------------------------------------- sign in
group("wallet sign-in");
{
const s = Session();
const c = await challenge(s, "/wp-login.php");
check(Boolean(c.nonce && c.message), "the login page issues a challenge");
const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" });
check(wallet.address === admin.address, "the phrase derives the administrator's address");
const { res } = await s.go(
"/wp-login.php",
post({
log: "",
pwd: "",
sirius_nonce: c.nonce,
sirius_purpose: "login",
sirius_signature: await wallet.sign(c.message),
sirius_address: wallet.address,
"wp-submit": "Log In",
redirect_to: `${BASE}/wp-admin/`,
testcookie: "1",
}),
);
check(res.status === 302, `a valid signature signs you in (${res.status})`);
check(s.loggedIn(), "an ordinary WordPress session cookie is issued");
const dash = await s.go("/wp-admin/");
check(dash.res.status === 200 && /Dashboard/i.test(dash.body), "wp-admin loads with that session");
// Every screen the fork adds has to render without a PHP diagnostic.
const screens = [
["Settings", "/wp-admin/admin.php?page=sirius-press"],
["Sign-in", "/wp-admin/admin.php?page=sirius-press-auth"],
["Publishing", "/wp-admin/admin.php?page=sirius-press-export"],
["Inbox", "/wp-admin/admin.php?page=sirius-inbox"],
["Profile", "/wp-admin/profile.php"],
["Users", "/wp-admin/users.php"],
["Plugins", "/wp-admin/plugins.php"],
];
for (const [label, path] of screens) {
const page = await s.go(path);
// Match PHP's own diagnostic output, not the word "Warning" wherever it
// happens to appear. PHP always appends " in <file> on line <n>", and
// with html_errors on it wraps the label in <b>. Without that anchor this
// check trips over plugins whose translation strings contain the word —
// Yoast ships several.
const LABEL = "Fatal error|Parse error|Warning|Notice|Deprecated";
const diagnostic =
new RegExp(`<b>(?:${LABEL})</b>:[^<]{0,200}`).exec(page.body) ||
new RegExp(`(?:${LABEL}):[^\n<]{0,200}? in [^\n<]{0,200}? on line \d+`).exec(page.body);
check(
page.res.status === 200 && !diagnostic,
`${label} renders cleanly (${page.res.status})`,
diagnostic ? diagnostic[0] : "",
);
}
const settings = await s.go("/wp-admin/admin.php?page=sirius-press");
check(/Publishing address/.test(settings.body), "settings shows the publishing status block");
const profile = await s.go("/wp-admin/profile.php");
check(profile.body.includes(admin.address), "the profile shows the attached wallet");
const users = await s.go("/wp-admin/users.php");
check(/Wallet/.test(users.body), "the users list has a Wallet column");
check(!/noreply\+/.test(users.body), "and does not show placeholder email addresses");
}
// ----------------------------------------------------- one signature, one use
group("a signature is worth one use");
{
const c = await challenge(Session(), "/wp-login.php");
const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" });
const signature = await wallet.sign(c.message);
const fields = {
log: "",
pwd: "",
sirius_nonce: c.nonce,
sirius_purpose: "login",
sirius_signature: signature,
sirius_address: wallet.address,
"wp-submit": "Log In",
testcookie: "1",
};
const first = await Session().go("/wp-login.php", post(fields));
check(first.res.status === 302, "the first use is accepted");
const second = await Session().go("/wp-login.php", post(fields));
check(second.res.status === 200, "replaying it does not sign anyone in");
check(/already been used/i.test(second.body), "and the page says why", errorIn(second.body));
}
// ------------------------------------------------------------ wrong wallet
group("a stranger's signature");
{
const s = Session();
const c = await challenge(s, "/wp-login.php");
const stranger = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" });
const { res, body } = await s.go(
"/wp-login.php",
post({
log: "",
pwd: "",
sirius_nonce: c.nonce,
sirius_purpose: "login",
sirius_signature: await stranger.sign(c.message),
sirius_address: stranger.address,
"wp-submit": "Log In",
testcookie: "1",
}),
);
check(res.status === 200, "an unknown wallet is not signed in");
check(/No account/i.test(body), "and is told no account uses it", errorIn(body));
}
// ------------------------------------------------------- tampered message
group("a signature over different text");
{
const s = Session();
const c = await challenge(s, "/wp-login.php");
const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" });
const { res, body } = await s.go(
"/wp-login.php",
post({
log: "",
pwd: "",
sirius_nonce: c.nonce,
sirius_purpose: "login",
sirius_signature: await wallet.sign(c.message + " "),
sirius_address: wallet.address,
"wp-submit": "Log In",
testcookie: "1",
}),
);
check(res.status === 200, "a signature over altered text is refused");
check(/does not match/i.test(body), "and says the text does not match", errorIn(body));
}
// ------------------------------------------- purposes are not interchangeable
group("a login signature cannot create an account");
{
const s = Session();
const login = await challenge(s, "/wp-login.php");
const stranger = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" });
const { body } = await s.go(
"/wp-login.php?action=sirius_register",
post({
user_login: "",
sirius_nonce: login.nonce,
sirius_purpose: "register",
sirius_signature: await stranger.sign(login.message),
sirius_address: stranger.address,
}),
);
check(
/does not match/i.test(body),
"signing the login text does not register an account",
errorIn(body),
);
}
// ------------------------------------------------------------- registration
group("registration");
{
const s = Session();
const c = await challenge(s, "/wp-login.php?action=sirius_register");
check(Boolean(c.nonce && c.message), "the registration page issues its own challenge");
check(/create an account/i.test(c.message || ""), "the challenge says what it is for",
(c.message || "").split("\n")[0]);
check(!/name="user_email"|type="email"/.test(c.body), "there is no email field on it");
const wallet = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" });
const suffix = Math.random().toString(36).slice(2, 8);
const { res } = await s.go(
"/wp-login.php?action=sirius_register",
post({
user_login: `reader_${suffix}`,
sirius_nonce: c.nonce,
sirius_purpose: "register",
sirius_signature: await wallet.sign(c.message),
sirius_address: wallet.address,
}),
);
check(res.status === 302, `one signature creates the account and signs it in (${res.status})`);
check(s.loggedIn(), "the new account has a session straight away");
/*
* Prove the account was bound to the right key by signing in again with it,
* rather than by loading wp-admin. A subscriber cannot necessarily reach
* wp-admin at all — WooCommerce redirects them away by default — and that
* would make this assertion a test of whichever plugins happen to be
* installed instead of a test of registration.
*/
const fresh = await fetch(restUrl("/sirius-press/v1/challenge", "purpose=login"));
const freshJson = await fresh.json().catch(() => ({}));
const back = await fetch(restUrl("/sirius-press/v1/login"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nonce: freshJson.nonce, signature: await wallet.sign(freshJson.message) }),
});
const backJson = await back.json().catch(() => ({}));
check(
back.status === 200 && backJson.address === wallet.address,
"the new account signs in again with the same wallet",
`status ${back.status}, address ${backJson.address}`,
);
const s2 = Session();
const c2 = await challenge(s2, "/wp-login.php?action=sirius_register");
const again = await s2.go(
"/wp-login.php?action=sirius_register",
post({
user_login: "",
sirius_nonce: c2.nonce,
sirius_purpose: "register",
sirius_signature: await wallet.sign(c2.message),
sirius_address: wallet.address,
}),
);
check(
again.res.status === 200 && /already has an account/i.test(again.body),
"the same wallet cannot register twice",
errorIn(again.body),
);
}
// ------------------------------------------------------------ recovery page
group("the recovery page");
{
const { res, body } = await Session().go("/wp-login.php?action=lostpassword");
check(res.status === 200, "it loads");
check(/cannot reset your account/i.test(body), "it says the site cannot reset anything");
check(!/Get New Password/i.test(body), "and offers no reset form");
}
// -------------------------------------------------------------- REST surface
group("the REST endpoints");
{
const res = await fetch(restUrl("/sirius-press/v1/challenge", "purpose=login"));
const json = await res.json().catch(() => ({}));
check(res.status === 200, `challenge returns 200 (${res.status})`);
check(Boolean(json.nonce && json.message), "and carries a nonce and a message");
const wallet = await W.fromPhrase(admin.phrase, { prefix: "bchtest" });
const login = await fetch(restUrl("/sirius-press/v1/login"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nonce: json.nonce, signature: await wallet.sign(json.message) }),
});
const out = await login.json().catch(() => ({}));
check(login.status === 200 && out.ok === true, `login returns a session (${login.status})`);
check(out.address === admin.address, "and reports the address that signed");
const bad = await fetch(restUrl("/sirius-press/v1/login"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nonce: json.nonce, signature: "not-a-signature" }),
});
check(bad.status >= 400, `a bad signature is rejected with an error status (${bad.status})`);
// Registration through the API must insist on the claimed address for the
// same reason the form does.
const reg = await fetch(restUrl("/sirius-press/v1/challenge", "purpose=register"));
const regJson = await reg.json().catch(() => ({}));
if (regJson.nonce) {
const orphan = await W.fromPhrase(await W.generatePhrase(12), { prefix: "bchtest" });
const noAddr = await fetch(restUrl("/sirius-press/v1/register"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ nonce: regJson.nonce, signature: await orphan.sign(regJson.message) }),
});
check(noAddr.status === 400, `register without an address is refused (${noAddr.status})`);
}
}
console.log(`\n ${pass + fail} checks, ${fail ? `${fail} FAILED` : "all passed"}\n`);
process.exit(fail ? 1 : 0);