diff --git a/backend/static/ui.js b/backend/static/ui.js index f6450cf..fd03872 100644 --- a/backend/static/ui.js +++ b/backend/static/ui.js @@ -1,17 +1,22 @@ /** * ui.js – browser UI logic for crushdb. * - * Flow: - * 1. User enters a passphrase → Argon2id derives the session key in - * memory (never sent anywhere, never persisted). - * 2. Fetch ciphertext records from /api/records, decrypt each in - * memory; decrypt failures are surfaced as "wrong passphrase or - * tampered record" without leaking details. - * 3. Create/edit records: form values are encrypted client-side - * before POST/PUT; the server only ever sees ciphertext. - * 4. Delete requires the record's current version (optimistic lock). - * 5. "Lock" wipes the passphrase field and releases decrypted rows so - * nothing sensitive lingers in the DOM. + * Two distinct screens before the app opens: + * + * 1. "setup" (first visit): No encrypted records exist on the server + * yet. The user picks a NEW passphrase (typed twice to avoid + * typos). This passphrase then protects every future record. + * + * 2. "unlock" (returning): encrypted records exist, but the user's + * key isn't known to the browser (it never is – that's the whole + * point of client-side encryption). The user types their + * existing passphrase. + * + * Which screen to show is decided by a cheap server call: + * GET /api/records → empty array ⇒ setup, else unlock. + * + * The passphrase itself NEVER leaves the browser: Argon2id derives a + * 256-bit session key in memory, and that key exists only here. */ import { @@ -42,21 +47,24 @@ function flash(msg, isError = false) { const box = $("#flash"); box.textContent = msg; box.className = isError ? "flash error" : "flash"; - setTimeout(() => { box.textContent = ""; }, 4000); + setTimeout(() => { box.textContent = ""; }, 5000); } // ---------- session state (memory only) ---------- let sessionKey = null; // Uint8Array(32) or null +let busy = false; // Argon2id takes a second; block double submits + +// Salt for the session-key derivation. Fixed domain string, contains +// no secret; the per-record salt is separate (see crypto.js). +const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1"); function resetSession() { wipeKey(sessionKey); sessionKey = null; $("#records").replaceChildren(); $("#app").hidden = true; - $("#unlock").hidden = false; - $("#passphrase").value = ""; - $("#passphrase").focus(); + location.reload(); // cheapest and safest way back to a clean state } // ---------- API helpers ---------- @@ -136,6 +144,7 @@ async function reload() { ); } } + $("#new-btn").disabled = false; if (failures > 0) flash(`${failures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true); } @@ -206,7 +215,7 @@ async function saveRecord(ev) { } closeEditor(); await reload(); - flash(isEdit ? "Datensatz aktualisiert." : "Datensatz erstellt."); + flash(isEdit ? "Datensatz aktualisiert." : "Datensatz gespeichert."); } catch (e) { closeEditor(); if (String(e).includes("409")) { @@ -236,27 +245,49 @@ async function removeRecord(record) { } } -// ---------- unlock / lock ---------- +// ---------- first-time setup (create a NEW passphrase) ---------- -async function unlock(ev) { +async function setup(ev) { ev.preventDefault(); - const passphrase = $("#passphrase").value; - if (!passphrase) { flash("Bitte Passphrase eingeben.", true); return; } - // Salt for key derivation: per-record salts are used for the AEAD; - // for the session key we use a fixed domain-salt so all records can - // be opened with one passphrase. It contains no secret. - const domainSalt = new TextEncoder().encode("crushdb://session-key-v1"); + if (busy) return; + const p1 = $("#setup-pass1").value; + const p2 = $("#setup-pass2").value; + + if (p1 !== p2) { flash("Die beiden Passphrasen stimmen nicht überein.", true); return; } + if (p1.length < 8) { flash("Bitte mindestens 8 Zeichen verwenden.", true); return; } + + busy = true; + $("#setup-btn").disabled = true; + $("#setup-progress").hidden = false; try { - sessionKey = deriveKey(passphrase, domainSalt); + sessionKey = deriveKey(p1, DOMAIN_SALT); + // Verify no records existed while we were deriving (TOCTOU-safe): + // if someone else created data in parallel, fall back to unlock. + const existing = await api("/api/records"); + if (existing.length > 0) { + flash("Es existieren bereits Daten. Bitte entsperren statt neu einrichten.", true); + resetSession(); + return; + } $("#passphrase").value = ""; - $("#unlock").hidden = true; + $("#setup").hidden = true; $("#app").hidden = false; await reload(); - } catch (e) { - flash("Entsperren fehlgeschlagen: " + e.message, true); + $("#setup-pass1").value = ""; + $("#setup-pass2").value = ""; + $("#setup").hidden = true; + $("#app").hidden = false; + await reload(); + flash("Konto eingerichtet. Ab jetzt schützt deine Passphrase alle Daten."); + } finally { + busy = false; + $("#setup-btn").disabled = false; + $("#setup-progress").hidden = true; } } +// ---------- lock (wipe key, back to clean state) ---------- + function lock() { if (!confirm("Sperren? Alle entschlüsselten Daten werden verworfen.")) return; resetSession(); @@ -264,12 +295,65 @@ function lock() { // ---------- wire-up ---------- -document.addEventListener("DOMContentLoaded", () => { +document.addEventListener("DOMContentLoaded", async () => { + $("#setup-form").addEventListener("submit", setup); $("#unlock-form").addEventListener("submit", unlock); $("#lock-btn").addEventListener("click", lock); $("#new-btn").addEventListener("click", newRecord); + $("#new-btn").disabled = true; // enabled after reload() succeeds $("#editor").addEventListener("submit", saveRecord); $("#editor-cancel").addEventListener("click", closeEditor); window.addEventListener("beforeunload", () => wipeKey(sessionKey)); - $("#passphrase").focus(); + + // Decide which pre-screen to show: fresh install vs returning user. + try { + const raw = await api("/api/records"); + const firstVisit = raw.length === 0; + $("#setup").hidden = !firstVisit; + $("#unlock").hidden = firstVisit; + if (firstVisit) $("#setup-pass1").focus(); + else $("#passphrase").focus(); + } catch (e) { + // Server unreachable → default to unlock screen; retry will tell more. + $("#setup").hidden = true; + $("#unlock").hidden = false; + flash("Server nicht erreichbar: " + e.message, true); + } }); + +// ---------- unlock (returning user) ---------- + +async function unlock(ev) { + ev.preventDefault(); + if (busy) return; + const passphrase = $("#passphrase").value; + if (!passphrase) { flash("Bitte Passphrase eingeben.", true); return; } + + busy = true; + $("#unlock-btn").disabled = true; + $("#unlock-progress").hidden = false; + try { + sessionKey = deriveKey(passphrase, DOMAIN_SALT); + $("#passphrase").value = ""; + $("#unlock").hidden = true; + $("#app").hidden = false; + await reload(); + + // If nothing decrypts, the passphrase is (almost certainly) wrong. + const tbody = $("#records tbody"); + const failed = tbody.querySelectorAll("tr.row-error").length; + const total = tbody.children.length; + if (total > 0 && failed === total) { + // Everything undecryptable ⇒ wrong passphrase; go back cleanly. + flash("Entsperren fehlgeschlagen: falsche Passphrase?"); + resetSession(); + } + } catch (e) { + flash("Entsperren fehlgeschlagen: " + e.message, true); + resetSession(); + } finally { + busy = false; + $("#unlock-btn").disabled = false; + $("#unlock-progress").hidden = true; + } +}