Vestibule/extension/admin.js

543 lines
20 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Vestibule admin wizard — production implementation.
//
// Two-phase UI:
// Phase A (auth gate):
// - No admin password set → setup view (first run)
// - Admin password set → enter view (returning)
// Phase B (wizard):
// - Six steps, navigated with prev/next
// - Final step shows JSON review and save button
//
// Credential split (two passwords, two surfaces):
// Admin password — gates this wizard. PBKDF2-SHA-256, 100k
// iterations, 16-byte salt, in-browser via Web Crypto.
// Kiosk unlock password — releases the session. Argon2id in
// usher, file storage at 0600 (see helper/src/crypto.rs).
const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_HASH = "SHA-256";
const PBKDF2_KEYLEN_BITS = 256;
const SALT_BYTES = 16;
const MIN_PASSWORD_LENGTH = 8;
const TOTAL_STEPS = 6;
// URL policy engine — shared with the background script. Loaded via
// <script src="url-policy.js"> in admin.html; the wizard uses it for
// entry normalization and the homepage-on-safelist validation so the
// rule the operator configures is the rule the engine enforces.
const UrlPolicy = globalThis.VestibuleUrlPolicy;
// ─── Crypto utilities ─────────────────────────────────────────────────
const bytesToBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
const base64ToBytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const generateSalt = () => crypto.getRandomValues(new Uint8Array(SALT_BYTES));
async function hashPassword(password, saltBytes) {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveBits"]
);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", salt: saltBytes, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH },
keyMaterial,
PBKDF2_KEYLEN_BITS
);
return new Uint8Array(bits);
}
async function verifyPassword(password, storedHashB64, storedSaltB64) {
const hash = await hashPassword(password, base64ToBytes(storedSaltB64));
const stored = base64ToBytes(storedHashB64);
if (hash.length !== stored.length) return false;
// SEI CERT MSC06-C: XOR-accumulate every byte. Array.prototype.every
// short-circuits on the first mismatch and leaks timing; reduce does not.
return hash.reduce((acc, b, i) => acc | (b ^ stored[i]), 0) === 0;
}
// ─── View helpers (step-down, no nested conditionals) ─────────────────
const ALL_VIEWS = () =>
document.querySelectorAll(".auth-view, #view-wizard");
function showView(id) {
ALL_VIEWS().forEach((v) => (v.hidden = true));
const el = document.getElementById(id);
if (el) el.hidden = false;
}
function showStep(n) {
document.querySelectorAll(".step").forEach((s) => {
s.hidden = true;
s.classList.remove("active");
});
const target = document.querySelector(`.step[data-step="${n}"]`);
if (target) {
target.hidden = false;
target.classList.add("active");
}
document.querySelectorAll("#step-list li").forEach((li) => {
li.classList.remove("active", "completed");
const step = parseInt(li.dataset.step, 10);
if (step === n) li.classList.add("active");
else if (step < n) li.classList.add("completed");
});
document.getElementById("wizard-progress").textContent = `Step ${n} of ${TOTAL_STEPS}`;
document.getElementById("btn-prev").disabled = n === 1;
const nextBtn = document.getElementById("btn-next");
nextBtn.hidden = n === TOTAL_STEPS;
if (n === TOTAL_STEPS) updateReview();
}
const getRadio = (name) => {
const el = document.querySelector(`input[name="${name}"]:checked`);
return el ? el.value : null;
};
const setRadio = (name, value) => {
const el = document.querySelector(`input[name="${name}"][value="${value}"]`);
if (el) el.checked = true;
};
const textareaLines = (id) =>
document
.getElementById(id)
.value.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
// Safelist entries as they will be saved: hostnames only, normalized,
// deduplicated, order preserved.
const safelistFromTextarea = () => {
const seen = new Set();
const entries = [];
textareaLines("cfg-url-entries").forEach((line) => {
const entry = UrlPolicy.normalizeDomainEntry(line);
if (entry === null || seen.has(entry)) return;
seen.add(entry);
entries.push(entry);
});
return entries;
};
// ─── Config gather / fill (table-driven field maps) ───────────────────
const FIELD_MAP = [
{ key: "kioskName", el: "cfg-kiosk-name", get: (el) => el.value.trim() || "Vestibule Kiosk" },
{ key: "homeUrl", el: "cfg-home-url", get: (el) => el.value.trim() || "about:blank" },
{ key: "attractUrl", el: "cfg-attract-url", get: (el) => el.value.trim() },
];
const RADIO_MAP = [
{ cfg: "urlPolicy.mode", radio: "url-mode", default: "safelist" },
{ cfg: "session.onReset", radio: "on-reset", default: "both" },
{ cfg: "power.mode", radio: "power-mode", default: "aware" },
{ cfg: "power.onWake", radio: "on-wake", default: "both" },
{ cfg: "unlock.method", radio: "unlock-method", default: "password" },
];
// Help text per URL mode — the entries textarea means something
// different in each. One row per mode, shown under the textarea.
const ENTRIES_HELP = {
safelist: "Domains, one per line. An entry grants the domain and every subdomain; pasted URLs are normalized to their hostname. Include the domains of third-party resources (CDNs, SSO, analytics) the kiosk content needs — they are blocked too.",
open: "No entries apply in open mode; every URL is allowed.",
blocklist: "Substrings, one per line. Any URL containing a listed string is blocked. Case-insensitive.",
allowlist: "Substrings, one per line. Only URLs containing a listed string are allowed. An empty list allows everything — prefer safelist mode.",
};
function getNested(obj, path, fallback) {
const result = path.split(".").reduce((acc, key) => (acc ? acc[key] : undefined), obj);
return result !== undefined ? result : fallback;
}
function setNested(obj, path, value) {
const keys = path.split(".");
const last = keys.pop();
const target = keys.reduce((acc, key) => (acc[key] = acc[key] || {}), obj);
target[last] = value;
}
function gatherConfig() {
const cfg = {
version: 1,
urlPolicy: { safelist: [], allowlist: [], blocklist: [] },
session: { dataPersistenceAllowlist: [] },
power: {},
unlock: {},
presence: {
enabled: false,
awayThresholdS: 10,
faceMatchThreshold: 0.6,
privacyMode: true,
},
_meta: { savedAt: new Date().toISOString(), schemaVersion: 1 },
};
FIELD_MAP.forEach(({ key, el, get }) => setNested(cfg, key, get(document.getElementById(el))));
RADIO_MAP.forEach(({ cfg: cfgPath, radio, default: def }) => {
setNested(cfg, cfgPath, getRadio(radio) || def);
});
// Entries feed the list the selected mode consumes. Safelist mode
// normalizes to hostnames; the substring modes keep raw lines.
const urlEntries = textareaLines("cfg-url-entries");
if (cfg.urlPolicy.mode === "safelist") cfg.urlPolicy.safelist = safelistFromTextarea();
else if (cfg.urlPolicy.mode === "allowlist") cfg.urlPolicy.allowlist = urlEntries;
else cfg.urlPolicy.blocklist = urlEntries;
cfg.session.idleTimeoutS = parseInt(document.getElementById("cfg-idle-timeout").value, 10) || 300;
cfg.session.dataPersistenceAllowlist = textareaLines("cfg-persistence-allowlist");
return cfg;
}
function fillConfig(cfg) {
if (!cfg) return;
FIELD_MAP.forEach(({ key, el }) => {
const val = getNested(cfg, key, "");
if (val) document.getElementById(el).value = val;
});
RADIO_MAP.forEach(({ cfg: cfgPath, radio, default: def }) => {
setRadio(radio, getNested(cfg, cfgPath, def));
});
if (cfg.session) {
document.getElementById("cfg-idle-timeout").value = cfg.session.idleTimeoutS || 300;
document.getElementById("cfg-persistence-allowlist").value =
(cfg.session.dataPersistenceAllowlist || []).join("\n");
}
if (cfg.urlPolicy) {
const mode = cfg.urlPolicy.mode;
const entries =
mode === "safelist" ? cfg.urlPolicy.safelist :
mode === "allowlist" ? cfg.urlPolicy.allowlist :
cfg.urlPolicy.blocklist;
document.getElementById("cfg-url-entries").value = (entries || []).join("\n");
}
}
// ─── Review and save ──────────────────────────────────────────────────
function updateReview() {
const cfg = gatherConfig();
const display = { ...cfg, unlock: { method: cfg.unlock.method, password: "(sent to usher for Argon2id hashing)" } };
document.getElementById("review-output").textContent = JSON.stringify(display, null, 2);
}
async function saveConfig() {
const resultEl = document.getElementById("save-result");
resultEl.hidden = true;
resultEl.className = "save-result";
const unlockPwd = document.getElementById("cfg-unlock-password").value;
const unlockPwdConfirm = document.getElementById("cfg-unlock-password-confirm").value;
if (unlockPwd && unlockPwd !== unlockPwdConfirm) {
return showSaveError(resultEl, "Unlock passwords do not match.");
}
const cfg = gatherConfig();
// Safelist mode: the kiosk must be able to reach its own home page
// (and attract URL when set) — a domain not on the list refuses the
// save instead of shipping a configuration that blocks the kiosk's
// front door.
const missing = missingSafelistDomains(cfg);
if (missing.length > 0) {
return showSaveError(resultEl,
`Safelist check failed: ${missing.map((d) => `'${d}'`).join(", ")} ` +
`${missing.length === 1 ? "is" : "are"} not on the safelist (step 2). ` +
"Add the domain there, or set the home URL to an internal page (about:blank).");
}
// Persist config to browser storage (adminConfig + policy mirror)
await persistConfig(cfg, resultEl);
// If a new unlock password was entered, send it to usher for Argon2id
// hashing and file storage. usher stores the PHC string at
// ~/.config/vestibule/unlock.hash (0600). The hash never touches
// browser storage.
if (unlockPwd) {
await sendUnlockPasswordToUsher(unlockPwd, resultEl);
}
}
// URLs the kiosk navigates to on its own (home, attract) that the
// current safelist does not cover. The decision is delegated to the
// engine — the same function the background script calls — so the
// wizard can never disagree with runtime enforcement.
function missingSafelistDomains(cfg) {
if (cfg.urlPolicy.mode !== "safelist") return [];
const safelist = cfg.urlPolicy.safelist;
return [cfg.homeUrl, cfg.attractUrl]
.filter((url) => url && url.length > 0)
.filter((url) =>
UrlPolicy.shouldBlockRequest(url, { mode: "safelist", safelist }, { mainFrame: true }))
.map((url) => UrlPolicy.homeHostnameOf(url) || url);
}
function sendUnlockPasswordToUsher(password, resultEl) {
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
showSaveError(resultEl, "Unlock password storage timed out. Is usher running?");
browser.runtime.onMessage.removeListener(handler);
resolve();
}, 10_000);
const handler = (msg) => {
if (msg.type !== "unlock-set-result") return;
clearTimeout(timeoutId);
browser.runtime.onMessage.removeListener(handler);
if (msg.ok) {
console.log("[vestibule-admin] unlock hash stored by usher");
} else {
showSaveError(resultEl, `Unlock password storage failed: ${msg.error || "unknown"}`);
}
resolve();
};
browser.runtime.onMessage.addListener(handler);
browser.runtime.sendMessage({ type: "set-unlock-password", password }).catch(() => {
clearTimeout(timeoutId);
browser.runtime.onMessage.removeListener(handler);
showSaveError(resultEl, "Cannot reach usher. Is the native host installed?");
resolve();
});
});
}
async function persistConfig(cfg, resultEl) {
try {
await browser.storage.local.set({
adminConfig: cfg,
policy: {
mode: cfg.urlPolicy.mode,
safelist: cfg.urlPolicy.safelist,
allowlist: cfg.urlPolicy.allowlist,
blocklist: cfg.urlPolicy.blocklist,
homeUrl: cfg.homeUrl,
idleTimeoutS: cfg.session.idleTimeoutS,
onReset: cfg.session.onReset,
dataPersistenceAllowlist: cfg.session.dataPersistenceAllowlist,
onWake: cfg.power.onWake,
},
});
resultEl.textContent = "Configuration saved. The kiosk will use these settings immediately.";
resultEl.classList.add("success");
resultEl.hidden = false;
console.log("[vestibule-admin] config saved:", cfg);
} catch (e) {
showSaveError(resultEl, `Save failed: ${e.message}`);
console.error("[vestibule-admin] save failed:", e);
}
}
function showSaveError(el, message) {
el.textContent = message;
el.classList.add("error");
el.hidden = false;
}
// ─── Auth gate ────────────────────────────────────────────────────────
async function initAuthGate() {
const stored = await browser.storage.local.get("adminAuth");
return stored.adminAuth ? initReturningUser(stored.adminAuth) : initFirstRun();
}
function initFirstRun() {
showView("view-auth-setup");
document.getElementById("setup-submit").addEventListener("click", handleSetupSubmit);
document.getElementById("setup-password-confirm").addEventListener("keydown", (e) => {
if (e.key === "Enter") document.getElementById("setup-submit").click();
});
}
async function handleSetupSubmit() {
const pwd = document.getElementById("setup-password").value;
const pwdConfirm = document.getElementById("setup-password-confirm").value;
if (!pwd) return alert("Password cannot be empty.");
if (pwd.length < MIN_PASSWORD_LENGTH) return alert(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
if (pwd !== pwdConfirm) return alert("Passwords do not match.");
const salt = await generateSalt();
const hash = await hashPassword(pwd, salt);
await browser.storage.local.set({
adminAuth: {
salt: bytesToBase64(salt),
hash: bytesToBase64(hash),
iterations: PBKDF2_ITERATIONS,
algorithm: "PBKDF2-SHA-256",
createdAt: new Date().toISOString(),
},
});
console.log("[vestibule-admin] admin password set");
enterWizard();
}
function initReturningUser(auth) {
showView("view-auth-enter");
const submit = document.getElementById("enter-submit");
const passwordInput = document.getElementById("enter-password");
const errEl = document.getElementById("enter-error");
submit.addEventListener("click", async () => {
errEl.hidden = true;
const ok = await verifyPassword(passwordInput.value, auth.hash, auth.salt);
if (ok) return enterWizard();
errEl.textContent = "Invalid password.";
errEl.hidden = false;
passwordInput.value = "";
passwordInput.focus();
});
passwordInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") submit.click();
});
}
// ─── Homepage × safelist live check (step 1 ↔ step 2) ────────────────
//
// Safelist mode only: the home URL's domain must be on the list.
// The status line under the Home URL field answers in the operator's
// terms — which domain was read, whether it is listed — and offers
// the one-click fix. The same rule refuses the save (see
// missingSafelistDomains) and is guaranteed at runtime by the
// background script's home-origin exemption.
function updateUrlEntriesHelp() {
const mode = getRadio("url-mode") || "safelist";
document.getElementById("url-entries-help").textContent = ENTRIES_HELP[mode];
}
function updateHomeDomainStatus() {
const statusEl = document.getElementById("home-domain-status");
const textEl = document.getElementById("home-domain-text");
const btnEl = document.getElementById("btn-add-home-domain");
const mode = getRadio("url-mode") || "safelist";
const homeUrl = document.getElementById("cfg-home-url").value.trim();
// The check is a safelist-mode concern; other modes do not gate
// the home page this way and the status line stays out of the way.
if (mode !== "safelist" || !homeUrl) {
statusEl.hidden = true;
btnEl.hidden = true;
return;
}
const hostname = UrlPolicy.homeHostnameOf(homeUrl);
if (!hostname) {
textEl.textContent = "Internal page — always permitted.";
statusEl.classList.remove("warn");
statusEl.classList.add("ok");
btnEl.hidden = true;
statusEl.hidden = false;
return;
}
const blocked = UrlPolicy.shouldBlockRequest(
homeUrl, { mode: "safelist", safelist: safelistFromTextarea() }, { mainFrame: true });
if (!blocked) {
textEl.textContent = `${hostname} — on the safelist.`;
statusEl.classList.remove("warn");
statusEl.classList.add("ok");
btnEl.hidden = true;
} else {
textEl.textContent = `${hostname} is not on the safelist — the save will be refused until it is added.`;
statusEl.classList.remove("ok");
statusEl.classList.add("warn");
btnEl.hidden = false;
}
statusEl.hidden = false;
}
function addHomeDomainToSafelist() {
const hostname = UrlPolicy.homeHostnameOf(
document.getElementById("cfg-home-url").value.trim());
if (!hostname) return;
if (!safelistFromTextarea().some((entry) =>
hostname === entry || hostname.endsWith("." + entry))) {
const textarea = document.getElementById("cfg-url-entries");
textarea.value = textarea.value.trim();
if (textarea.value) textarea.value += "\n";
textarea.value += hostname;
}
updateHomeDomainStatus();
}
function wireUrlPolicyLiveChecks() {
document.getElementById("cfg-home-url").addEventListener("input", updateHomeDomainStatus);
document.getElementById("cfg-url-entries").addEventListener("input", updateHomeDomainStatus);
document.querySelectorAll("input[name='url-mode']").forEach((radio) =>
radio.addEventListener("change", () => {
updateUrlEntriesHelp();
updateHomeDomainStatus();
}));
document.getElementById("btn-add-home-domain").addEventListener(
"click", addHomeDomainToSafelist);
updateUrlEntriesHelp();
updateHomeDomainStatus();
}
// ─── Wizard entry ─────────────────────────────────────────────────────
async function enterWizard() {
showView("view-wizard");
const stored = await browser.storage.local.get("adminConfig");
if (stored.adminConfig) fillConfig(stored.adminConfig);
else applyDefaults();
let currentStep = 1;
showStep(currentStep);
document.getElementById("btn-prev").addEventListener("click", () => {
if (currentStep > 1) showStep(--currentStep);
});
document.getElementById("btn-next").addEventListener("click", () => {
if (currentStep < TOTAL_STEPS) showStep(++currentStep);
});
document.querySelectorAll("#step-list li").forEach((li) => {
li.addEventListener("click", () => {
const target = parseInt(li.dataset.step, 10);
if (target <= currentStep || target === currentStep + 1) showStep((currentStep = target));
});
});
document.getElementById("btn-save").addEventListener("click", saveConfig);
document.getElementById("btn-cancel").addEventListener("click", () => {
if (confirm("Discard changes and close?")) window.close();
});
wireUrlPolicyLiveChecks();
}
function applyDefaults() {
setRadio("url-mode", "safelist");
setRadio("on-reset", "both");
setRadio("power-mode", "aware");
setRadio("on-wake", "both");
setRadio("unlock-method", "password");
document.getElementById("cfg-idle-timeout").value = "300";
}
// ─── Boot ─────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", initAuthGate);
console.log("[vestibule-admin] wizard loaded");