rewirite to new name

This commit is contained in:
2026-10-14 20:29:22 +02:00
parent 2120c56f45
commit 1cdb3fa784
23 changed files with 2521 additions and 5755 deletions
+47 -147
View File
@@ -1,155 +1,55 @@
/**
* crypto.js – client-side encryption module (vanilla ES module).
*
* All cryptography happens in the browser. The passphrase and derived
* key never leave this module's memory and are never sent anywhere.
*
* Design:
* - Key derivation: Argon2id (via @noble/hashes), one key per session,
* derived once from the passphrase the user enters on opening the
* site. The salt used for derivation is *not* the per-record salt;
* it is a random domain salt stored alongside each record.
* - Cipher: XChaCha20-Poly1305 (AEAD) with a fresh random 24-byte nonce
* per record (via @noble/ciphers).
* - Each record carries its own random salt. That salt is bound into
* the AEAD header (as an extra attribute) so re-deriving the key for
* a record with a modified salt reliably fails. TLS-like: header is
* encrypted alongside the payload body.
* - AAD (associated data) binds the record's uuid into the AEAD header,
* so a ciphertext cannot be swapped between records undetected.
*
* Everything is in-memory only: this file has no persistence, and the
* session key must be wiped explicitly by the caller (wipeKey).
*/
"use strict";
import { xchacha20poly1305 } from "./vendor/noble-ciphers/chacha.js";
import { randomBytes } from "./vendor/noble-ciphers/utils.js";
import { argon2id } from "./vendor/noble-hashes/argon2.js";
const ARGON2_PARAMS = {
// Moderate defaults so unlocking stays usable on typical hardware in-JS.
t: 2, // passes
m: 16384, // memory, KiB (16 MiB)
p: 1, // parallelism
dkLen: 32, // 256-bit key for XChaCha20
};
const REC_SALT_LEN = 16; // per-record salt is independent from KDF salt? No:
// the KDF salt IS the per-record salt (see below).
// ---------- base64 / utf8 helpers ----------
export function b64encode(bytes) {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
export function b64decode(str) {
const bin = atob(str);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
export function utf8encode(str) {
return new TextEncoder().encode(str);
}
export function utf8decode(bytes) {
return new TextDecoder().decode(bytes);
}
export function randomUuid() {
return crypto.randomUUID();
}
// ---------- key derivation ----------
/**
* Derive a 32-byte key from a passphrase + salt with Argon2id.
* Runs entirely in the browser; the passphrase never leaves it.
*/
export function deriveKey(passphrase, saltBytes) {
if (typeof passphrase !== "string" || passphrase.length === 0)
throw new Error("invalid passphrase");
if (!(saltBytes instanceof Uint8Array) || saltBytes.length < 8)
throw new Error("invalid salt");
const pwBytes = utf8encode(passphrase);
try {
return argon2id(pwBytes, saltBytes, ARGON2_PARAMS);
} finally {
pwBytes.fill(0); // best-effort scratch wipe
function req(path, options) {
const headers = { "Content-Type": "application/json" };
options = Object.assign({ method: "GET", headers }, options || {});
if (options.body && typeof options.body === "object") {
options.body = JSON.stringify(options.body);
}
return fetch(path, options).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || "request failed");
return data;
});
});
}
// ---------- record encryption ----------
/**
* Encrypt a record payload.
*
* For a NEW record pass salt = randomSalt(). For an UPDATE pass the
* salt already stored on the record — the payload must be encrypted
* under a key derived from the SAME salt, otherwise decryption on the
* other side fails loudly. This binds the salt to the ciphertext:
* a modified salt (= malicious re-key) cannot go unnoticed.
*
* Payload is any JSON-serializable value. Returns the record body
* ready for the API ({uuid, ciphertext, nonce, salt}) with the AEAD
* computed over uuid+salt as associated data.
*/
export function encryptRecord(key, uuid, payload, saltBytes) {
if (!(saltBytes instanceof Uint8Array) || saltBytes.length !== REC_SALT_LEN)
throw new Error("invalid record salt");
const nonce = randomBytes(24);
// AAD binds uuid AND salt: swapping ciphertext between records or
// silently changing the salt breaks the tag.
const aad = utf8encode(
"crushdb:record:" + uuid + ":" + b64encode(saltBytes)
);
const plaintext = utf8encode(JSON.stringify(payload));
const ciphertext = xchacha20poly1305(key, nonce, aad).encrypt(plaintext);
plaintext.fill(0); // no plaintext residue in heap
return {
uuid,
ciphertext: b64encode(ciphertext),
nonce: b64encode(nonce),
salt: b64encode(saltBytes),
};
}
export function randomSalt() {
return randomBytes(REC_SALT_LEN);
}
// ---------- record decryption ----------
/**
* Decrypt a record fetched from the API.
* Throws on wrong key, wrong salt, or any tampering (AEAD tag).
*/
export function decryptRecord(key, rec) {
if (!rec || typeof rec.uuid !== "string") throw new Error("invalid record");
const saltBytes = b64decode(rec.salt);
const aad = utf8encode(
"crushdb:record:" + rec.uuid + ":" + b64encode(saltBytes)
);
const ct = b64decode(rec.ciphertext);
const nonce = b64decode(rec.nonce);
const plaintext = xchacha20poly1305(key, nonce, aad).decrypt(ct);
try {
return JSON.parse(utf8decode(plaintext));
} finally {
plaintext.fill(0);
function postForm(path, form) {
const data = {};
for (const [k, v] of new FormData(form)) {
if (v) data[k] = v;
}
return req(path, { method: "POST", body: JSON.stringify(data) });
}
// ---------- hygiene ----------
/**
* Best-effort wipe of a derived key (32 bytes). JS cannot guarantee
* zeroing, but this removes the obvious copy.
*/
export function wipeKey(key) {
if (key instanceof Uint8Array) key.fill(0);
function patchJson(path, body) {
return req(path, { method: "PATCH", body: body });
}
function delReq(path) {
return req(path, { method: "DELETE" });
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function uuid() {
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
const a = new Uint8Array(16);
(crypto.getRandomValues || function () { for (let i = 0; i < a.length; i++) a[i] = Math.floor(Math.random() * 256); })(a);
a[6] = (a[6] & 0x0f) | 0x40;
a[8] = (a[8] & 0x3f) | 0x80;
const hex = Array.from(a, b => b.toString(16).padStart(2, "0")).join("");
return hex.slice(0, 8) + "-" + hex.slice(8, 12) + "-" + hex.slice(12, 16) + "-" + hex.slice(16, 20) + "-" + hex.slice(20);
}
function appendMsg(el, message, isError) {
el.textContent = message || "";
el.classList.toggle("hidden", !message);
el.style.color = isError ? "#dc2626" : "#047857";
}