eleven / messenger Public

crypto: add binary encryptBytes/decryptBytes for attachments

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell cc96f4ddae9dfd4b68e0cc5e33d6e7b0f1e614ab parent 1cf9b4b
2 files changed, +39 −0
  • test/crypto.test.mjs +19 −0
  • web/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);
+}