/* ============================================================================ * solutions.js — per-page, password-gated solutions for the course site. * * How a page opts in: * 1. Put one gate element somewhere on the page: *
* `data-bundle` is resolved relative to the page URL, so each page points * at its own bundle and therefore has its own password. Nothing else is * shared between pages. * 2. For every gated item, leave an empty, hidden slot where the solution * should appear: * * The id in data-solution-for must match a key in the bundle's `items`. * * What happens on unlock: * The typed password is run through PBKDF2 (same params as the bundle's kdf * block) to derive an AES-256-GCM key. The first item is decrypted as a * probe: because GCM authenticates, a wrong password throws, and we report * "wrong password" without revealing anything. On success every slot is * filled, un-hidden, and MathJax re-typesets the injected math. * * The ciphertext ships in the page, so this stops a student * from reading answers in "View source" or the Network tab, and a wrong * password reveals nothing. Anyone who has the password can decrypt, and the * bundle can be brute-forced offline against a weak password. Use a real, * non-guessable per-page password, and rotate it if a key deadline has passed. * ==========================================================================*/ (() => { "use strict"; const b64ToBytes = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); async function deriveKey(password, kdf) { const base = await crypto.subtle.importKey( "raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]); return crypto.subtle.deriveKey( { name: "PBKDF2", salt: b64ToBytes(kdf.salt), iterations: kdf.iterations, hash: kdf.hash }, base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]); } async function decryptItem(key, item) { const pt = await crypto.subtle.decrypt( { name: "AES-GCM", iv: b64ToBytes(item.iv) }, key, b64ToBytes(item.ct)); return new TextDecoder().decode(pt); } function typeset(nodes) { if (window.MathJax && typeof window.MathJax.typesetPromise === "function") { window.MathJax.typesetPromise(nodes).catch(() => { /* leave as-is */ }); } } function buildGate(gate) { gate.classList.add("is-locked"); gate.innerHTML = `

`; return { input: gate.querySelector(".gate-input"), button: gate.querySelector(".gate-btn"), status: gate.querySelector(".gate-status"), }; } async function unlock(gate, ui) { const url = gate.getAttribute("data-bundle"); const pw = ui.input.value; if (!pw) { ui.input.focus(); return; } gate.classList.remove("is-error"); ui.button.disabled = true; ui.status.textContent = "Checking\u2026"; let bundle; try { const res = await fetch(url, { cache: "no-store" }); if (!res.ok) throw new Error(`bundle ${res.status}`); bundle = await res.json(); } catch (e) { ui.button.disabled = false; ui.status.textContent = "Could not load the solutions file for this page."; return; } let key; try { key = await deriveKey(pw, bundle.kdf); // Probe with the first item so a wrong password fails before we touch DOM. const firstId = Object.keys(bundle.items)[0]; await decryptItem(key, bundle.items[firstId]); } catch (e) { gate.classList.add("is-error"); ui.button.disabled = false; ui.status.textContent = "That password didn\u2019t work. Try again."; ui.input.select(); return; } const filled = []; for (const slot of document.querySelectorAll("[data-solution-for]")) { const id = slot.getAttribute("data-solution-for"); const item = bundle.items[id]; if (!item) continue; try { slot.innerHTML = await decryptItem(key, item); slot.hidden = false; slot.classList.add("is-unlocked"); filled.push(slot); } catch (e) { /* skip an item that fails; others still unlock */ } } typeset(filled); gate.classList.remove("is-locked"); gate.classList.add("is-unlocked"); gate.innerHTML = `
Solutions unlocked
`; gate.querySelector(".gate-btn").addEventListener("click", () => { for (const s of filled) { s.hidden = true; s.classList.remove("is-unlocked"); } buildAndWire(gate); // relock the UI; content stays in memory only }); } function buildAndWire(gate) { const ui = buildGate(gate); const go = () => unlock(gate, ui); ui.button.addEventListener("click", go); ui.input.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); }); } function init() { const gate = document.querySelector(".solutions-gate[data-bundle]"); if (!gate) return; if (!window.crypto || !crypto.subtle) { gate.textContent = "This browser can\u2019t decrypt solutions (no Web Crypto over http/file)."; return; } buildAndWire(gate); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();