crypto.ts
| 1 | // AES-256-GCM (authenticated) encryption with a PBKDF2-derived key, using the |
| 2 | // WebCrypto API (crypto.subtle) which is available both in Bun (server) and the |
| 3 | // browser (client), so encryption/decryption is defined once for both sides. |
| 4 | // GCM gives us integrity/authentication for free (the tag is appended to the |
| 5 | // ciphertext by WebCrypto), so tampering and padding-oracle attacks don't apply. |
| 6 | // Wire format: salt[16] | iv[12] | ciphertext+tag. |
| 7 | |
| 8 | async function deriveKey( |
| 9 | password: string, |
| 10 | salt: Uint8Array, |
| 11 | usage: KeyUsage[], |
| 12 | ): Promise<CryptoKey> { |
| 13 | const material = await crypto.subtle.importKey( |
| 14 | "raw", |
| 15 | new TextEncoder().encode(password), |
| 16 | { name: "PBKDF2" }, |
| 17 | false, |
| 18 | ["deriveKey"], |
| 19 | ); |
| 20 | return crypto.subtle.deriveKey( |
| 21 | { name: "PBKDF2", salt, iterations: 210000, hash: "SHA-512" }, |
| 22 | material, |
| 23 | { name: "AES-GCM", length: 256 }, |
| 24 | false, |
| 25 | usage, |
| 26 | ); |
| 27 | } |
| 28 | |
| 29 | export async function encrypt( |
| 30 | content: Uint8Array, |
| 31 | password: string, |
| 32 | ): Promise<Uint8Array> { |
| 33 | const iv = crypto.getRandomValues(new Uint8Array(12)); |
| 34 | const salt = crypto.getRandomValues(new Uint8Array(16)); |
| 35 | const key = await deriveKey(password, salt, ["encrypt"]); |
| 36 | const ciphertext = await crypto.subtle.encrypt( |
| 37 | { name: "AES-GCM", iv }, |
| 38 | key, |
| 39 | content, |
| 40 | ); |
| 41 | return new Uint8Array([...salt, ...iv, ...new Uint8Array(ciphertext)]); |
| 42 | } |
| 43 | |
| 44 | export async function decrypt( |
| 45 | data: Uint8Array, |
| 46 | password: string, |
| 47 | ): Promise<Uint8Array> { |
| 48 | const salt = data.slice(0, 16); |
| 49 | const iv = data.slice(16, 28); |
| 50 | const key = await deriveKey(password, salt, ["decrypt"]); |
| 51 | const plaintext = await crypto.subtle.decrypt( |
| 52 | { name: "AES-GCM", iv }, |
| 53 | key, |
| 54 | data.slice(28), |
| 55 | ); |
| 56 | return new Uint8Array(plaintext); |
| 57 | } |
| 58 |