Files
crushdb/backend/static/crypto.js
T
jhkaktus 2ff58441f4 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>
2026-10-11 00:01:30 +02:00

156 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}