feat: client-side crypto – Argon2id KDF + XChaCha20-Poly1305 AEAD

Adds a vanilla ES-module crypto layer for the browser:
- Argon2id (t=2, m=16 MiB, p=1) derives a 256-bit session key from the
  passphrase; the passphrase never leaves the client.
- Records are encrypted with XChaCha20-Poly1305, fresh 24-byte nonce
  per record, per-record salt bound into the AEAD associated data
  together with the uuid (tamper/swap detection).
- Vendored audited @noble/hashes + @noble/ciphers ES modules; verified
  end-to-end in node (encrypt/decrypt roundtrip, wrong key, tampered
  salt, swapped uuid all rejected).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
2026-10-11 00:01:30 +02:00
parent a6a37d675d
commit 2ff58441f4
16 changed files with 7456 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
/**
* 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);
}