/** * 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. */ import { deriveKey, encryptRecord, decryptRecord, randomSalt, randomUuid, wipeKey, } from "./crypto.js"; // ---------- tiny DOM helpers ---------- const $ = (sel) => document.querySelector(sel); function el(tag, attrs = {}, children = []) { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === "class") node.className = v; else if (k === "text") node.textContent = v; else node.setAttribute(k, v); } for (const child of children) node.appendChild(child); return node; } function flash(msg, isError = false) { const box = $("#flash"); box.textContent = msg; box.className = isError ? "flash error" : "flash"; setTimeout(() => { box.textContent = ""; }, 4000); } // ---------- session state (memory only) ---------- let sessionKey = null; // Uint8Array(32) or null function resetSession() { wipeKey(sessionKey); sessionKey = null; $("#records").replaceChildren(); $("#app").hidden = true; $("#unlock").hidden = false; $("#passphrase").value = ""; $("#passphrase").focus(); } // ---------- API helpers ---------- async function api(path, opts = {}) { const res = await fetch(path, { headers: opts.body ? { "Content-Type": "application/json" } : {}, ...opts, }); if (!res.ok) { let detail = ""; try { detail = (await res.json()).error ?? ""; } catch { /* noop */ } throw new Error(`HTTP ${res.status}${detail ? ": " + detail : ""}`); } if (res.status === 204) return null; return res.json(); } // ---------- rendering ---------- function iso(ts) { if (ts === null || ts === "") return "–"; const d = new Date(ts); return isNaN(d) ? String(ts) : d.toLocaleString(); } function renderRow(record, payload) { const tr = el("tr"); const cells = [ payload.personA, payload.personB, iso(payload.tsStart), iso(payload.tsEnd), payload.action, ]; for (const text of cells) { const td = el("td"); td.textContent = text ?? ""; // textContent = no HTML injection tr.appendChild(td); } const ops = el("td"); ops.appendChild( el("button", { text: "Bearbeiten", type: "button", onclick: () => openEditor(record, payload), }) ); ops.appendChild( el("button", { text: "Löschen", type: "button", onclick: () => removeRecord(record), }) ); tr.appendChild(ops); return tr; } async function reload() { const tbody = $("#records tbody"); tbody.replaceChildren(); const raw = await api("/api/records"); let failures = 0; for (const record of raw) { try { const payload = decryptRecord(sessionKey, record); payload.__version = record.version; // used for optimistic locking tbody.appendChild(renderRow(record, payload)); } catch { failures++; tbody.appendChild( el("tr", { class: "row-error" }, [ el("td", { colspan: "6", text: "Datensatz nicht entschlüsselbar (falsche Passphrase oder manipuliert)." }), ]) ); } } if (failures > 0) flash(`${failures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true); } function openEditor(record, payload) { const form = $("#editor"); form.dataset.uuid = record.uuid; form.dataset.version = payload.__version; form.dataset.salt = record.salt; $("#editor-title").textContent = "Datensatz bearbeiten"; $("#f-persona").value = payload.personA ?? ""; $("#f-personb").value = payload.personB ?? ""; $("#f-tsstart").value = payload.tsStart ? new Date(payload.tsStart).toISOString().slice(0, 16) : ""; $("#f-tsend").value = payload.tsEnd ? new Date(payload.tsEnd).toISOString().slice(0, 16) : ""; $("#f-action").value = payload.action ?? ""; $("#editor-backdrop").hidden = false; } function newRecord() { const form = $("#editor"); form.reset(); form.dataset.uuid = randomUuid(); form.dataset.version = ""; form.dataset.salt = ""; $("#editor-title").textContent = "Neuer Datensatz"; $("#editor-backdrop").hidden = false; } function closeEditor() { $("#editor-backdrop").hidden = true; $("#editor").reset(); } function tsOrNull(inputValue) { if (!inputValue) return null; const d = new Date(inputValue); return isNaN(d) ? null : d.toISOString(); } // ---------- record operations ---------- async function saveRecord(ev) { ev.preventDefault(); if (!sessionKey) { flash("Bitte zuerst entsperren.", true); return; } const form = $("#editor"); const payload = { personA: $("#f-persona").value.trim(), personB: $("#f-personb").value.trim(), tsStart: tsOrNull($("#f-tsstart").value), tsEnd: tsOrNull($("#f-tsend").value), action: $("#f-action").value.trim(), }; if (!payload.personA || !payload.personB || !payload.action) { flash("Person A, Person B und Aktion sind erforderlich.", true); return; } const isEdit = form.dataset.version !== ""; const salt = isEdit ? new Uint8Array(atob(form.dataset.salt).split("").map((c) => c.charCodeAt(0))) : randomSalt(); const enc = encryptRecord(sessionKey, form.dataset.uuid, payload, salt); try { if (isEdit) { enc.version = Number(form.dataset.version); await api(`/api/records/${encodeURIComponent(enc.uuid)}`, { method: "PUT", body: JSON.stringify(enc) }); } else { await api("/api/records", { method: "POST", body: JSON.stringify(enc) }); } closeEditor(); await reload(); flash(isEdit ? "Datensatz aktualisiert." : "Datensatz erstellt."); } catch (e) { closeEditor(); if (String(e).includes("409")) { flash("Konflikt: Der Datensatz wurde zwischenzeitlich geändert. Liste wird neu geladen.", true); } else { flash("Speichern fehlgeschlagen: " + e.message, true); } await reload(); } } async function removeRecord(record) { if (!sessionKey) { flash("Bitte zuerst entsperren.", true); return; } if (!confirm("Diesen Datensatz wirklich löschen?")) return; try { const current = await api(`/api/records/${encodeURIComponent(record.uuid)}`); await api(`/api/records/${encodeURIComponent(record.uuid)}`, { method: "DELETE", body: JSON.stringify({ version: current.version }), }); await reload(); flash("Datensatz gelöscht."); } catch (e) { if (String(e).includes("409")) flash("Konflikt beim Löschen (Version geändert). Bitte erneut versuchen.", true); else flash("Löschen fehlgeschlagen: " + e.message, true); await reload(); } } // ---------- unlock / lock ---------- async function unlock(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"); try { sessionKey = deriveKey(passphrase, domainSalt); $("#passphrase").value = ""; $("#unlock").hidden = true; $("#app").hidden = false; await reload(); } catch (e) { flash("Entsperren fehlgeschlagen: " + e.message, true); } } function lock() { if (!confirm("Sperren? Alle entschlüsselten Daten werden verworfen.")) return; resetSession(); } // ---------- wire-up ---------- document.addEventListener("DOMContentLoaded", () => { $("#unlock-form").addEventListener("submit", unlock); $("#lock-btn").addEventListener("click", lock); $("#new-btn").addEventListener("click", newRecord); $("#editor").addEventListener("submit", saveRecord); $("#editor-cancel").addEventListener("click", closeEditor); window.addEventListener("beforeunload", () => wipeKey(sessionKey)); $("#passphrase").focus(); });