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]>
282 lines
11 KiB
JavaScript
282 lines
11 KiB
JavaScript
// 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}`;
|
||
}
|
||
|