160 lines
6.0 KiB
JavaScript
160 lines
6.0 KiB
JavaScript
/* ============================================================================
|
|
* 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:
|
|
* <div class="solutions-gate" data-bundle="a1.1-solutions.json"></div>
|
|
* `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:
|
|
* <div class="solution" data-solution-for="q-enthalpy-qp-001" hidden></div>
|
|
* 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 = `
|
|
<div class="gate-inner">
|
|
<span class="gate-lock" aria-hidden="true"></span>
|
|
<label class="gate-label" for="gate-pw">Solutions are locked</label>
|
|
<input class="gate-input" id="gate-pw" type="password"
|
|
autocomplete="off" spellcheck="false"
|
|
placeholder="Enter the page password" />
|
|
<button class="gate-btn" type="button">Unlock</button>
|
|
<p class="gate-status" role="status" aria-live="polite"></p>
|
|
</div>`;
|
|
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 = `
|
|
<div class="gate-inner">
|
|
<span class="gate-lock is-open" aria-hidden="true"></span>
|
|
<span class="gate-label">Solutions unlocked</span>
|
|
<button class="gate-btn gate-btn-ghost" type="button">Hide again</button>
|
|
</div>`;
|
|
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();
|
|
}
|
|
})(); |