crypto: add binary encryptBytes/decryptBytes for attachments
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 files changed,
+39
−0
test/crypto.test.mjs+19 −0web/static/crypto.js+20 −0
diff --git a/test/crypto.test.mjs b/test/crypto.test.mjs| index f57a51d..dfe510e 100644 |
| --- a/test/crypto.test.mjs |
| +++ b/test/crypto.test.mjs |
| @@ -6,6 +6,8 @@ import { |
| importKey, |
| encrypt, |
| decrypt, |
| + encryptBytes, |
| + decryptBytes, |
| b64urlToBytes, |
| bytesToB64url, |
| } from "../web/static/crypto.js"; |
| @@ -63,5 +65,22 @@ await test("two clients sharing the exported key interoperate", async () => { |
| assert.equal(out.text, "shared"); |
| }); |
| +await test("encryptBytes → decryptBytes round-trips binary data", async () => { |
| + const { key } = await generateKey(); |
| + const original = new Uint8Array([0, 1, 2, 3, 250, 251, 255, 128, 64]); |
| + const enc = await encryptBytes(key, original); |
| + assert.ok(enc.length > original.length, "ciphertext carries iv + tag overhead"); |
| + const dec = await decryptBytes(key, enc); |
| + assert.deepEqual(Array.from(dec), Array.from(original)); |
| +}); |
| + |
| +await test("encryptBytes output is not the plaintext", async () => { |
| + const { key } = await generateKey(); |
| + const original = new Uint8Array([10, 20, 30, 40]); |
| + const enc = await encryptBytes(key, original); |
| + // The first 12 bytes are the IV; the rest must differ from the plaintext. |
| + assert.notDeepEqual(Array.from(enc.slice(12)), Array.from(original)); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/crypto.js b/web/static/crypto.js| index 710a01f..7258847 100644 |
| --- a/web/static/crypto.js |
| +++ b/web/static/crypto.js |
| @@ -57,3 +57,23 @@ export async function decrypt(key, blob) { |
| const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); |
| return JSON.parse(new TextDecoder().decode(pt)); |
| } |
| + |
| +// Binary variants for file/image uploads. Returns/accepts raw iv(12)||ciphertext |
| +// bytes (Uint8Array) so attachments upload as octet-stream, not base64. |
| +export async function encryptBytes(key, bytes) { |
| + const iv = crypto.getRandomValues(new Uint8Array(12)); |
| + const ct = new Uint8Array( |
| + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, bytes) |
| + ); |
| + const out = new Uint8Array(iv.length + ct.length); |
| + out.set(iv, 0); |
| + out.set(ct, iv.length); |
| + return out; |
| +} |
| + |
| +export async function decryptBytes(key, bytes) { |
| + const iv = bytes.slice(0, 12); |
| + const ct = bytes.slice(12); |
| + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); |
| + return new Uint8Array(pt); |
| +} |