zero-knowledge file sharing
1// AES-256-GCM helpers using Web Crypto API
2// Each upload gets a unique key, so a fixed zero IV is safe.
3// Filename + file body are packed into a single blob before encryption,
4// so only one (key, IV) pair is ever used.
5
6const IV = new Uint8Array(12); // 12 zero bytes
7const PADDING_BLOCK = 4096;
8
9export async function generateKey() {
10 const key = await crypto.subtle.generateKey(
11 { name: "AES-GCM", length: 256 },
12 true,
13 ["encrypt", "decrypt"],
14 );
15 const raw = await crypto.subtle.exportKey("raw", key);
16 return {
17 key,
18 encoded: new Uint8Array(raw).toBase64({
19 alphabet: "base64url",
20 omitPadding: true,
21 }),
22 };
23}
24
25export async function importKey(encoded: string) {
26 const raw = Uint8Array.fromBase64(encoded, { alphabet: "base64url" });
27 return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, [
28 "encrypt",
29 "decrypt",
30 ]);
31}
32
33// Pack: [u16 filenameLen][u64 fileLen][filename][file][zero padding to 4K boundary]
34// Then encrypt the whole thing as one AES-GCM ciphertext.
35export async function encrypt(
36 fileName: string,
37 fileBuffer: ArrayBuffer,
38 key: CryptoKey,
39) {
40 const nameBytes = new TextEncoder().encode(fileName);
41 if (nameBytes.length > 0xffff) throw new Error("Filename too long");
42
43 const headerSize = 2 + 8; // u16 + u64
44 const payloadSize = headerSize + nameBytes.length + fileBuffer.byteLength;
45 const paddedSize = Math.ceil(payloadSize / PADDING_BLOCK) * PADDING_BLOCK;
46
47 const buf = new ArrayBuffer(paddedSize);
48 const view = new DataView(buf);
49 const bytes = new Uint8Array(buf);
50
51 // Header
52 view.setUint16(0, nameBytes.length, false); // big-endian
53 // u64 file length — DataView has no setUint64, use two u32s
54 const fileLen = fileBuffer.byteLength;
55 view.setUint32(2, Math.floor(fileLen / 0x100000000), false); // high 32
56 view.setUint32(6, fileLen >>> 0, false); // low 32
57
58 // Filename + file body
59 bytes.set(nameBytes, headerSize);
60 bytes.set(new Uint8Array(fileBuffer), headerSize + nameBytes.length);
61
62 const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv: IV }, key, buf);
63 return new Uint8Array(ct);
64}
65
66// Decrypt and unpack — returns { fileName, fileData }
67export async function decrypt(
68 ciphertext: Uint8Array<ArrayBuffer>,
69 key: CryptoKey,
70) {
71 const plain = await crypto.subtle.decrypt(
72 { name: "AES-GCM", iv: IV },
73 key,
74 ciphertext,
75 );
76
77 const view = new DataView(plain);
78 const nameLen = view.getUint16(0, false);
79 const fileLenHi = view.getUint32(2, false);
80 const fileLenLo = view.getUint32(6, false);
81 const fileLen = fileLenHi * 0x100000000 + fileLenLo;
82
83 const headerSize = 2 + 8;
84 const nameBytes = new Uint8Array(plain, headerSize, nameLen);
85 const fileName = new TextDecoder().decode(nameBytes);
86 const fileData = new Uint8Array(plain, headerSize + nameLen, fileLen);
87
88 return { fileName, fileData };
89}