543 lines
20 KiB
JavaScript
Executable File
543 lines
20 KiB
JavaScript
Executable File
// 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");
|