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