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 // Cancels the source and releases the reader lock. Used when the consuming
224 // stream is cancelled (e.g. a client aborts a download) so the underlying
225 // file handle isn't held until GC.
226 async cancel(): Promise<void> {
227 this.#done = true;
228 await this.#reader.cancel().catch(() => {});
229 }
230}
231
232// Encrypts a plaintext stream into the chunked wire format. `onHead`, if given,
233// is invoked once with up to `headBytes` of leading plaintext (used to sniff a
234// media type for previews) before the stream completes.
235export async function encryptStream(
236 input: ReadableStream<Uint8Array>,
237 password: string,
238 onHead?: (head: Uint8Array) => void,
239 headBytes = 4100,
240): Promise<ReadableStream<Uint8Array>> {
241 const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
242 const prefix = crypto.getRandomValues(new Uint8Array(PREFIX_LEN));
243 const key = await deriveKey(password, salt, ["encrypt"]);
244 const reader = new ByteStreamReader(input);
245 let index = 0;
246 let headDone = onHead === undefined;
247 const headParts: Uint8Array[] = [];
248 let headLen = 0;
249
250 function recordHead(chunk: Uint8Array) {
251 if (headDone) return;
252 const need = headBytes - headLen;
253 if (need > 0) {
254 const slice = chunk.subarray(0, need);
255 headParts.push(slice);
256 headLen += slice.length;
257 }
258 }
259 function flushHead() {
260 if (!headDone) {
261 headDone = true;
262 onHead?.(concat(headParts));
263 }
264 }
265
266 return new ReadableStream<Uint8Array>({
267 start(controller) {
268 // salt + noncePrefix come first, before any chunk.
269 controller.enqueue(concat([salt, prefix]));
270 },
271 async pull(controller) {
272 const current = await reader.readUpTo(CHUNK);
273 recordHead(current);
274 // Peek (without consuming) whether more plaintext follows so the final
275 // chunk's flag is set correctly. An empty input still yields one final
276 // (empty) chunk on the first pull.
277 const last = !(await reader.hasMore());
278 controller.enqueue(await sealChunk(key, prefix, index, last, current));
279 index++;
280 if (last) {
281 flushHead();
282 controller.close();
283 }
284 },
285 cancel() {
286 return reader.cancel();
287 },
288 });
289}
290
291// Decrypts a stream in the chunked wire format. `totalLen` is the full byte
292// length of the source (e.g. the on-disk file size) so chunk boundaries and the
293// final chunk can be derived. Returns the first plaintext chunk eagerly (for
294// MIME sniffing) plus a `body` stream that re-emits it and then the rest, all
295// from a single key derivation.
296export async function decryptToStream(
297 source: ReadableStream<Uint8Array>,
298 password: string,
299 totalLen: number,
300): Promise<{ firstChunk: Uint8Array; body: ReadableStream<Uint8Array> }> {
301 const dataLen = totalLen - HEADER_LEN;
302 if (dataLen < TAG_LEN) throw new Error("ciphertext too short");
303 const reader = new ByteStreamReader(source);
304 const header = await reader.readExact(HEADER_LEN);
305 const salt = header.subarray(0, SALT_LEN);
306 const prefix = header.subarray(SALT_LEN, HEADER_LEN);
307 const key = await deriveKey(password, salt, ["decrypt"]);
308
309 let consumed = 0;
310 let index = 0;
311 async function next(): Promise<Uint8Array | null> {
312 if (consumed >= dataLen) return null;
313 const encLen = Math.min(ENC_CHUNK, dataLen - consumed);
314 const last = consumed + encLen >= dataLen;
315 const ct = await reader.readExact(encLen);
316 consumed += encLen;
317 const pt = await openChunk(key, prefix, index, last, ct);
318 index++;
319 return pt;
320 }
321
322 // Decrypt the first chunk now (throws on a wrong password / tamper).
323 const firstChunk = (await next()) ?? new Uint8Array(0);
324 let firstEmitted = false;
325 const body = new ReadableStream<Uint8Array>({
326 async pull(controller) {
327 if (!firstEmitted) {
328 firstEmitted = true;
329 controller.enqueue(firstChunk);
330 return;
331 }
332 const chunk = await next();
333 if (chunk === null) controller.close();
334 else controller.enqueue(chunk);
335 },
336 cancel() {
337 return reader.cancel();
338 },
339 });
340 return { firstChunk, body };
341}
342