diff --git a/backend/static/index.html b/backend/static/index.html new file mode 100644 index 0000000..d75273d --- /dev/null +++ b/backend/static/index.html @@ -0,0 +1,84 @@ + + + + + + crushdb + + + +

crushdb

+

Verschlüsselte Datensätze zwischen zwei Personen

+ +
+ + +
+
+ + + +

+ Die Passphrase verlässt niemals deinen Browser. Sie wird per + Argon2id lokal abgeleitet und nur im Arbeitsspeicher gehalten. +

+
+
+ + + + + + + + + + diff --git a/backend/static/style.css b/backend/static/style.css new file mode 100644 index 0000000..ef6c9c8 --- /dev/null +++ b/backend/static/style.css @@ -0,0 +1,129 @@ +/* crushdb – simple, clean dark UI */ + +:root { + --bg: #101418; + --panel: #1a2028; + --panel-2: #202830; + --border: #2c3642; + --text: #e6edf3; + --muted: #8b98a5; + --accent: #4da3ff; + --danger: #ff5d5d; + --ok: #47d68a; +} + +* { box-sizing: border-box; } + +body { + margin: 0 auto; + max-width: 960px; + padding: 2rem 1rem; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} + +h1 { margin: 0 0 0.25rem; font-size: 1.6rem; } +.tagline { margin: 0 0 1.5rem; color: var(--muted); } + +#flash { + min-height: 1.4rem; + margin-bottom: 1rem; + color: var(--ok); +} +#flash.error { color: var(--danger); } + +#unlock { + max-width: 420px; + margin: 3rem auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.5rem; +} + +#unlock label { display: block; margin-bottom: 0.5rem; } +#unlock input { + width: 100%; + margin-bottom: 1rem; +} +.hint { color: var(--muted); font-size: 0.85rem; margin-top: 1rem; } + +input, button { + font: inherit; + color: var(--text); +} + +input { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.7rem; +} +input:focus { outline: 1px solid var(--accent); } + +button { + background: var(--accent); + color: #0b1016; + border: none; + border-radius: 6px; + padding: 0.5rem 1rem; + cursor: pointer; + font-weight: 600; +} +button:hover { filter: brightness(1.1); } + +#app .toolbar { + display: flex; + gap: 0.75rem; + margin-bottom: 1rem; +} +#lock-btn { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); } + +table { + width: 100%; + border-collapse: collapse; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +th, td { + padding: 0.6rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border); +} +thead th { background: var(--panel-2); color: var(--muted); font-weight: 600; } +tbody tr:last-child td { border-bottom: none; } +tbody tr:hover { background: var(--panel-2); } + +.row-error td { color: var(--danger); font-size: 0.9rem; } + +td:last-child, td button { white-space: nowrap; } +td button + button { margin-left: 0.4rem; } + +/* modal */ +#editor-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; +} + +#editor { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.5rem; + width: min(440px, 92vw); +} + +#editor h2 { margin-top: 0; font-size: 1.15rem; } +#editor label { display: block; margin-bottom: 0.75rem; color: var(--muted); } +#editor input { width: 100%; margin-top: 0.25rem; } +#editor .actions { display: flex; gap: 0.75rem; margin-top: 1rem; } +#editor-cancel { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); } diff --git a/backend/static/ui.js b/backend/static/ui.js new file mode 100644 index 0000000..f6450cf --- /dev/null +++ b/backend/static/ui.js @@ -0,0 +1,275 @@ +/** + * 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(); +});