SHA256
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:
@@ -0,0 +1,84 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>crushdb</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>crushdb</h1>
|
||||||
|
<p class="tagline">Verschlüsselte Datensätze zwischen zwei Personen</p>
|
||||||
|
|
||||||
|
<section id="flash" class="flash" aria-live="polite"></section>
|
||||||
|
|
||||||
|
<!-- unlock screen: passphrase stays in memory only -->
|
||||||
|
<section id="unlock">
|
||||||
|
<form id="unlock-form" autocomplete="off">
|
||||||
|
<label for="passphrase">Passphrase</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="passphrase"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
placeholder="Passphrase eingeben …"
|
||||||
|
/>
|
||||||
|
<button type="submit">Entsperren</button>
|
||||||
|
<p class="hint">
|
||||||
|
Die Passphrase verlässt niemals deinen Browser. Sie wird per
|
||||||
|
Argon2id lokal abgeleitet und nur im Arbeitsspeicher gehalten.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- main app, hidden until unlocked -->
|
||||||
|
<section id="app" hidden>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="new-btn" type="button">+ Neuer Datensatz</button>
|
||||||
|
<button id="lock-btn" type="button">Sperren</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table id="records">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Person A</th>
|
||||||
|
<th>Person B</th>
|
||||||
|
<th>Start</th>
|
||||||
|
<th>Ende</th>
|
||||||
|
<th>Aktion</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- create/edit modal -->
|
||||||
|
<div id="editor-backdrop" hidden>
|
||||||
|
<form id="editor" autocomplete="off">
|
||||||
|
<h2 id="editor-title">Neuer Datensatz</h2>
|
||||||
|
<label>Person A
|
||||||
|
<input id="f-persona" type="text" required maxlength="120" />
|
||||||
|
</label>
|
||||||
|
<label>Person B
|
||||||
|
<input id="f-personb" type="text" required maxlength="120" />
|
||||||
|
</label>
|
||||||
|
<label>Start (optional)
|
||||||
|
<input id="f-tsstart" type="datetime-local" />
|
||||||
|
</label>
|
||||||
|
<label>Ende (optional)
|
||||||
|
<input id="f-tsend" type="datetime-local" />
|
||||||
|
</label>
|
||||||
|
<label>Aktion
|
||||||
|
<input id="f-action" type="text" required maxlength="500" />
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<button type="button" id="editor-cancel">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/static/ui.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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); }
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user