SHA256
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>
156 lines
5.1 KiB
JavaScript
156 lines
5.1 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|