crypto.ts
Raw
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
8async 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
29export 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
44export 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