// 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}} */ 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 }, }; })();