SHA256
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>
276 lines
8.3 KiB
JavaScript
276 lines
8.3 KiB
JavaScript
/**
|
||
* 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();
|
||
});
|