crypto.ts
Raw
1// Chunked, streaming AES-256-GCM (authenticated) encryption with a
2// PBKDF2-derived key, using WebCrypto (crypto.subtle) so the exact same format
3// works in Bun (server) and the browser (client), and is matched byte-for-byte
4// by assets/encrypt.py.
5//
6// The content is split into fixed-size plaintext chunks, each sealed
7// independently with AES-GCM. This lets the server encrypt on upload and
8// decrypt on download *while streaming* (chunk by chunk) instead of holding the
9// whole file in memory. The key is still derived only once per file, so the
10// expensive PBKDF2 cost is paid once regardless of size.
11//
12// Wire format: salt[16] | noncePrefix[7] | encChunk_0 | encChunk_1 | ...
13// encChunk_i = AES-GCM(key, nonce_i, plaintext_i) (16-byte tag appended)
14// nonce_i(12) = noncePrefix[7] || uint32_be(i) || flag (flag=1 on the final
15// chunk, else 0)
16// The per-chunk counter prevents reordering and the final-chunk flag prevents
17// truncation: dropping or rearranging chunks fails authentication. Each
18// non-final plaintext chunk is exactly CHUNK bytes, so the decryptor can derive
19// chunk boundaries (and which chunk is last) from the total length alone —
20// no per-chunk length prefixes are stored.
21
22const SALT_LEN = 16;
23const PREFIX_LEN = 7;
24const TAG_LEN = 16;
25const HEADER_LEN = SALT_LEN + PREFIX_LEN; // bytes before the first chunk
26export const CHUNK = 64 * 1024; // plaintext bytes per chunk
27const ENC_CHUNK = CHUNK + TAG_LEN; // ciphertext bytes for a full chunk
28
29// `crypto.subtle` wants a BufferSource; Bun's lib types are stricter about
30// ArrayBuffer vs SharedArrayBuffer than the values we actually pass, so cast at
31// the boundary rather than littering call sites with copies.
32const src = (b: Uint8Array): BufferSource => b as unknown as BufferSource;
33
34async function deriveKey(
35 password: string,
36 salt: Uint8Array,
37 usage: KeyUsage[],
38): Promise<CryptoKey> {
39 const material = await crypto.subtle.importKey(
40 "raw",
41 src(new TextEncoder().encode(password)),
42 { name: "PBKDF2" },
43 false,
44 ["deriveKey"],
45 );
46 return crypto.subtle.deriveKey(
47 { name: "PBKDF2", salt: src(salt), iterations: 210000, hash: "SHA-512" },
48 material,
49 { name: "AES-GCM", length: 256 },
50 false,
51 usage,
52 );
53}
54
55function nonce(prefix: Uint8Array, index: number, last: boolean): Uint8Array {
56 const n = new Uint8Array(12);
57 n.set(prefix, 0);
58 // 4-byte big-endian chunk counter at offset 7.
59 n[7] = (index >>> 24) & 0xff;
60 n[8] = (index >>> 16) & 0xff;
61 n[9] = (index >>> 8) & 0xff;
62 n[10] = index & 0xff;
63 n[11] = last ? 1 : 0;
64 return n;
65}
66
67function concat(parts: Uint8Array[]): Uint8Array {
68 let total = 0;
69 for (const p of parts) total += p.length;
70 const out = new Uint8Array(total);
71 let off = 0;
72 for (const p of parts) {
73 out.set(p, off);
74 off += p.length;
75 }
76 return out;
77}
78
79async function sealChunk(
80 key: CryptoKey,
81 prefix: Uint8Array,
82 index: number,
83 last: boolean,
84 plaintext: Uint8Array,
85): Promise<Uint8Array> {
86 const ct = await crypto.subtle.encrypt(
87 { name: "AES-GCM", iv: src(nonce(prefix, index, last)) },
88 key,
89 src(plaintext),
90 );
91 return new Uint8Array(ct);
92}
93
94async function openChunk(
95 key: CryptoKey,
96 prefix: Uint8Array,
97 index: number,
98 last: boolean,
99 ciphertext: Uint8Array,
100): Promise<Uint8Array> {
101 const pt = await crypto.subtle.decrypt(
102 { name: "AES-GCM", iv: src(nonce(prefix, index, last)) },
103 key,
104 src(ciphertext),
105 );
106 return new Uint8Array(pt);
107}
108
109// ----------------------------------------------------------------------------
110// One-shot helpers (browser uploads/downloads, tests, python parity). These
111// buffer the whole payload; the server uses the streaming variants below.
112// ----------------------------------------------------------------------------
113
114export async function encrypt(
115 content: Uint8Array,
116 password: string,
117): Promise<Uint8Array> {
118 const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
119 const prefix = crypto.getRandomValues(new Uint8Array(PREFIX_LEN));
120 const key = await deriveKey(password, salt, ["encrypt"]);
121 const out: Uint8Array[] = [salt, prefix];
122 // Always emit at least one (possibly empty) final chunk so empty input still
123 // round-trips and the final-chunk flag is always present.
124 const chunks = Math.max(1, Math.ceil(content.length / CHUNK));
125 for (let i = 0; i < chunks; i++) {
126 const start = i * CHUNK;
127 const slice = content.subarray(
128 start,
129 Math.min(start + CHUNK, content.length),
130 );
131 out.push(await sealChunk(key, prefix, i, i === chunks - 1, slice));
132 }
133 return concat(out);
134}
135
136export async function decrypt(
137 data: Uint8Array,
138 password: string,
139): Promise<Uint8Array> {
140 if (data.length < HEADER_LEN + TAG_LEN) {
141 throw new Error("ciphertext too short");
142 }
143 const salt = data.subarray(0, SALT_LEN);
144 const prefix = data.subarray(SALT_LEN, HEADER_LEN);
145 const key = await deriveKey(password, salt, ["decrypt"]);
146 const body = data.subarray(HEADER_LEN);
147 const out: Uint8Array[] = [];
148 let offset = 0;
149 let index = 0;
150 while (offset < body.length) {
151 const encLen = Math.min(ENC_CHUNK, body.length - offset);
152 const last = offset + encLen >= body.length;
153 out.push(
154 await openChunk(
155 key,
156 prefix,
157 index,
158 last,
159 body.subarray(offset, offset + encLen),
160 ),
161 );
162 offset += encLen;
163 index++;
164 }
165 return concat(out);
166}
167
168// ----------------------------------------------------------------------------
169// Streaming helpers (server-side encrypt on upload / decrypt on download).
170// ----------------------------------------------------------------------------
171
172// A small pull-based reader over a ReadableStream that can hand back exact byte
173// counts, buffering only the unconsumed remainder.
174class ByteStreamReader {
175 #reader: ReadableStreamDefaultReader<Uint8Array>;
176 #buf: Uint8Array = new Uint8Array(0);
177 #done = false;
178
179 constructor(stream: ReadableStream<Uint8Array>) {
180 this.#reader = stream.getReader();
181 }
182
183 async #fill(): Promise<boolean> {
184 if (this.#done) return false;
185 const { done, value } = await this.#reader.read();
186 if (done) {
187 this.#done = true;
188 return false;
189 }
190 this.#buf = this.#buf.length === 0 ? value : concat([this.#buf, value]);
191 return true;
192 }
193
194 // Reads exactly `n` bytes, or throws if the stream ends first.
195 async readExact(n: number): Promise<Uint8Array> {
196 while (this.#buf.length < n) {
197 if (!(await this.#fill())) throw new Error("unexpected end of stream");
198 }
199 const out = this.#buf.subarray(0, n);
200 this.#buf = this.#buf.subarray(n);
201 return out;
202 }
203
204 // Reads up to `n` bytes; returns fewer only at end of stream.
205 async readUpTo(n: number): Promise<Uint8Array> {
206 while (this.#buf.length < n) {
207 if (!(await this.#fill())) break;
208 }
209 const take = Math.min(n, this.#buf.length);
210 const out = this.#buf.subarray(0, take);
211 this.#buf = this.#buf.subarray(take);
212 return out;
213 }
214
215 // True if more bytes remain, without consuming them.
216 async hasMore(): Promise<boolean> {
217 while (this.#buf.length === 0) {
218 if (!(await this.#fill())) return false;
219 }
220 return true;
221 }
222}
223
224// Encrypts a plaintext stream into the chunked wire format. `onHead`, if given,
225// is invoked once with up to `headBytes` of leading plaintext (used to sniff a
226// media type for previews) before the stream completes.
227export async function encryptStream(
228 input: ReadableStream<Uint8Array>,
229 password: string,
230 onHead?: (head: Uint8Array) => void,
231 headBytes = 4100,
232): Promise<ReadableStream<Uint8Array>> {
233 const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
234 const prefix = crypto.getRandomValues(new Uint8Array(PREFIX_LEN));
235 const key = await deriveKey(password, salt, ["encrypt"]);
236 const reader = new ByteStreamReader(input);
237 let index = 0;
238 let headDone = onHead === undefined;
239 const headParts: Uint8Array[] = [];
240 let headLen = 0;
241
242 function recordHead(chunk: Uint8Array) {
243 if (headDone) return;
244 const need = headBytes - headLen;
245 if (need > 0) {
246 const slice = chunk.subarray(0, need);
247 headParts.push(slice);
248 headLen += slice.length;
249 }
250 }
251 function flushHead() {
252 if (!headDone) {
253 headDone = true;
254 onHead?.(concat(headParts));
255 }
256 }
257
258 return new ReadableStream<Uint8Array>({
259 start(controller) {
260 // salt + noncePrefix come first, before any chunk.
261 controller.enqueue(concat([salt, prefix]));
262 },
263 async pull(controller) {
264 const current = await reader.readUpTo(CHUNK);
265 recordHead(current);
266 // Peek (without consuming) whether more plaintext follows so the final
267 // chunk's flag is set correctly. An empty input still yields one final
268 // (empty) chunk on the first pull.
269 const last = !(await reader.hasMore());
270 controller.enqueue(await sealChunk(key, prefix, index, last, current));
271 index++;
272 if (last) {
273 flushHead();
274 controller.close();
275 }
276 },
277 });
278}
279
280// Decrypts a stream in the chunked wire format. `totalLen` is the full byte
281// length of the source (e.g. the on-disk file size) so chunk boundaries and the
282// final chunk can be derived. Returns the first plaintext chunk eagerly (for
283// MIME sniffing) plus a `body` stream that re-emits it and then the rest, all
284// from a single key derivation.
285export async function decryptToStream(
286 source: ReadableStream<Uint8Array>,
287 password: string,
288 totalLen: number,
289): Promise<{ firstChunk: Uint8Array; body: ReadableStream<Uint8Array> }> {
290 const dataLen = totalLen - HEADER_LEN;
291 if (dataLen < TAG_LEN) throw new Error("ciphertext too short");
292 const reader = new ByteStreamReader(source);
293 const header = await reader.readExact(HEADER_LEN);
294 const salt = header.subarray(0, SALT_LEN);
295 const prefix = header.subarray(SALT_LEN, HEADER_LEN);
296 const key = await deriveKey(password, salt, ["decrypt"]);
297
298 let consumed = 0;
299 let index = 0;
300 async function next(): Promise<Uint8Array | null> {
301 if (consumed >= dataLen) return null;
302 const encLen = Math.min(ENC_CHUNK, dataLen - consumed);
303 const last = consumed + encLen >= dataLen;
304 const ct = await reader.readExact(encLen);
305 consumed += encLen;
306 const pt = await openChunk(key, prefix, index, last, ct);
307 index++;
308 return pt;
309 }
310
311 // Decrypt the first chunk now (throws on a wrong password / tamper).
312 const firstChunk = (await next()) ?? new Uint8Array(0);
313 let firstEmitted = false;
314 const body = new ReadableStream<Uint8Array>({
315 async pull(controller) {
316 if (!firstEmitted) {
317 firstEmitted = true;
318 controller.enqueue(firstChunk);
319 return;
320 }
321 const chunk = await next();
322 if (chunk === null) controller.close();
323 else controller.enqueue(chunk);
324 },
325 });
326 return { firstChunk, body };
327}
328