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