crypto.test.ts
Raw
1import { describe, expect, test } from "bun:test";
2import {
3 CHUNK,
4 decrypt,
5 decryptToStream,
6 encrypt,
7 encryptStream,
8} from "./crypto";
9
10function streamOf(data: Uint8Array, pieces = 1): ReadableStream<Uint8Array> {
11 const size = Math.ceil(data.length / pieces) || 1;
12 let off = 0;
13 return new ReadableStream<Uint8Array>({
14 pull(c) {
15 if (off >= data.length) {
16 c.close();
17 return;
18 }
19 c.enqueue(data.subarray(off, off + size));
20 off += size;
21 },
22 });
23}
24
25async function collect(
26 stream: ReadableStream<Uint8Array>,
27): Promise<Uint8Array> {
28 const parts: Uint8Array[] = [];
29 const reader = stream.getReader();
30 while (true) {
31 const { done, value } = await reader.read();
32 if (done) break;
33 parts.push(value);
34 }
35 let total = 0;
36 for (const p of parts) total += p.length;
37 const out = new Uint8Array(total);
38 let off = 0;
39 for (const p of parts) {
40 out.set(p, off);
41 off += p.length;
42 }
43 return out;
44}
45
46describe("crypto (chunked AES-256-GCM)", () => {
47 test("round-trips content with the correct password", async () => {
48 const data = new TextEncoder().encode("hello zbin 🔐 multi-byte");
49 const enc = await encrypt(data, "correct horse battery staple");
50 const dec = await decrypt(enc, "correct horse battery staple");
51 expect(new TextDecoder().decode(dec)).toBe("hello zbin 🔐 multi-byte");
52 });
53
54 test("wire format is salt[16] | prefix[7] | ct+tag", async () => {
55 const enc = await encrypt(new Uint8Array([1, 2, 3]), "pw");
56 // 16 (salt) + 7 (prefix) + 3 (plaintext) + 16 (GCM tag)
57 expect(enc.length).toBe(16 + 7 + 3 + 16);
58 });
59
60 test("round-trips empty input", async () => {
61 const enc = await encrypt(new Uint8Array(0), "pw");
62 expect(enc.length).toBe(16 + 7 + 16); // single empty final chunk (tag only)
63 const dec = await decrypt(enc, "pw");
64 expect(dec.length).toBe(0);
65 });
66
67 test("round-trips multi-chunk content (> CHUNK)", async () => {
68 const data = crypto.getRandomValues(new Uint8Array(CHUNK * 2 + 1234));
69 const enc = await encrypt(data, "pw");
70 const dec = await decrypt(enc, "pw");
71 expect(dec).toEqual(data);
72 });
73
74 test("round-trips content of exactly CHUNK bytes", async () => {
75 const data = crypto.getRandomValues(new Uint8Array(CHUNK));
76 const dec = await decrypt(await encrypt(data, "pw"), "pw");
77 expect(dec).toEqual(data);
78 });
79
80 test("rejects a wrong password (authenticated)", async () => {
81 const enc = await encrypt(new Uint8Array([1, 2, 3]), "right");
82 await expect(decrypt(enc, "wrong")).rejects.toThrow();
83 });
84
85 test("rejects tampered ciphertext", async () => {
86 const enc = await encrypt(new Uint8Array([9, 9, 9]), "pw");
87 enc[enc.length - 1] ^= 0xff; // flip a tag byte
88 await expect(decrypt(enc, "pw")).rejects.toThrow();
89 });
90
91 test("rejects truncation (dropping the final chunk)", async () => {
92 const data = crypto.getRandomValues(new Uint8Array(CHUNK * 2));
93 const enc = await encrypt(data, "pw");
94 // Drop the final (full) chunk; the now-last chunk was sealed with flag=0
95 // but will be opened with flag=1, so authentication must fail.
96 const truncated = enc.subarray(0, enc.length - (CHUNK + 16));
97 await expect(decrypt(truncated, "pw")).rejects.toThrow();
98 });
99});
100
101describe("crypto streaming", () => {
102 test("encryptStream output decrypts via one-shot decrypt", async () => {
103 const data = crypto.getRandomValues(new Uint8Array(CHUNK * 3 + 7));
104 const enc = await collect(await encryptStream(streamOf(data, 5), "pw"));
105 expect(await decrypt(enc, "pw")).toEqual(data);
106 });
107
108 test("encryptStream invokes onHead with leading plaintext", async () => {
109 const data = new TextEncoder().encode("GIF89a-ish header then more data");
110 let head: Uint8Array | undefined;
111 await collect(
112 await encryptStream(streamOf(data, 3), "pw", (h) => {
113 head = h;
114 }),
115 );
116 expect(head).toBeDefined();
117 expect(new TextDecoder().decode((head as Uint8Array).subarray(0, 6))).toBe(
118 "GIF89a",
119 );
120 });
121
122 test("decryptToStream round-trips and exposes the first chunk", async () => {
123 const data = crypto.getRandomValues(new Uint8Array(CHUNK + 500));
124 const enc = await encrypt(data, "pw");
125 const { firstChunk, body } = await decryptToStream(
126 streamOf(enc, 4),
127 "pw",
128 enc.length,
129 );
130 expect(firstChunk).toEqual(data.subarray(0, CHUNK));
131 expect(await collect(body)).toEqual(data);
132 });
133
134 test("decryptToStream rejects a wrong password", async () => {
135 const enc = await encrypt(new Uint8Array([1, 2, 3]), "right");
136 await expect(
137 decryptToStream(streamOf(enc), "wrong", enc.length),
138 ).rejects.toThrow();
139 });
140
141 test("stream encrypt -> stream decrypt round-trips", async () => {
142 const data = crypto.getRandomValues(new Uint8Array(CHUNK * 2 + 42));
143 const enc = await collect(await encryptStream(streamOf(data, 7), "pw"));
144 const { body } = await decryptToStream(streamOf(enc, 3), "pw", enc.length);
145 expect(await collect(body)).toEqual(data);
146 });
147});
148