/** * 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). */ 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 } } // ---------- 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); } } // ---------- 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); }