feat: browser UI – unlock, table view, create/edit/delete with client-side crypto

Vanilla HTML/CSS/JS frontend served by Flask:
- Unlock screen: passphrase -> Argon2id session key, held in memory
  only (never sent to the server); "Sperren" wipes key and DOM rows.
- Records table (Person A/B, start/end, action) rendered via
  textContent only (no HTML injection); undecryptable rows are flagged.
- Create/edit modal encrypts payloads with XChaCha20-Poly1305 before
  any network call; updates carry the server version (optimistic lock,
  conflict toast on 409); delete fetches current version first.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
2026-10-11 00:02:40 +02:00
parent 7cbc2eecd2
commit a963baa8de
3 changed files with 488 additions and 0 deletions
+275
View File
@@ -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();
});