// AES-256-GCM (authenticated) encryption with a PBKDF2-derived key, using the // WebCrypto API (crypto.subtle) which is available both in Bun (server) and the // browser (client), so encryption/decryption is defined once for both sides. // GCM gives us integrity/authentication for free (the tag is appended to the // ciphertext by WebCrypto), so tampering and padding-oracle attacks don't apply. // Wire format: salt[16] | iv[12] | ciphertext+tag. async function deriveKey( password: string, salt: Uint8Array, usage: KeyUsage[], ): Promise { const material = await crypto.subtle.importKey( "raw", new TextEncoder().encode(password), { name: "PBKDF2" }, false, ["deriveKey"], ); return crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations: 210000, hash: "SHA-512" }, material, { name: "AES-GCM", length: 256 }, false, usage, ); } export async function encrypt( content: Uint8Array, password: string, ): Promise { const iv = crypto.getRandomValues(new Uint8Array(12)); const salt = crypto.getRandomValues(new Uint8Array(16)); const key = await deriveKey(password, salt, ["encrypt"]); const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, content, ); return new Uint8Array([...salt, ...iv, ...new Uint8Array(ciphertext)]); } export async function decrypt( data: Uint8Array, password: string, ): Promise { const salt = data.slice(0, 16); const iv = data.slice(16, 28); const key = await deriveKey(password, salt, ["decrypt"]); const plaintext = await crypto.subtle.decrypt( { name: "AES-GCM", iv }, key, data.slice(28), ); return new Uint8Array(plaintext); }