open-standards/joule-code/joule-code-rs/crates/joule-moq-web/web/room/privacy-pass.js
Transaction Science c93aa4f7a9 The room page's Privacy Pass client (RFC 9577, RFC 9578 §6) and ratings on its tracks; every grant request in a room that admits by age carries a token
web/room/privacy-pass.js reads RFC 9577's challenges, checks them (§2.1.3), and obtains a
token by blind issuance (RFC 9474 §4.2 to §4.4) from the issuer's directory (RFC 9578 §4); it
reproduces RFC 9578 A.2's TokenRequest and token from the corpus's vector. The page asks
again with the token on a 401, carries the room's rating (and, with rate=, its own rating of
its video, signed in wasm) on every PUBLISH, and records a relay's below_threshold refusal
as withheld. The service now challenges publishers as it does viewers: every track of a rated
room is held to its rating, the room's events among them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 19:15:49 -04:00

282 lines
11 KiB
JavaScript
Raw Permalink 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.

// Privacy Pass in the page: the client of RFC 9577's `PrivateToken` HTTP authentication
// scheme and of RFC 9578's publicly verifiable issuance (§6, token type 0x0002, blind RSA of
// RFC 9474), for a room that admits by age (WAI age-assurance §5.6).
//
// - parseChallenges: the `WWW-Authenticate` challenges an origin sends (RFC 9577 §2.1.2),
// each a TokenChallenge (§2.1.1) and the issuer's key;
// - acceptable: RFC 9577 §2.1.3's checks of a challenge, its origin_info holding the page's
// own server name;
// - issue: the client of RFC 9578 §6.1 to §6.3. It blinds the token input under the issuer's
// key (RFC 9474 §4.2: EMSA-PSS with SHA-384, MGF1 with SHA-384, a salt of 48 bytes, then
// m·r^e mod n), sends the TokenRequest to the issuer's request URL (from its directory,
// RFC 9578 §4), unblinds the answer (RFC 9474 §4.4: s = z·r^−1 mod n), verifies it, and
// returns the token. The issuer signs blindly, so it never sees the token, the challenge or
// the room; the origin sees the token and never the issuance.
// - authorization: the `Authorization: PrivateToken token="…"` field (RFC 9577 §2.2.2).
//
// The arithmetic is BigInt's; SHA-256 and SHA-384 are WebCrypto's. Nothing here is a secret
// the page keeps: a token is spent once, and the blind is forgotten once the token is made.
const TOKEN_TYPE = 0x0002;
const NK = 256; // RSA-2048
const SALT_LEN = 48;
const H_LEN = 48;
const cat = (...parts) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const p of parts) { out.set(p, at); at += p.length; }
return out;
};
const sha = async (alg, b) => new Uint8Array(await crypto.subtle.digest(alg, b));
const eq = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
// ---- base64url (RFC 4648 §5), with padding as RFC 9577 writes it ----
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
export function b64url(b) {
let s = "";
for (let i = 0; i < b.length; i += 3) {
const n = (b[i] << 16) | ((b[i + 1] || 0) << 8) | (b[i + 2] || 0);
const k = Math.min(3, b.length - i);
for (let j = 0; j < 4; j++) s += j <= k ? B64[(n >> (18 - 6 * j)) & 63] : "=";
}
return s;
}
export function unb64url(s) {
const t = s.replace(/=+$/, "");
if (t.length % 4 === 1) throw new Error("not base64url");
const out = [];
for (let i = 0; i < t.length; i += 4) {
let n = 0;
const c = t.slice(i, i + 4);
for (let j = 0; j < c.length; j++) {
const v = B64.indexOf(c[j]);
if (v < 0) throw new Error("not base64url");
n |= v << (18 - 6 * j);
}
out.push((n >> 16) & 255);
if (c.length > 2) out.push((n >> 8) & 255);
if (c.length > 3) out.push(n & 255);
}
return new Uint8Array(out);
}
// ---- RFC 9577: challenges and the token's field ----
// The challenges of `WWW-Authenticate` field values (one or more fields, each one or more
// challenges): those of the scheme `PrivateToken`, each its TokenChallenge, its issuer key and
// its max-age.
export function parseChallenges(values) {
const out = [];
for (const v of [].concat(values || [])) {
for (const part of String(v).split(/(?=PrivateToken\s)/)) {
const m = part.match(/^PrivateToken\s+(.*)$/s);
if (!m) continue;
const params = {};
for (const p of m[1].matchAll(/([A-Za-z-]+)\s*=\s*("([^"]*)"|[^,\s]+)/g)) params[p[1].toLowerCase()] = p[3] !== undefined ? p[3] : p[2];
if (!params.challenge) continue;
out.push({
challenge: unb64url(params.challenge),
tokenKey: params["token-key"] ? unb64url(params["token-key"]) : null,
maxAge: params["max-age"] ? Number(params["max-age"]) : null,
});
}
}
return out;
}
// RFC 9577 §2.1.1: a TokenChallenge, or null.
export function readChallenge(b) {
if (b.length < 4) return null;
const tokenType = (b[0] << 8) | b[1];
const nl = (b[2] << 8) | b[3];
let i = 4 + nl;
if (nl < 1 || i >= b.length) return null;
const issuerName = new TextDecoder().decode(b.subarray(4, i));
const cl = b[i];
if (cl !== 0 && cl !== 32) return null;
const redemptionContext = b.subarray(i + 1, i + 1 + cl);
i += 1 + cl;
if (i + 2 > b.length) return null;
const ol = (b[i] << 8) | b[i + 1];
if (i + 2 + ol !== b.length) return null;
return { tokenType, issuerName, redemptionContext, originInfo: new TextDecoder().decode(b.subarray(i + 2)) };
}
// RFC 9577 §2.1.3: a challenge a client may act on, for the origin `host`: a token type it
// supports (0x0002), well formed, and an origin_info that is empty or lists `host`, compared
// without regard to ASCII case.
export function acceptable(c, host) {
if (!c || c.tokenType !== TOKEN_TYPE) return false;
return c.originInfo === "" || c.originInfo.split(",").some((n) => n.toLowerCase() === String(host).toLowerCase());
}
export function authorization(token) {
return `PrivateToken token="${b64url(token)}"`;
}
// ---- RSA (RFC 8017) in BigInt ----
const big = (b) => b.reduce((n, x) => (n << 8n) | BigInt(x), 0n);
function bytes(n, len) {
const out = new Uint8Array(len);
for (let i = len - 1; i >= 0; i--) { out[i] = Number(n & 255n); n >>= 8n; }
if (n !== 0n) throw new Error("integer too large");
return out;
}
export function modpow(b, e, m) {
let r = 1n;
b %= m;
while (e > 0n) {
if (e & 1n) r = (r * b) % m;
b = (b * b) % m;
e >>= 1n;
}
return r;
}
function gcd(a, b) {
while (b) [a, b] = [b, a % b];
return a;
}
function inverse(a, m) {
let [r0, r1, s0, s1] = [m, a % m, 0n, 1n];
while (r1) {
const q = r0 / r1;
[r0, r1] = [r1, r0 - q * r1];
[s0, s1] = [s1, s0 - q * s1];
}
if (r0 !== 1n) throw new Error("blinding error");
return ((s0 % m) + m) % m;
}
const bitLength = (n) => n.toString(2).length;
// One DER element at `i`: [tag, start of contents, end].
function der(b, i) {
const tag = b[i];
let len = b[i + 1];
let at = i + 2;
if (len & 0x80) {
const k = len & 0x7f;
len = 0;
for (let j = 0; j < k; j++) len = len * 256 + b[at + j];
at += k;
}
return [tag, at, at + len];
}
// RFC 9578 §6.5's SubjectPublicKeyInfo: its modulus and exponent.
export function readSpki(spki) {
const [, s0] = der(spki, 0);
const [, , algEnd] = der(spki, s0);
const [bt, bs, be] = der(spki, algEnd);
if (bt !== 0x03 || spki[bs] !== 0) throw new Error("not a SubjectPublicKeyInfo");
const [, k0] = der(spki, bs + 1);
const [nt, ns, ne] = der(spki, k0);
const [et, es, ee] = der(spki, ne);
if (nt !== 2 || et !== 2 || ee > be) throw new Error("not an RSA key");
return { n: big(spki.subarray(ns, ne)), e: big(spki.subarray(es, ee)) };
}
async function mgf1(seed, len) {
const out = [];
for (let c = 0; out.length < len; c++) out.push(...(await sha("SHA-384", cat(seed, new Uint8Array([c >>> 24, (c >>> 16) & 255, (c >>> 8) & 255, c & 255])))));
return new Uint8Array(out.slice(0, len));
}
// RFC 8017 §9.1.1, EMSA-PSS-ENCODE with SHA-384, MGF1 with SHA-384 and `salt`.
async function pssEncode(m, emBits, salt) {
const emLen = Math.ceil(emBits / 8);
const mHash = await sha("SHA-384", m);
const h = await sha("SHA-384", cat(new Uint8Array(8), mHash, salt));
const db = cat(new Uint8Array(emLen - salt.length - H_LEN - 2), new Uint8Array([1]), salt);
const mask = await mgf1(h, emLen - H_LEN - 1);
const masked = db.map((x, i) => x ^ mask[i]);
masked[0] &= 0xff >> (8 * emLen - emBits);
return cat(masked, h, new Uint8Array([0xbc]));
}
// RFC 8017 §8.1.2 and §9.1.2: RSASSA-PSS-VERIFY with SHA-384 and a salt of 48 bytes.
export async function pssVerify(key, m, sig) {
const modBits = bitLength(key.n);
if (sig.length !== Math.ceil(modBits / 8)) return false;
const s = big(sig);
if (s >= key.n) return false;
const emBits = modBits - 1;
const emLen = Math.ceil(emBits / 8);
const em = bytes(modpow(s, key.e, key.n), emLen);
if (em[emLen - 1] !== 0xbc) return false;
const masked = em.subarray(0, emLen - H_LEN - 1);
const h = em.subarray(emLen - H_LEN - 1, emLen - 1);
if (masked[0] & ~(0xff >> (8 * emLen - emBits)) & 0xff) return false;
const mask = await mgf1(h, masked.length);
const db = masked.map((x, i) => x ^ mask[i]);
db[0] &= 0xff >> (8 * emLen - emBits);
const ps = emLen - H_LEN - SALT_LEN - 2;
if (!db.subarray(0, ps).every((x) => x === 0) || db[ps] !== 1) return false;
const salt = db.subarray(ps + 1);
return eq(await sha("SHA-384", cat(new Uint8Array(8), await sha("SHA-384", m), salt)), h);
}
function randomBytes(n) {
return crypto.getRandomValues(new Uint8Array(n));
}
function randomBelow(n) {
for (;;) {
const r = big(randomBytes(Math.ceil(bitLength(n) / 8))) % (1n << BigInt(bitLength(n)));
if (r > 0n && r < n) return r;
}
}
// ---- RFC 9578 §6: issuance ----
// The TokenRequest for `challenge` under `tokenKey` (§6.1), and how to finalize the issuer's
// answer into a token (§6.3). `nonce`, `blind` and `salt` are drawn here unless given (the
// tests give RFC 9578 Appendix A.2's).
export async function tokenRequest({ challenge, tokenKey, nonce, blind, salt }) {
const key = readSpki(tokenKey);
nonce = nonce || randomBytes(32);
salt = salt || randomBytes(SALT_LEN);
const keyId = await sha("SHA-256", tokenKey);
const input = cat(new Uint8Array([0, TOKEN_TYPE]), nonce, await sha("SHA-256", challenge), keyId);
const m = big(await pssEncode(input, bitLength(key.n) - 1, salt));
if (gcd(m, key.n) !== 1n) throw new Error("invalid input");
const r = blind ? big(blind) : randomBelow(key.n);
const inv = inverse(r, key.n);
const blinded = (m * modpow(r, key.e, key.n)) % key.n;
const request = cat(new Uint8Array([0, TOKEN_TYPE, keyId[31]]), bytes(blinded, NK));
const finalize = async (response) => {
if (response.length !== NK) throw new Error("unexpected input size");
const sig = bytes((big(response) * inv) % key.n, NK);
if (!(await pssVerify(key, input, sig))) throw new Error("invalid signature");
return cat(input, sig);
};
return { request, finalize };
}
// A token for `challenge` from its issuer, whose directory (RFC 9578 §4) is at `directory`:
// the TokenRequest sent to the directory's request URL, the answer finalized. `fetchFn` is
// the page's fetch.
export async function issue({ challenge, tokenKey, directory, fetchFn = fetch }) {
const d = await fetchFn(directory, { cache: "no-store" });
if (!d.ok) throw new Error(`the issuer's directory answered ${d.status}`);
const dir = await d.json();
const listed = (dir["token-keys"] || []).some((k) => k["token-type"] === TOKEN_TYPE && eq(unb64url(k["token-key"]), tokenKey));
if (!listed) throw new Error("the issuer does not list the challenge's key");
const url = new URL(dir["issuer-request-uri"], directory).toString();
const { request, finalize } = await tokenRequest({ challenge, tokenKey });
const r = await fetchFn(url, { method: "POST", headers: { "content-type": "application/private-token-request", accept: "application/private-token-response" }, body: request, cache: "no-store" });
if (r.status !== 200) throw new Error(`the issuer answered ${r.status}`);
return finalize(new Uint8Array(await r.arrayBuffer()));
}
// The directory of `issuerName` (RFC 9578 §4): at its well-known URI, or under `base` where
// the page is told one (`?issuer_directory=`), at `<base>/<issuer name>/.well-known/…`.
export function directoryOf(issuerName, base) {
const path = "/.well-known/private-token-issuer-directory";
return base ? `${base.replace(/\/$/, "")}/${encodeURIComponent(issuerName)}${path}` : `https://${issuerName}${path}`;
}