SHA256
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>
This commit is contained in:
+114
-30
@@ -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 {
|
||||||
@@ -42,21 +47,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 ----------
|
||||||
@@ -136,6 +144,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +215,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 +245,49 @@ 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);
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
$("#passphrase").value = "";
|
$("#passphrase").value = "";
|
||||||
$("#unlock").hidden = true;
|
$("#setup").hidden = true;
|
||||||
$("#app").hidden = false;
|
$("#app").hidden = false;
|
||||||
await reload();
|
await reload();
|
||||||
} catch (e) {
|
$("#setup-pass1").value = "";
|
||||||
flash("Entsperren fehlgeschlagen: " + e.message, true);
|
$("#setup-pass2").value = "";
|
||||||
|
$("#setup").hidden = true;
|
||||||
|
$("#app").hidden = false;
|
||||||
|
await reload();
|
||||||
|
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() {
|
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 +295,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user