SHA256
Compare commits
6
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
c82de902a8 | ||
|
|
8d488f44ff | ||
|
|
168927453a | ||
|
|
2df603b32b | ||
|
|
535a9c9db0 | ||
|
|
863cd1fd3a |
@@ -8,6 +8,16 @@ Alle Datensätze werden **im Browser** verschlüsselt
|
||||
Der Server sieht ausschließlich Chiffretext – auch bei vollständiger
|
||||
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)
|
||||
|
||||
```bash
|
||||
|
||||
+45
-14
@@ -12,18 +12,47 @@
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<h2>Entsperren</h2>
|
||||
<p class="intro">
|
||||
Du hast bereits verschlüsselte Datensätze hinterlegt.
|
||||
Gib deine Passphrase ein, um sie zu lesen.
|
||||
</p>
|
||||
<label>Passphrase
|
||||
<input type="password" id="passphrase" autocomplete="current-password"
|
||||
required placeholder="deine Passphrase …" />
|
||||
</label>
|
||||
<button type="submit" id="unlock-btn">Entsperren</button>
|
||||
<p id="unlock-progress" class="hint" hidden>Schlüssel wird abgeleitet …</p>
|
||||
<p class="hint">
|
||||
Die Passphrase verlässt niemals deinen Browser. Sie wird per
|
||||
Argon2id lokal abgeleitet und nur im Arbeitsspeicher gehalten.
|
||||
@@ -63,11 +92,13 @@
|
||||
<label>Person B
|
||||
<input id="f-personb" type="text" required maxlength="120" />
|
||||
</label>
|
||||
<label>Start (optional)
|
||||
<input id="f-tsstart" type="datetime-local" />
|
||||
<label>Start (optional, beliebiger Text)
|
||||
<input id="f-tsstart" type="text" maxlength="120"
|
||||
placeholder="z. B. 12.03.2026, Sommer 2025, unbekannt …" />
|
||||
</label>
|
||||
<label>Ende (optional)
|
||||
<input id="f-tsend" type="datetime-local" />
|
||||
<label>Ende (optional, beliebiger Text)
|
||||
<input id="f-tsend" type="text" maxlength="120"
|
||||
placeholder="z. B. laufend, unbekannt, März 2026 …" />
|
||||
</label>
|
||||
<label>Aktion
|
||||
<input id="f-action" type="text" required maxlength="500" />
|
||||
|
||||
@@ -34,7 +34,7 @@ h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
|
||||
}
|
||||
#flash.error { color: var(--danger); }
|
||||
|
||||
#unlock {
|
||||
#unlock, #setup {
|
||||
max-width: 420px;
|
||||
margin: 3rem auto;
|
||||
background: var(--panel);
|
||||
@@ -43,10 +43,13 @@ h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
#unlock label { display: block; margin-bottom: 0.5rem; }
|
||||
#unlock input {
|
||||
#unlock h2, #setup h2 { margin: 0 0 0.5rem; font-size: 1.15rem; }
|
||||
.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%;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.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; }
|
||||
|
||||
/* 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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
+128
-42
@@ -1,17 +1,22 @@
|
||||
/**
|
||||
* 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.
|
||||
* Two distinct screens before the app opens:
|
||||
*
|
||||
* 1. "setup" (first visit): No encrypted records exist on the server
|
||||
* yet. The user picks a NEW passphrase (typed twice to avoid
|
||||
* typos). This passphrase then protects every future record.
|
||||
*
|
||||
* 2. "unlock" (returning): encrypted records exist, but the user's
|
||||
* key isn't known to the browser (it never is – that's the whole
|
||||
* point of client-side encryption). The user types their
|
||||
* existing passphrase.
|
||||
*
|
||||
* 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 {
|
||||
@@ -32,6 +37,12 @@ function el(tag, attrs = {}, children = []) {
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === "class") node.className = 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);
|
||||
}
|
||||
for (const child of children) node.appendChild(child);
|
||||
@@ -42,21 +53,24 @@ function flash(msg, isError = false) {
|
||||
const box = $("#flash");
|
||||
box.textContent = msg;
|
||||
box.className = isError ? "flash error" : "flash";
|
||||
setTimeout(() => { box.textContent = ""; }, 4000);
|
||||
setTimeout(() => { box.textContent = ""; }, 5000);
|
||||
}
|
||||
|
||||
// ---------- session state (memory only) ----------
|
||||
|
||||
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() {
|
||||
wipeKey(sessionKey);
|
||||
sessionKey = null;
|
||||
$("#records").replaceChildren();
|
||||
$("#app").hidden = true;
|
||||
$("#unlock").hidden = false;
|
||||
$("#passphrase").value = "";
|
||||
$("#passphrase").focus();
|
||||
location.reload(); // cheapest and safest way back to a clean state
|
||||
}
|
||||
|
||||
// ---------- API helpers ----------
|
||||
@@ -77,10 +91,10 @@ async function api(path, opts = {}) {
|
||||
|
||||
// ---------- rendering ----------
|
||||
|
||||
function iso(ts) {
|
||||
if (ts === null || ts === "") return "–";
|
||||
const d = new Date(ts);
|
||||
return isNaN(d) ? String(ts) : d.toLocaleString();
|
||||
// Timestamps are free text now ("unknown", "Sommer 2025", …). Display
|
||||
// them verbatim; nothing to parse or format.
|
||||
function showTs(value) {
|
||||
return (value === null || value === undefined || value === "") ? "–" : String(value);
|
||||
}
|
||||
|
||||
function renderRow(record, payload) {
|
||||
@@ -88,8 +102,8 @@ function renderRow(record, payload) {
|
||||
const cells = [
|
||||
payload.personA,
|
||||
payload.personB,
|
||||
iso(payload.tsStart),
|
||||
iso(payload.tsEnd),
|
||||
showTs(payload.tsStart),
|
||||
showTs(payload.tsEnd),
|
||||
payload.action,
|
||||
];
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -147,8 +162,8 @@ function openEditor(record, payload) {
|
||||
$("#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-tsstart").value = payload.tsStart ?? "";
|
||||
$("#f-tsend").value = payload.tsEnd ?? "";
|
||||
$("#f-action").value = payload.action ?? "";
|
||||
$("#editor-backdrop").hidden = false;
|
||||
}
|
||||
@@ -169,9 +184,9 @@ function closeEditor() {
|
||||
}
|
||||
|
||||
function tsOrNull(inputValue) {
|
||||
if (!inputValue) return null;
|
||||
const d = new Date(inputValue);
|
||||
return isNaN(d) ? null : d.toISOString();
|
||||
// Free-text field: trim to null or the plain string.
|
||||
const v = inputValue.trim();
|
||||
return v === "" ? null : v;
|
||||
}
|
||||
|
||||
// ---------- record operations ----------
|
||||
@@ -206,7 +221,7 @@ async function saveRecord(ev) {
|
||||
}
|
||||
closeEditor();
|
||||
await reload();
|
||||
flash(isEdit ? "Datensatz aktualisiert." : "Datensatz erstellt.");
|
||||
flash(isEdit ? "Datensatz aktualisiert." : "Datensatz gespeichert.");
|
||||
} catch (e) {
|
||||
closeEditor();
|
||||
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();
|
||||
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");
|
||||
if (busy) return;
|
||||
const p1 = $("#setup-pass1").value;
|
||||
const p2 = $("#setup-pass2").value;
|
||||
|
||||
if (p1 !== p2) { flash("Die beiden Passphrasen stimmen nicht überein.", true); return; }
|
||||
if (p1.length < 8) { flash("Bitte mindestens 8 Zeichen verwenden.", true); return; }
|
||||
|
||||
busy = true;
|
||||
$("#setup-btn").disabled = true;
|
||||
$("#setup-progress").hidden = false;
|
||||
try {
|
||||
sessionKey = deriveKey(passphrase, domainSalt);
|
||||
$("#passphrase").value = "";
|
||||
$("#unlock").hidden = true;
|
||||
sessionKey = deriveKey(p1, DOMAIN_SALT);
|
||||
// Verify no records existed while we were deriving (TOCTOU-safe):
|
||||
// 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;
|
||||
await reload();
|
||||
} catch (e) {
|
||||
flash("Entsperren fehlgeschlagen: " + e.message, true);
|
||||
flash("Konto eingerichtet. Ab jetzt schützt deine Passphrase alle Daten.");
|
||||
} finally {
|
||||
busy = false;
|
||||
$("#setup-btn").disabled = false;
|
||||
$("#setup-progress").hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- lock (wipe key, back to clean state) ----------
|
||||
|
||||
function lock() {
|
||||
if (!confirm("Sperren? Alle entschlüsselten Daten werden verworfen.")) return;
|
||||
resetSession();
|
||||
@@ -264,12 +297,65 @@ function lock() {
|
||||
|
||||
// ---------- wire-up ----------
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
$("#setup-form").addEventListener("submit", setup);
|
||||
$("#unlock-form").addEventListener("submit", unlock);
|
||||
$("#lock-btn").addEventListener("click", lock);
|
||||
$("#new-btn").addEventListener("click", newRecord);
|
||||
$("#new-btn").disabled = true; // enabled after reload() succeeds
|
||||
$("#editor").addEventListener("submit", saveRecord);
|
||||
$("#editor-cancel").addEventListener("click", closeEditor);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user