// 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 `//.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}`; }