crypto.ts
Raw
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
6async 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
27export 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
42export 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