sirius-press/plugins/sirius-press-auth/assets/wallet.js
Silent Mode 5465b65756 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

511 lines
19 KiB
JavaScript

// The wallet that signs Sirius Press sign-ins, running in the visitor's page.
//
// Why this exists at all: a self-hosted Sirius Press site must be able to log
// its own users in without calling home. Redirecting to sirius.x to sign would
// make every install depend on Silent Mode's portal being up and willing —
// which is exactly the arrangement this whole project exists to get away from.
// So the signing happens here, in the browser, against a phrase the server
// never sees.
//
// No build step and no dependencies, deliberately: a wallet you cannot read is
// a wallet you cannot trust, and a site owner should be able to open this file
// and follow every line from phrase to signature. BigInt does the curve maths,
// WebCrypto does SHA-256, HMAC and PBKDF2. RIPEMD-160 is implemented below
// because WebCrypto does not offer it and address derivation needs it.
//
// The phrase never leaves the page. It is not posted, not stored in a cookie,
// and — unless the visitor asks to stay signed in — not written to disk at
// all. What crosses the wire is a signature over a challenge the server
// issued, which proves control of the key and nothing else.
//
// window.SiriusWallet = { fromPhrase, generatePhrase, validatePhrase,
// external, addressFromPublicKey }
(() => {
"use strict";
// ---------------------------------------------------------------- secp256k1
const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
const GX = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n;
const GY = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n;
const HALF_N = N >> 1n;
const mod = (a, m = P) => ((a % m) + m) % m;
function powMod(base, exp, m) {
let result = 1n;
let b = mod(base, m);
let e = exp;
while (e > 0n) {
if (e & 1n) result = (result * b) % m;
b = (b * b) % m;
e >>= 1n;
}
return result;
}
// Both moduli here are prime, so Fermat gives the inverse without an
// extended-Euclid routine.
const invMod = (a, m) => powMod(a, m - 2n, m);
// Points are Jacobian [X, Y, Z]; affine is X/Z^2, Y/Z^3. One inversion per
// scalar multiply instead of one per bit.
const INF = [1n, 1n, 0n];
function jDouble([x, y, z]) {
if (z === 0n || y === 0n) return INF;
const a = mod(x * x);
const b = mod(y * y);
const c = mod(b * b);
const d = mod(2n * (mod((x + b) * (x + b)) - a - c));
const e = mod(3n * a);
const f = mod(e * e);
const x3 = mod(f - 2n * d);
const y3 = mod(e * (d - x3) - 8n * c);
const z3 = mod(2n * y * z);
return [x3, y3, z3];
}
function jAdd(p1, p2) {
if (p1[2] === 0n) return p2;
if (p2[2] === 0n) return p1;
const [x1, y1, z1] = p1;
const [x2, y2, z2] = p2;
const z1z1 = mod(z1 * z1);
const z2z2 = mod(z2 * z2);
const u1 = mod(x1 * z2z2);
const u2 = mod(x2 * z1z1);
const s1 = mod(y1 * z2 * z2z2);
const s2 = mod(y2 * z1 * z1z1);
if (u1 === u2) return s1 === s2 ? jDouble(p1) : INF;
const h = mod(u2 - u1);
const i = mod(mod(2n * h) * mod(2n * h));
const j = mod(h * i);
const r = mod(2n * (s2 - s1));
const v = mod(u1 * i);
const x3 = mod(r * r - j - 2n * v);
const y3 = mod(r * (v - x3) - 2n * s1 * j);
const z3 = mod((mod((z1 + z2) * (z1 + z2)) - z1z1 - z2z2) * h);
return [x3, y3, z3];
}
function toAffine([x, y, z]) {
if (z === 0n) return null;
const zi = invMod(z, P);
const zi2 = mod(zi * zi);
return [mod(x * zi2), mod(y * zi2 * zi)];
}
function mulPoint(k, px, py) {
k = mod(k, N);
if (k === 0n) return null;
const base = [px, py, 1n];
let acc = INF;
for (const bit of k.toString(2)) {
acc = jDouble(acc);
if (bit === "1") acc = jAdd(acc, base);
}
return toAffine(acc);
}
// ------------------------------------------------------------------- bytes
const enc = new TextEncoder();
const hexToBytes = (hex) =>
Uint8Array.from(hex.match(/.{1,2}/g).map((b) => parseInt(b, 16)));
const bytesToHex = (b) =>
[...b].map((x) => x.toString(16).padStart(2, "0")).join("");
const bytesToBig = (b) => BigInt("0x" + (bytesToHex(b) || "0"));
const bigToBytes = (n, len = 32) => hexToBytes(n.toString(16).padStart(len * 2, "0"));
const concat = (...arrays) => {
const total = arrays.reduce((n, a) => n + a.length, 0);
const out = new Uint8Array(total);
let at = 0;
for (const a of arrays) { out.set(a, at); at += a.length; }
return out;
};
const toBase64 = (b) => btoa(String.fromCharCode(...b));
const sha256 = async (bytes) =>
new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
async function hmac(hash, key, data) {
const k = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash }, false, ["sign"]);
return new Uint8Array(await crypto.subtle.sign("HMAC", k, data));
}
// --------------------------------------------------------------- RIPEMD-160
//
// WebCrypto has no RIPEMD-160 and an address cannot be derived without it.
// Straight transcription of the reference implementation.
function ripemd160(message) {
const rl = [
0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,
7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8,
3,10,14,4,9,15,8,1,2,7,0,6,13,11,5,12,
1,9,11,10,0,8,12,4,13,3,7,15,14,5,6,2,
4,0,5,9,7,12,2,10,14,1,3,8,11,6,15,13,
];
const rr = [
5,14,7,0,9,2,11,4,13,6,15,8,1,10,3,12,
6,11,3,7,0,13,5,10,14,15,8,12,4,9,1,2,
15,5,1,3,7,14,6,9,11,8,12,2,10,0,4,13,
8,6,4,1,3,11,15,0,5,12,2,13,9,7,10,14,
12,15,10,4,1,5,8,7,6,2,13,14,0,3,9,11,
];
const sl = [
11,14,15,12,5,8,7,9,11,13,14,15,6,7,9,8,
7,6,8,13,11,9,7,15,7,12,15,9,11,7,13,12,
11,13,6,7,14,9,13,15,14,8,13,6,5,12,7,5,
11,12,14,15,14,15,9,8,9,14,5,6,8,6,5,12,
9,15,5,11,6,8,13,12,5,12,13,14,11,8,5,6,
];
const sr = [
8,9,9,11,13,15,15,5,7,7,8,11,14,14,12,6,
9,13,15,7,12,8,9,11,7,7,12,7,6,15,13,11,
9,7,15,11,8,6,6,14,12,13,5,14,13,13,7,5,
15,5,8,11,14,14,6,14,6,9,12,9,12,5,15,8,
8,5,12,9,12,5,14,6,8,13,6,5,15,13,11,11,
];
const kl = [0x00000000, 0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xa953fd4e];
const kr = [0x50a28be6, 0x5c4dd124, 0x6d703ef3, 0x7a6d76e9, 0x00000000];
const rol = (x, n) => ((x << n) | (x >>> (32 - n))) >>> 0;
const f = (j, x, y, z) => {
if (j < 16) return x ^ y ^ z;
if (j < 32) return (x & y) | (~x & z);
if (j < 48) return (x | ~y) ^ z;
if (j < 64) return (x & z) | (y & ~z);
return x ^ (y | ~z);
};
// Pad to 64-byte blocks: 0x80, zeros, then a 64-bit little-endian length.
const len = message.length;
const withPad = new Uint8Array((((len + 8) >> 6) + 1) << 6);
withPad.set(message);
withPad[len] = 0x80;
const view = new DataView(withPad.buffer);
view.setUint32(withPad.length - 8, (len << 3) >>> 0, true);
view.setUint32(withPad.length - 4, Math.floor((len * 8) / 0x100000000), true);
let h = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0];
const x = new Array(16);
for (let block = 0; block < withPad.length; block += 64) {
for (let i = 0; i < 16; i++) x[i] = view.getUint32(block + i * 4, true);
let [al, bl, cl, dl, el] = h;
let [ar, br, cr, dr, er] = h;
for (let j = 0; j < 80; j++) {
const round = Math.floor(j / 16);
let t = (al + f(j, bl, cl, dl) + x[rl[j]] + kl[round]) >>> 0;
t = (rol(t, sl[j]) + el) >>> 0;
al = el; el = dl; dl = rol(cl, 10); cl = bl; bl = t;
t = (ar + f(79 - j, br, cr, dr) + x[rr[j]] + kr[round]) >>> 0;
t = (rol(t, sr[j]) + er) >>> 0;
ar = er; er = dr; dr = rol(cr, 10); cr = br; br = t;
}
// The new state is a rotation of the old one: h0 takes the value built
// from h1, h1 from h2, and so on, with h4 wrapping back to h0.
h = [
(h[1] + cl + dr) >>> 0,
(h[2] + dl + er) >>> 0,
(h[3] + el + ar) >>> 0,
(h[4] + al + br) >>> 0,
(h[0] + bl + cr) >>> 0,
];
}
const out = new Uint8Array(20);
const ov = new DataView(out.buffer);
h.forEach((word, i) => ov.setUint32(i * 4, word, true));
return out;
}
// --------------------------------------------------------------- CashAddr
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
function polyMod(values) {
// 40-bit accumulator, split across two 32-bit halves because JavaScript's
// bitwise operators truncate to 32 bits and BigInt here would be slower
// than the arithmetic is worth.
let c = 1n;
for (const d of values) {
const c0 = c >> 35n;
c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(d);
if (c0 & 0x01n) c ^= 0x98f2bc8e61n;
if (c0 & 0x02n) c ^= 0x79b76d99e2n;
if (c0 & 0x04n) c ^= 0xf33e5fb3c4n;
if (c0 & 0x08n) c ^= 0xae2eabe2a8n;
if (c0 & 0x10n) c ^= 0x1e4f43e470n;
}
return c ^ 1n;
}
const expandPrefix = (prefix) => [...prefix].map((ch) => ch.charCodeAt(0) & 0x1f).concat([0]);
function convertBits(values, from, to, pad) {
let acc = 0;
let bits = 0;
const out = [];
const max = (1 << to) - 1;
for (const v of values) {
if (v < 0 || v >> from !== 0) return null;
acc = (acc << from) | v;
bits += from;
while (bits >= to) {
bits -= to;
out.push((acc >> bits) & max);
}
}
if (pad) {
if (bits > 0) out.push((acc << (to - bits)) & max);
} else if (bits >= from || ((acc << (to - bits)) & max)) {
return null;
}
return out;
}
function encodeCashAddr(prefix, hash160, type = 0) {
const payload = concat(Uint8Array.of(type << 3), hash160);
const data = convertBits([...payload], 8, 5, true);
const checksum = polyMod(expandPrefix(prefix).concat(data, [0, 0, 0, 0, 0, 0, 0, 0]));
const cs = [];
for (let i = 0; i < 8; i++) cs.push(Number((checksum >> BigInt(5 * (7 - i))) & 0x1fn));
return prefix + ":" + data.concat(cs).map((v) => CHARSET[v]).join("");
}
const addressFromPublicKey = async (pub, prefix) =>
encodeCashAddr(prefix, ripemd160(await sha256(pub)), 0);
// ------------------------------------------------------------------- keys
async function publicKey(priv) {
const pt = mulPoint(bytesToBig(priv), GX, GY);
if (!pt) throw new Error("that key does not produce a valid public key");
return concat(Uint8Array.of(pt[1] & 1n ? 3 : 2), bigToBytes(pt[0]));
}
// BIP-32 CKDpriv. Hardened steps use the private key, normal steps the
// public one — which is why this has to be async all the way down.
async function deriveChild(node, index) {
const hardened = index >= 0x80000000;
const indexBytes = new Uint8Array(4);
new DataView(indexBytes.buffer).setUint32(0, index, false);
const data = hardened
? concat(Uint8Array.of(0), node.key, indexBytes)
: concat(await publicKey(node.key), indexBytes);
const i = await hmac("SHA-512", node.chain, data);
const tweak = bytesToBig(i.slice(0, 32));
if (tweak >= N) throw new Error("derivation hit an invalid tweak");
const child = mod(tweak + bytesToBig(node.key), N);
if (child === 0n) throw new Error("derivation produced a zero key");
return { key: bigToBytes(child), chain: i.slice(32) };
}
async function derivePath(seed, path) {
const i = await hmac("SHA-512", enc.encode("Bitcoin seed"), seed);
let node = { key: i.slice(0, 32), chain: i.slice(32) };
for (const part of path.split("/").slice(1)) {
if (!part) continue;
const hardened = /['h]$/i.test(part);
const num = parseInt(part.replace(/['h]$/i, ""), 10);
if (!Number.isInteger(num) || num < 0) throw new Error("bad derivation path: " + path);
node = await deriveChild(node, hardened ? num + 0x80000000 : num);
}
return node;
}
async function seedFromPhrase(phrase, passphrase = "") {
const key = await crypto.subtle.importKey("raw", enc.encode(phrase), "PBKDF2", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", salt: enc.encode("mnemonic" + passphrase), iterations: 2048, hash: "SHA-512" },
key,
512,
);
return new Uint8Array(bits);
}
// ------------------------------------------------------------- signing
const MAGIC = "Bitcoin Signed Message:\n";
function varStr(bytes) {
if (bytes.length < 0xfd) return concat(Uint8Array.of(bytes.length), bytes);
const len = new Uint8Array(3);
len[0] = 0xfd;
new DataView(len.buffer).setUint16(1, bytes.length, true);
return concat(len, bytes);
}
// The digest every BCH wallet signs for a text message: double SHA-256 over
// varstr(magic) || varstr(message). Sirius Press verifies against this, so a
// signature made in Electron Cash or the Theseus wallet works identically.
async function messageDigest(message) {
const payload = concat(varStr(enc.encode(MAGIC)), varStr(enc.encode(message)));
return sha256(await sha256(payload));
}
// RFC 6979: the nonce comes from the key and the message, never from the
// page's random source. Two signatures of the same thing are identical, and
// a bad RNG cannot leak the key.
async function* nonces(digest, priv) {
const h1 = bigToBytes(mod(bytesToBig(digest), N));
let v = new Uint8Array(32).fill(1);
let k = new Uint8Array(32).fill(0);
k = await hmac("SHA-256", k, concat(v, Uint8Array.of(0), priv, h1));
v = await hmac("SHA-256", k, v);
k = await hmac("SHA-256", k, concat(v, Uint8Array.of(1), priv, h1));
v = await hmac("SHA-256", k, v);
for (let i = 0; i < 64; i++) {
v = await hmac("SHA-256", k, v);
const candidate = bytesToBig(v);
if (candidate > 0n && candidate < N) yield candidate;
k = await hmac("SHA-256", k, concat(v, Uint8Array.of(0)));
v = await hmac("SHA-256", k, v);
}
}
async function signDigest(digest, priv) {
const z = mod(bytesToBig(digest), N);
const d = bytesToBig(priv);
for await (const k of nonces(digest, priv)) {
const pt = mulPoint(k, GX, GY);
if (!pt) continue;
const r = mod(pt[0], N);
if (r === 0n) continue;
let s = mod(invMod(k, N) * (z + r * d), N);
if (s === 0n) continue;
let recid = (pt[1] & 1n ? 1 : 0) | (pt[0] >= N ? 2 : 0);
if (s > HALF_N) {
s = N - s;
recid ^= 1;
}
return concat(Uint8Array.of(27 + 4 + recid), bigToBytes(r), bigToBytes(s));
}
throw new Error("could not produce a signature");
}
// ------------------------------------------------------------- public API
const normalizePhrase = (p) => String(p || "").trim().toLowerCase().replace(/\s+/g, " ");
function validatePhrase(phrase) {
const clean = normalizePhrase(phrase);
if (!clean) return { ok: false, error: "Enter your recovery phrase." };
const words = clean.split(" ");
if (![12, 15, 18, 21, 24].includes(words.length)) {
return { ok: false, error: `A recovery phrase has 12, 15, 18, 21 or 24 words — this has ${words.length}.` };
}
const list = window.SIRIUS_BIP39_EN;
if (Array.isArray(list)) {
for (let i = 0; i < words.length; i++) {
if (!list.includes(words[i])) {
return { ok: false, error: `Word ${i + 1}, “${words[i]}”, is not a recovery-phrase word.` };
}
}
}
return { ok: true, error: "" };
}
/** A fresh phrase with a valid BIP-39 checksum, from the browser's CSPRNG. */
async function generatePhrase(wordCount = 12) {
const list = window.SIRIUS_BIP39_EN;
if (!Array.isArray(list) || list.length !== 2048) {
throw new Error("the word list has not loaded, so a phrase cannot be generated safely");
}
const entropyBits = (wordCount / 3) * 32;
const entropy = crypto.getRandomValues(new Uint8Array(entropyBits / 8));
const hash = await sha256(entropy);
let bits = [...entropy].map((b) => b.toString(2).padStart(8, "0")).join("");
bits += hash[0].toString(2).padStart(8, "0").slice(0, entropyBits / 32);
const words = [];
for (let i = 0; i < bits.length / 11; i++) {
words.push(list[parseInt(bits.slice(i * 11, i * 11 + 11), 2)]);
}
return words.join(" ");
}
/**
* Open a wallet from a phrase.
*
* @returns {{address: string, sign: (message: string) => Promise<string>}}
*/
async function fromPhrase(phrase, { prefix = "bitcoincash", path = "m/44'/145'/0'/0/0" } = {}) {
const clean = normalizePhrase(phrase);
const check = validatePhrase(clean);
if (!check.ok) throw new Error(check.error);
const node = await derivePath(await seedFromPhrase(clean), path);
const pub = await publicKey(node.key);
const address = await addressFromPublicKey(pub, prefix);
return {
address,
async sign(message) {
return toBase64(await signDigest(await messageDigest(message), node.key));
},
/**
* Sign an already-computed 32-byte digest.
*
* The gateway's upload envelope (BNS-SITE1) is a single SHA-256 over a
* fixed line format, not the BIP-137 message scheme — so the static
* exporter, signing from the browser, needs this rather than sign().
* Handing a wallet a bare digest is a sharp tool: nothing about the
* bytes is legible to the person approving it, which is exactly why
* the interactive login path does not use it.
*/
async signRaw(digest) {
if (!(digest instanceof Uint8Array) || digest.length !== 32) {
throw new Error("signRaw expects a 32-byte digest");
}
return toBase64(await signDigest(digest, node.key));
},
/** sha256 of arbitrary bytes, so callers need no second hash library. */
sha256,
/** Drop the key material once the page is done with it. */
forget() {
node.key.fill(0);
node.chain.fill(0);
},
};
}
/**
* A wallet the browser already provides, if there is one.
*
* Theseus exposes `window.bitcoincash` on `.x` origins, which signs with the
* same BIP-137 scheme. Using it means the key stays in the browser's own
* wallet and never touches this page — strictly better than asking for a
* phrase, so the login screen offers it first when it is there.
*/
function external() {
const bridge = window.bitcoincash;
if (!bridge || typeof bridge.signMessage !== "function") return null;
return {
name: "Theseus",
async address() {
const a = await bridge.getAddress();
return typeof a === "string" ? a : a && a.address ? a.address : "";
},
async sign(message) {
const r = await bridge.signMessage(message);
if (typeof r === "string") return r;
if (r && r.signature) return r.signature;
throw new Error("the wallet returned a signature this page did not understand");
},
};
}
window.SiriusWallet = {
fromPhrase,
generatePhrase,
validatePhrase,
normalizePhrase,
external,
addressFromPublicKey,
// Exposed for the test page in tests/browser/, not used by the UI.
_internals: { signDigest, messageDigest, publicKey, ripemd160, encodeCashAddr },
};
})();