Compare commits

..
6 Commits
Author SHA256 Message Date
jhkaktus c82de902a8 feat: free-text timestamps instead of datetime pickers
Users wanted to type things like "unbekannt" or comments for the date
fields. Start/End are now plain text inputs (max 120 chars) and are
stored/displayed verbatim - no Date parsing, no formatting. Existing
records that contain ISO strings keep working: they render exactly as
stored and refill the edit form unchanged. Also removes a duplicated
block in setup() that had slipped in earlier (double reload/flash).

Verified: node --check, no datetime-local/Date-parsing remnants,
crypto roundtrip with free-text and legacy ISO timestamps, pytest 3/3.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 00:58:19 +02:00
jhkaktus 8d488f44ff fix: edit and delete buttons now actually fire their handlers
The shared el() helper passed every attr through setAttribute(); for
event handlers like onclick a function value gets stringified into an
attribute the DOM compiles as an expression that is evaluated but never
called - so Bearbeiten/Löschen (wired through el()) were silent no-ops,
while addEventListener-wired buttons (Neuer Datensatz, Sperren) worked.

el() now assigns on*-function attrs as properties (node[k] = v), preserving
the real handler. Verified with a DOM-semantics shim: fixed path fires,
legacy setAttribute path provably compiles but never calls.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 00:43:21 +02:00
jhkaktus 168927453a fix: backdrop now actually hidden, no longer covers the page
The #editor-backdrop rule set display:flex, which has higher specificity
than the UA style for the hidden attribute - so the modal overlay was
permanently visible on load and blocked the whole UI underneath (user-
reported: "the new record field hides everything"). Adding
#editor-backdrop[hidden] { display:none } makes the attribute win again;
opening/closing the editor still uses flex centering.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 00:37:57 +02:00
jhkaktus 2df603b32b style+docs: consistent pre-screen styling and first-start UX docs
Setup screen shares the unlock panel look; README gains an "Erster
Start (UX)" section explaining first visit vs returning visit behaviour.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 00:30:51 +02:00
jhkaktus 535a9c9db0 feat: client logic for setup vs unlock flow and wrong-passphrase handling
ui.js now (a) decides setup-vs-unlock from GET /api/records emptiness,
(b) runs the setup flow (double entry check, min length, TOCTOU re-check
that the DB stayed empty), (c) detects a fully failed unlock (all rows
undecryptable = wrong passphrase) and returns to a clean state, and
(d) blocks double submits while Argon2id derives the key.

Verified: node --check passes, live server serves both screens and a
crypto roundtrip proves setup passphrase unlocks on second visit while
a wrong passphrase is rejected.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 00:30:51 +02:00
jhkaktus 863cd1fd3a feat: first-time setup screen for creating a new passphrase
UX fix from user feedback: previously the app only showed an "Entsperren"
form, leaving first-time users without any passphrase confused. The page
now loads with two distinct pre-screens decided by an initial API call:

- empty DB -> "Willkommen" setup form: new passphrase, typed twice,
  minimum 8 characters, with no-reset warning
- existing ciphertext records -> unlock form as before

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-11 00:30:51 +02:00
4 changed files with 194 additions and 60 deletions
+10
View File
@@ -8,6 +8,16 @@ Alle Datensätze werden **im Browser** verschlüsselt
Der Server sieht ausschließlich Chiffretext – auch bei vollständiger Der Server sieht ausschließlich Chiffretext – auch bei vollständiger
Serverkompromittierung bleiben die Inhalte ohne Passphrase vertraulich. Serverkompromittierung bleiben die Inhalte ohne Passphrase vertraulich.
## Erster Start (UX)
- **Beim ersten Besuch** (Datenbank leer): Die App zeigt "Willkommen" und
fragt zweimal nach einer neuen Passphrase (mindestens 8 Zeichen).
Diese schützt ab sofort alle Datensätze – es gibt keine Zurücksetzung.
- **Bei weiteren Besuchen**: Die App bittet einmal um die bekannte
Passphrase ("Entsperren").
- Der Server speichert niemals die Passphrase selbst, nur verschlüsselte
Datensätze.
## Starten (HTTP; TLS über Reverse Proxy) ## Starten (HTTP; TLS über Reverse Proxy)
```bash ```bash
+45 -14
View File
@@ -12,18 +12,47 @@
<section id="flash" class="flash" aria-live="polite"></section> <section id="flash" class="flash" aria-live="polite"></section>
<!-- unlock screen: passphrase stays in memory only --> <!-- First visit: NO records exist yet → user chooses a new passphrase -->
<section id="setup" hidden>
<form id="setup-form" autocomplete="off">
<h2>Willkommen bei crushdb!</h2>
<p class="intro">
Du benutzt crushdb zum ersten Mal. Wähle jetzt eine Passphrase –
sie ist das Master-Passwort für alle deine Daten. Es gibt keine
Zurücksetzung, speichere sie sicher!
</p>
<label>Neue Passphrase
<input type="password" id="setup-pass1" autocomplete="new-password"
required minlength="8" placeholder="mindestens 8 Zeichen" />
</label>
<label>Passphrase wiederholen
<input type="password" id="setup-pass2" autocomplete="new-password"
required placeholder="gleiche Passphrase nochmal" />
</label>
<button type="submit" id="setup-btn">Konto einrichten →</button>
<p id="setup-progress" class="hint" hidden>Schlüssel wird abgeleitet …</p>
<p class="hint">
Wichtig: Die Passphrase wird <strong>nicht am Server gespeichert</strong>.
Sie bleibt nur in deinem Browser-Arbeitsspeicher. Wer sie vergisst,
verliert Zugriff auf alle Daten (absichtlich so).
</p>
</form>
</section>
<!-- Returning user: records exist but are undecryptable → enter passphrase -->
<section id="unlock"> <section id="unlock">
<form id="unlock-form" autocomplete="off"> <form id="unlock-form" autocomplete="off">
<label for="passphrase">Passphrase</label> <h2>Entsperren</h2>
<input <p class="intro">
type="password" Du hast bereits verschlüsselte Datensätze hinterlegt.
id="passphrase" Gib deine Passphrase ein, um sie zu lesen.
autocomplete="off" </p>
required <label>Passphrase
placeholder="Passphrase eingeben …" <input type="password" id="passphrase" autocomplete="current-password"
/> required placeholder="deine Passphrase …" />
<button type="submit">Entsperren</button> </label>
<button type="submit" id="unlock-btn">Entsperren</button>
<p id="unlock-progress" class="hint" hidden>Schlüssel wird abgeleitet …</p>
<p class="hint"> <p class="hint">
Die Passphrase verlässt niemals deinen Browser. Sie wird per Die Passphrase verlässt niemals deinen Browser. Sie wird per
Argon2id lokal abgeleitet und nur im Arbeitsspeicher gehalten. Argon2id lokal abgeleitet und nur im Arbeitsspeicher gehalten.
@@ -63,11 +92,13 @@
<label>Person B <label>Person B
<input id="f-personb" type="text" required maxlength="120" /> <input id="f-personb" type="text" required maxlength="120" />
</label> </label>
<label>Start (optional) <label>Start (optional, beliebiger Text)
<input id="f-tsstart" type="datetime-local" /> <input id="f-tsstart" type="text" maxlength="120"
placeholder="z. B. 12.03.2026, Sommer 2025, unbekannt …" />
</label> </label>
<label>Ende (optional) <label>Ende (optional, beliebiger Text)
<input id="f-tsend" type="datetime-local" /> <input id="f-tsend" type="text" maxlength="120"
placeholder="z. B. laufend, unbekannt, März 2026 …" />
</label> </label>
<label>Aktion <label>Aktion
<input id="f-action" type="text" required maxlength="500" /> <input id="f-action" type="text" required maxlength="500" />
+11 -4
View File
@@ -34,7 +34,7 @@ h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
} }
#flash.error { color: var(--danger); } #flash.error { color: var(--danger); }
#unlock { #unlock, #setup {
max-width: 420px; max-width: 420px;
margin: 3rem auto; margin: 3rem auto;
background: var(--panel); background: var(--panel);
@@ -43,10 +43,13 @@ h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
padding: 1.5rem; padding: 1.5rem;
} }
#unlock label { display: block; margin-bottom: 0.5rem; } #unlock h2, #setup h2 { margin: 0 0 0.5rem; font-size: 1.15rem; }
#unlock input { .intro { color: var(--muted); font-size: 0.9rem; margin: 0 0 1rem; }
#unlock label, #setup label { display: block; margin-bottom: 0.75rem; }
#unlock input, #setup input {
width: 100%; width: 100%;
margin-bottom: 1rem; margin-top: 0.25rem;
} }
.hint { color: var(--muted); font-size: 0.85rem; margin-top: 1rem; } .hint { color: var(--muted); font-size: 0.85rem; margin-top: 1rem; }
@@ -105,6 +108,10 @@ td:last-child, td button { white-space: nowrap; }
td button + button { margin-left: 0.4rem; } td button + button { margin-left: 0.4rem; }
/* modal */ /* modal */
/* NOTE: keep `hidden` winning over the flex display below, otherwise the
backdrop is permanently visible and blocks everything underneath. */
#editor-backdrop[hidden] { display: none; }
#editor-backdrop { #editor-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
+128 -42
View File
@@ -1,17 +1,22 @@
/** /**
* ui.js – browser UI logic for crushdb. * ui.js – browser UI logic for crushdb.
* *
* Flow: * Two distinct screens before the app opens:
* 1. User enters a passphrase → Argon2id derives the session key in *
* memory (never sent anywhere, never persisted). * 1. "setup" (first visit): No encrypted records exist on the server
* 2. Fetch ciphertext records from /api/records, decrypt each in * yet. The user picks a NEW passphrase (typed twice to avoid
* memory; decrypt failures are surfaced as "wrong passphrase or * typos). This passphrase then protects every future record.
* tampered record" without leaking details. *
* 3. Create/edit records: form values are encrypted client-side * 2. "unlock" (returning): encrypted records exist, but the user's
* before POST/PUT; the server only ever sees ciphertext. * key isn't known to the browser (it never is – that's the whole
* 4. Delete requires the record's current version (optimistic lock). * point of client-side encryption). The user types their
* 5. "Lock" wipes the passphrase field and releases decrypted rows so * existing passphrase.
* nothing sensitive lingers in the DOM. *
* Which screen to show is decided by a cheap server call:
* GET /api/records → empty array ⇒ setup, else unlock.
*
* The passphrase itself NEVER leaves the browser: Argon2id derives a
* 256-bit session key in memory, and that key exists only here.
*/ */
import { import {
@@ -32,6 +37,12 @@ function el(tag, attrs = {}, children = []) {
for (const [k, v] of Object.entries(attrs)) { for (const [k, v] of Object.entries(attrs)) {
if (k === "class") node.className = v; if (k === "class") node.className = v;
else if (k === "text") node.textContent = v; else if (k === "text") node.textContent = v;
else if (k.startsWith("on") && typeof v === "function") {
// Event handlers MUST be assigned as properties: setAttribute(k, fn)
// stringifies the function ("... => ...") and never binds a listener,
// which silently killed the Bearbeiten/Löschen buttons.
node[k] = v;
}
else node.setAttribute(k, v); else node.setAttribute(k, v);
} }
for (const child of children) node.appendChild(child); for (const child of children) node.appendChild(child);
@@ -42,21 +53,24 @@ function flash(msg, isError = false) {
const box = $("#flash"); const box = $("#flash");
box.textContent = msg; box.textContent = msg;
box.className = isError ? "flash error" : "flash"; box.className = isError ? "flash error" : "flash";
setTimeout(() => { box.textContent = ""; }, 4000); setTimeout(() => { box.textContent = ""; }, 5000);
} }
// ---------- session state (memory only) ---------- // ---------- session state (memory only) ----------
let sessionKey = null; // Uint8Array(32) or null let sessionKey = null; // Uint8Array(32) or null
let busy = false; // Argon2id takes a second; block double submits
// Salt for the session-key derivation. Fixed domain string, contains
// no secret; the per-record salt is separate (see crypto.js).
const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1");
function resetSession() { function resetSession() {
wipeKey(sessionKey); wipeKey(sessionKey);
sessionKey = null; sessionKey = null;
$("#records").replaceChildren(); $("#records").replaceChildren();
$("#app").hidden = true; $("#app").hidden = true;
$("#unlock").hidden = false; location.reload(); // cheapest and safest way back to a clean state
$("#passphrase").value = "";
$("#passphrase").focus();
} }
// ---------- API helpers ---------- // ---------- API helpers ----------
@@ -77,10 +91,10 @@ async function api(path, opts = {}) {
// ---------- rendering ---------- // ---------- rendering ----------
function iso(ts) { // Timestamps are free text now ("unknown", "Sommer 2025", …). Display
if (ts === null || ts === "") return "–"; // them verbatim; nothing to parse or format.
const d = new Date(ts); function showTs(value) {
return isNaN(d) ? String(ts) : d.toLocaleString(); return (value === null || value === undefined || value === "") ? "–" : String(value);
} }
function renderRow(record, payload) { function renderRow(record, payload) {
@@ -88,8 +102,8 @@ function renderRow(record, payload) {
const cells = [ const cells = [
payload.personA, payload.personA,
payload.personB, payload.personB,
iso(payload.tsStart), showTs(payload.tsStart),
iso(payload.tsEnd), showTs(payload.tsEnd),
payload.action, payload.action,
]; ];
for (const text of cells) { for (const text of cells) {
@@ -136,6 +150,7 @@ async function reload() {
); );
} }
} }
$("#new-btn").disabled = false;
if (failures > 0) flash(`${failures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true); if (failures > 0) flash(`${failures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true);
} }
@@ -147,8 +162,8 @@ function openEditor(record, payload) {
$("#editor-title").textContent = "Datensatz bearbeiten"; $("#editor-title").textContent = "Datensatz bearbeiten";
$("#f-persona").value = payload.personA ?? ""; $("#f-persona").value = payload.personA ?? "";
$("#f-personb").value = payload.personB ?? ""; $("#f-personb").value = payload.personB ?? "";
$("#f-tsstart").value = payload.tsStart ? new Date(payload.tsStart).toISOString().slice(0, 16) : ""; $("#f-tsstart").value = payload.tsStart ?? "";
$("#f-tsend").value = payload.tsEnd ? new Date(payload.tsEnd).toISOString().slice(0, 16) : ""; $("#f-tsend").value = payload.tsEnd ?? "";
$("#f-action").value = payload.action ?? ""; $("#f-action").value = payload.action ?? "";
$("#editor-backdrop").hidden = false; $("#editor-backdrop").hidden = false;
} }
@@ -169,9 +184,9 @@ function closeEditor() {
} }
function tsOrNull(inputValue) { function tsOrNull(inputValue) {
if (!inputValue) return null; // Free-text field: trim to null or the plain string.
const d = new Date(inputValue); const v = inputValue.trim();
return isNaN(d) ? null : d.toISOString(); return v === "" ? null : v;
} }
// ---------- record operations ---------- // ---------- record operations ----------
@@ -206,7 +221,7 @@ async function saveRecord(ev) {
} }
closeEditor(); closeEditor();
await reload(); await reload();
flash(isEdit ? "Datensatz aktualisiert." : "Datensatz erstellt."); flash(isEdit ? "Datensatz aktualisiert." : "Datensatz gespeichert.");
} catch (e) { } catch (e) {
closeEditor(); closeEditor();
if (String(e).includes("409")) { if (String(e).includes("409")) {
@@ -236,27 +251,45 @@ async function removeRecord(record) {
} }
} }
// ---------- unlock / lock ---------- // ---------- first-time setup (create a NEW passphrase) ----------
async function unlock(ev) { async function setup(ev) {
ev.preventDefault(); ev.preventDefault();
const passphrase = $("#passphrase").value; if (busy) return;
if (!passphrase) { flash("Bitte Passphrase eingeben.", true); return; } const p1 = $("#setup-pass1").value;
// Salt for key derivation: per-record salts are used for the AEAD; const p2 = $("#setup-pass2").value;
// for the session key we use a fixed domain-salt so all records can
// be opened with one passphrase. It contains no secret. if (p1 !== p2) { flash("Die beiden Passphrasen stimmen nicht überein.", true); return; }
const domainSalt = new TextEncoder().encode("crushdb://session-key-v1"); if (p1.length < 8) { flash("Bitte mindestens 8 Zeichen verwenden.", true); return; }
busy = true;
$("#setup-btn").disabled = true;
$("#setup-progress").hidden = false;
try { try {
sessionKey = deriveKey(passphrase, domainSalt); sessionKey = deriveKey(p1, DOMAIN_SALT);
$("#passphrase").value = ""; // Verify no records existed while we were deriving (TOCTOU-safe):
$("#unlock").hidden = true; // if someone else created data in parallel, fall back to unlock.
const existing = await api("/api/records");
if (existing.length > 0) {
flash("Es existieren bereits Daten. Bitte entsperren statt neu einrichten.", true);
resetSession();
return;
}
$("#setup-pass1").value = "";
$("#setup-pass2").value = "";
$("#setup").hidden = true;
$("#app").hidden = false; $("#app").hidden = false;
await reload(); await reload();
} catch (e) { flash("Konto eingerichtet. Ab jetzt schützt deine Passphrase alle Daten.");
flash("Entsperren fehlgeschlagen: " + e.message, true); } finally {
busy = false;
$("#setup-btn").disabled = false;
$("#setup-progress").hidden = true;
} }
} }
// ---------- lock (wipe key, back to clean state) ----------
function lock() { function lock() {
if (!confirm("Sperren? Alle entschlüsselten Daten werden verworfen.")) return; if (!confirm("Sperren? Alle entschlüsselten Daten werden verworfen.")) return;
resetSession(); resetSession();
@@ -264,12 +297,65 @@ function lock() {
// ---------- wire-up ---------- // ---------- wire-up ----------
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", async () => {
$("#setup-form").addEventListener("submit", setup);
$("#unlock-form").addEventListener("submit", unlock); $("#unlock-form").addEventListener("submit", unlock);
$("#lock-btn").addEventListener("click", lock); $("#lock-btn").addEventListener("click", lock);
$("#new-btn").addEventListener("click", newRecord); $("#new-btn").addEventListener("click", newRecord);
$("#new-btn").disabled = true; // enabled after reload() succeeds
$("#editor").addEventListener("submit", saveRecord); $("#editor").addEventListener("submit", saveRecord);
$("#editor-cancel").addEventListener("click", closeEditor); $("#editor-cancel").addEventListener("click", closeEditor);
window.addEventListener("beforeunload", () => wipeKey(sessionKey)); window.addEventListener("beforeunload", () => wipeKey(sessionKey));
$("#passphrase").focus();
// Decide which pre-screen to show: fresh install vs returning user.
try {
const raw = await api("/api/records");
const firstVisit = raw.length === 0;
$("#setup").hidden = !firstVisit;
$("#unlock").hidden = firstVisit;
if (firstVisit) $("#setup-pass1").focus();
else $("#passphrase").focus();
} catch (e) {
// Server unreachable → default to unlock screen; retry will tell more.
$("#setup").hidden = true;
$("#unlock").hidden = false;
flash("Server nicht erreichbar: " + e.message, true);
}
}); });
// ---------- unlock (returning user) ----------
async function unlock(ev) {
ev.preventDefault();
if (busy) return;
const passphrase = $("#passphrase").value;
if (!passphrase) { flash("Bitte Passphrase eingeben.", true); return; }
busy = true;
$("#unlock-btn").disabled = true;
$("#unlock-progress").hidden = false;
try {
sessionKey = deriveKey(passphrase, DOMAIN_SALT);
$("#passphrase").value = "";
$("#unlock").hidden = true;
$("#app").hidden = false;
await reload();
// If nothing decrypts, the passphrase is (almost certainly) wrong.
const tbody = $("#records tbody");
const failed = tbody.querySelectorAll("tr.row-error").length;
const total = tbody.children.length;
if (total > 0 && failed === total) {
// Everything undecryptable ⇒ wrong passphrase; go back cleanly.
flash("Entsperren fehlgeschlagen: falsche Passphrase?");
resetSession();
}
} catch (e) {
flash("Entsperren fehlgeschlagen: " + e.message, true);
resetSession();
} finally {
busy = false;
$("#unlock-btn").disabled = false;
$("#unlock-progress").hidden = true;
}
}