SHA256
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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user