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