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.
153 lines
5.6 KiB
JavaScript
153 lines
5.6 KiB
JavaScript
// A stand-in for the BNS gateway, for testing the publishing path.
|
|
//
|
|
// It implements exactly one thing, and implements it the way the real gateway
|
|
// does: the signature check on `PUT /api/site/<name>/<path>`. The verification
|
|
// below is transcribed from Argus/src/gateway/public-gateway.mjs — same
|
|
// envelope, same digest, same recovery, same comparison against the name's
|
|
// owner — so a request this accepts is one the real gateway accepts, and a
|
|
// request it rejects would have been rejected there too.
|
|
//
|
|
// That is the whole point. Publishing cannot be tested end to end without a
|
|
// registered name and a funded key, but the part that actually breaks — the
|
|
// bytes being signed — can be checked against the real verifier.
|
|
//
|
|
// node tests/mock-gateway.mjs --owner bchtest:qq… [--port 8799]
|
|
//
|
|
// Needs @bitauth/libauth resolvable from this file — deliberately, because
|
|
// verifying with the same library the real gateway uses is what makes this
|
|
// worth running at all. In the Silent Mode monorepo, copy it next to
|
|
// Argus/package.json and run it there; standalone, `npm i @bitauth/libauth`
|
|
// in this directory.
|
|
//
|
|
// Uploads are kept in memory and listed on GET /api/site/<name>. Nothing is
|
|
// written to disk and nothing leaves the machine.
|
|
|
|
import { createServer } from "node:http";
|
|
import { createHash } from "node:crypto";
|
|
import { secp256k1, sha256, ripemd160, encodeCashAddress, base64ToBin, utf8ToBin } from "@bitauth/libauth";
|
|
|
|
const args = process.argv.slice(2);
|
|
const argOf = (name, fallback) => {
|
|
const i = args.indexOf(`--${name}`);
|
|
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
|
};
|
|
|
|
const OWNER = argOf("owner", "");
|
|
const PORT = Number(argOf("port", "8799"));
|
|
if (!OWNER) {
|
|
console.error("usage: node tests/mock-gateway.mjs --owner <cashaddress> [--port 8799]");
|
|
process.exit(2);
|
|
}
|
|
const PREFIX = OWNER.split(":")[0] || "bchtest";
|
|
|
|
/** path -> {bytes, type} */
|
|
const store = new Map();
|
|
let accepted = 0;
|
|
let rejected = 0;
|
|
|
|
/**
|
|
* The real gateway's check, transcribed.
|
|
*
|
|
* digest = sha256("BNS-SITE1\n<name>\n<path>\n<sha256hex(body)>\n<ts>")
|
|
* then recover the compressed public key from the 65-byte signature and
|
|
* compare the address it controls with the name's current on-chain owner.
|
|
*/
|
|
function verify({ name, path, body, ts, sigB64 }) {
|
|
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) {
|
|
return "x-bns-ts missing or more than 10 minutes off";
|
|
}
|
|
let sig;
|
|
try {
|
|
sig = base64ToBin(sigB64);
|
|
} catch {
|
|
return "x-bns-sig is not valid base64";
|
|
}
|
|
if (sig.length !== 65) return `x-bns-sig must decode to 65 bytes (got ${sig.length})`;
|
|
|
|
const bodyHex = createHash("sha256").update(body).digest("hex");
|
|
const digest = sha256.hash(utf8ToBin(`BNS-SITE1\n${name}\n${path}\n${bodyHex}\n${ts}`));
|
|
const recovered = secp256k1.recoverPublicKeyCompressed(sig.slice(1), (sig[0] - 27) & 3, digest);
|
|
if (typeof recovered === "string") return `signature recovery failed: ${recovered}`;
|
|
|
|
const encoded = encodeCashAddress({
|
|
prefix: PREFIX,
|
|
type: "p2pkh",
|
|
payload: ripemd160.hash(sha256.hash(recovered)),
|
|
});
|
|
const derived = typeof encoded === "string" ? encoded : encoded?.address ?? "";
|
|
if (derived !== OWNER) {
|
|
return `signature does not match current on-chain NFT owner (derived ${derived})`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const server = createServer((req, res) => {
|
|
const json = (code, obj) => {
|
|
res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": "*" });
|
|
res.end(JSON.stringify(obj));
|
|
};
|
|
|
|
const url = new URL(req.url, "http://x");
|
|
if (!url.pathname.startsWith("/api/site/")) {
|
|
return json(404, { error: "only /api/site is implemented" });
|
|
}
|
|
|
|
const rest = url.pathname.slice("/api/site/".length);
|
|
const slash = rest.indexOf("/");
|
|
const name = decodeURIComponent(slash < 0 ? rest : rest.slice(0, slash));
|
|
const path = slash < 0 ? "" : decodeURIComponent(rest.slice(slash + 1));
|
|
|
|
if (req.method === "GET" && !path) {
|
|
return json(200, {
|
|
name,
|
|
prefix: `bns/${name}/`,
|
|
files: [...store].map(([p, v]) => ({ path: p, size: v.bytes.length, modified: null })),
|
|
});
|
|
}
|
|
if (req.method === "GET") {
|
|
const hit = store.get(path);
|
|
if (!hit) return json(404, { error: "not found" });
|
|
res.writeHead(200, { "content-type": hit.type });
|
|
return res.end(hit.bytes);
|
|
}
|
|
|
|
const chunks = [];
|
|
req.on("data", (c) => chunks.push(c));
|
|
req.on("end", () => {
|
|
const body = Buffer.concat(chunks);
|
|
const problem = verify({
|
|
name,
|
|
path,
|
|
body: req.method === "PUT" ? body : Buffer.alloc(0),
|
|
ts: Number(req.headers["x-bns-ts"] || 0),
|
|
sigB64: String(req.headers["x-bns-sig"] || ""),
|
|
});
|
|
if (problem) {
|
|
rejected++;
|
|
console.log(` reject ${req.method} ${path} — ${problem}`);
|
|
return json(problem.includes("owner") ? 403 : 401, { error: problem });
|
|
}
|
|
|
|
if (req.method === "DELETE") {
|
|
store.delete(path);
|
|
accepted++;
|
|
console.log(` delete ${path}`);
|
|
return json(200, { ok: true, name, path, deleted: true });
|
|
}
|
|
|
|
store.set(path, { bytes: body, type: String(req.headers["content-type"] || "") });
|
|
accepted++;
|
|
console.log(` accept ${path} (${body.length} bytes)`);
|
|
return json(200, { ok: true, name, path, bytes: body.length, sia_key: `bns/${name}/${path}` });
|
|
});
|
|
});
|
|
|
|
process.on("SIGTERM", () => process.exit(0));
|
|
process.on("SIGINT", () => {
|
|
console.log(`\naccepted ${accepted}, rejected ${rejected}, holding ${store.size} files`);
|
|
process.exit(0);
|
|
});
|
|
|
|
server.listen(PORT, "127.0.0.1", () => {
|
|
console.log(`mock gateway on http://127.0.0.1:${PORT}, owner ${OWNER}`);
|
|
});
|