feat: client logic for setup vs unlock flow and wrong-passphrase handling

ui.js now (a) decides setup-vs-unlock from GET /api/records emptiness,
(b) runs the setup flow (double entry check, min length, TOCTOU re-check
that the DB stayed empty), (c) detects a fully failed unlock (all rows
undecryptable = wrong passphrase) and returns to a clean state, and
(d) blocks double submits while Argon2id derives the key.

Verified: node --check passes, live server serves both screens and a
crypto roundtrip proves setup passphrase unlocks on second visit while
a wrong passphrase is rejected.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
2026-10-11 00:30:51 +02:00
parent 863cd1fd3a
commit 535a9c9db0
+114 -30
View File
@@ -1,17 +1,22 @@
/** /**
* ui.js – browser UI logic for crushdb. * ui.js – browser UI logic for crushdb.
* *
* Flow: * Two distinct screens before the app opens:
* 1. User enters a passphrase → Argon2id derives the session key in *
* memory (never sent anywhere, never persisted). * 1. "setup" (first visit): No encrypted records exist on the server
* 2. Fetch ciphertext records from /api/records, decrypt each in * yet. The user picks a NEW passphrase (typed twice to avoid
* memory; decrypt failures are surfaced as "wrong passphrase or * typos). This passphrase then protects every future record.
* tampered record" without leaking details. *
* 3. Create/edit records: form values are encrypted client-side * 2. "unlock" (returning): encrypted records exist, but the user's
* before POST/PUT; the server only ever sees ciphertext. * key isn't known to the browser (it never is – that's the whole
* 4. Delete requires the record's current version (optimistic lock). * point of client-side encryption). The user types their
* 5. "Lock" wipes the passphrase field and releases decrypted rows so * existing passphrase.
* nothing sensitive lingers in the DOM. *
* 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 { import {
@@ -42,21 +47,24 @@ function flash(msg, isError = false) {
const box = $("#flash"); const box = $("#flash");
box.textContent = msg; box.textContent = msg;
box.className = isError ? "flash error" : "flash"; box.className = isError ? "flash error" : "flash";
setTimeout(() => { box.textContent = ""; }, 4000); setTimeout(() => { box.textContent = ""; }, 5000);
} }
// ---------- session state (memory only) ---------- // ---------- session state (memory only) ----------
let sessionKey = null; // Uint8Array(32) or null 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() { function resetSession() {
wipeKey(sessionKey); wipeKey(sessionKey);
sessionKey = null; sessionKey = null;
$("#records").replaceChildren(); $("#records").replaceChildren();
$("#app").hidden = true; $("#app").hidden = true;
$("#unlock").hidden = false; location.reload(); // cheapest and safest way back to a clean state
$("#passphrase").value = "";
$("#passphrase").focus();
} }
// ---------- API helpers ---------- // ---------- 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); if (failures > 0) flash(`${failures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true);
} }
@@ -206,7 +215,7 @@ async function saveRecord(ev) {
} }
closeEditor(); closeEditor();
await reload(); await reload();
flash(isEdit ? "Datensatz aktualisiert." : "Datensatz erstellt."); flash(isEdit ? "Datensatz aktualisiert." : "Datensatz gespeichert.");
} catch (e) { } catch (e) {
closeEditor(); closeEditor();
if (String(e).includes("409")) { 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(); ev.preventDefault();
const passphrase = $("#passphrase").value; if (busy) return;
if (!passphrase) { flash("Bitte Passphrase eingeben.", true); return; } const p1 = $("#setup-pass1").value;
// Salt for key derivation: per-record salts are used for the AEAD; const p2 = $("#setup-pass2").value;
// for the session key we use a fixed domain-salt so all records can
// be opened with one passphrase. It contains no secret. if (p1 !== p2) { flash("Die beiden Passphrasen stimmen nicht überein.", true); return; }
const domainSalt = new TextEncoder().encode("crushdb://session-key-v1"); if (p1.length < 8) { flash("Bitte mindestens 8 Zeichen verwenden.", true); return; }
busy = true;
$("#setup-btn").disabled = true;
$("#setup-progress").hidden = false;
try { 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 = ""; $("#passphrase").value = "";
$("#unlock").hidden = true; $("#setup").hidden = true;
$("#app").hidden = false; $("#app").hidden = false;
await reload(); await reload();
} catch (e) { $("#setup-pass1").value = "";
flash("Entsperren fehlgeschlagen: " + e.message, true); $("#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() { function lock() {
if (!confirm("Sperren? Alle entschlüsselten Daten werden verworfen.")) return; if (!confirm("Sperren? Alle entschlüsselten Daten werden verworfen.")) return;
resetSession(); resetSession();
@@ -264,12 +295,65 @@ function lock() {
// ---------- wire-up ---------- // ---------- wire-up ----------
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", async () => {
$("#setup-form").addEventListener("submit", setup);
$("#unlock-form").addEventListener("submit", unlock); $("#unlock-form").addEventListener("submit", unlock);
$("#lock-btn").addEventListener("click", lock); $("#lock-btn").addEventListener("click", lock);
$("#new-btn").addEventListener("click", newRecord); $("#new-btn").addEventListener("click", newRecord);
$("#new-btn").disabled = true; // enabled after reload() succeeds
$("#editor").addEventListener("submit", saveRecord); $("#editor").addEventListener("submit", saveRecord);
$("#editor-cancel").addEventListener("click", closeEditor); $("#editor-cancel").addEventListener("click", closeEditor);
window.addEventListener("beforeunload", () => wipeKey(sessionKey)); 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;
}
}