Package 9's code half, built against OMEMO-PLAN.md's format decision. An image, file or voice message to an OMEMO-capable contact is encrypted with AES-256-GCM before the XEP-0363 upload and shared as an aesgcm:// URL whose fragment carries iv||key, sent through the existing encrypted path with no oob child at all — the composer no longer refuses it. New src/crypto/aesgcm.ts on plain WebCrypto (no libsignal, so an attachment never depends on omemoSupported): encrypt, decrypt, build and parse, plus the anonymised <uuid>.<ext> upload name Monal also uses. Receive accepts all three fragment layouts in the wild. mediaCapability now reports encrypt instead of a limitation, isAttachment became attachmentUrl(body, oob, encrypted) so the chat-list preview and notification label show "Photo" rather than a URL containing a key, and Chat.svelte fetches, decrypts and renders through one object URL per message with a distinct reason per failure and a retry. Two defects found while building, both written up in OMEMO-PLAN.md: - url.protocol = "aesgcm:" silently does nothing — WHATWG URL refuses a non-special scheme over a special one — so build and parse both returned https URLs. The fixed-vector test caught it. Swapped textually now, as Monal and Conversations do. - A key leak in the design's own send path: send() encrypts only while the peer is currently capable, so an emptied device list between upload and send would have put the aesgcm body, which is the file key, on the wire in clear. The authorize callback now rejects a changed encryption decision rather than only a withdrawn one, and send() refuses an aesgcm body without an envelope outright, and in a room at all. npm run check clean, 86 tests pass (9 new, including a vector decrypted from an independent node:crypto ciphertext), npm run build succeeds. Not run: live browser, real Monal round trip, iPhone recording. Not deployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
94 lines
4.8 KiB
TypeScript
94 lines
4.8 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createCipheriv } from "node:crypto";
|
|
import { anonymousName, buildAesgcmUrl, decryptFile, encryptFile, parseAesgcmUrl, TAG_BYTES } from "../src/crypto/aesgcm.ts";
|
|
|
|
const KEY = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
|
|
const IV = Uint8Array.from({ length: 12 }, (_, i) => 0xa0 + i);
|
|
const KEY_HEX = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
|
|
const IV_HEX = "a0a1a2a3a4a5a6a7a8a9aaab";
|
|
const GET = "https://upload.0ff.space/file_share/abc123/4f3c.png";
|
|
|
|
test("the fragment is hex(iv) then hex(key), 88 characters, Monal's layout", () => {
|
|
const url = buildAesgcmUrl(GET, { key: KEY, iv: IV });
|
|
assert.equal(url, `aesgcm://upload.0ff.space/file_share/abc123/4f3c.png#${IV_HEX}${KEY_HEX}`);
|
|
const fragment = url.slice(url.indexOf("#") + 1);
|
|
assert.equal(fragment.length, 88);
|
|
// Monal classifies an encrypted message as a file transfer only if the
|
|
// whole decrypted body is one token with no whitespace (OMEMO-PLAN.md).
|
|
assert.match(url, /^aesgcm:\/\/\S+$/);
|
|
assert.equal(fragment, fragment.toLowerCase());
|
|
});
|
|
|
|
test("an upload URL that is not https is refused", () => {
|
|
assert.throws(() => buildAesgcmUrl("http://upload.0ff.space/x/a.png", { key: KEY, iv: IV }), /non-HTTPS/);
|
|
});
|
|
|
|
test("ciphertext from an independent AES-256-GCM implementation decrypts", async () => {
|
|
// node:crypto rather than our own encryptFile, so this asserts the wire
|
|
// layout (ciphertext with the tag appended) and not just self-consistency.
|
|
const cipher = createCipheriv("aes-256-gcm", KEY, IV);
|
|
const body = Buffer.concat([cipher.update(Buffer.from("hello attachment")), cipher.final()]);
|
|
const sealed = new Uint8Array(Buffer.concat([body, cipher.getAuthTag()]));
|
|
assert.equal(sealed.length, "hello attachment".length + TAG_BYTES);
|
|
const plain = await decryptFile(sealed, { key: KEY, iv: IV });
|
|
assert.equal(Buffer.from(plain).toString(), "hello attachment");
|
|
});
|
|
|
|
test("our own round trip, and the tag's size overhead", async () => {
|
|
const plaintext = new Uint8Array(1024).fill(7);
|
|
const sealed = await encryptFile(plaintext);
|
|
assert.equal(sealed.ciphertext.length, plaintext.length + TAG_BYTES);
|
|
assert.equal(sealed.key.length, 32);
|
|
assert.equal(sealed.iv.length, 12);
|
|
const back = await decryptFile(sealed.ciphertext, sealed);
|
|
assert.deepEqual(back, plaintext);
|
|
});
|
|
|
|
test("a flipped byte, a truncated file and a wrong key all fail closed", async () => {
|
|
const sealed = await encryptFile(new Uint8Array([1, 2, 3, 4]));
|
|
const flipped = Uint8Array.from(sealed.ciphertext);
|
|
flipped[0] ^= 0x01;
|
|
await assert.rejects(decryptFile(flipped, sealed), /could not be decrypted/);
|
|
await assert.rejects(decryptFile(sealed.ciphertext.slice(0, -1), sealed), /could not be decrypted/);
|
|
await assert.rejects(decryptFile(sealed.ciphertext, { key: KEY, iv: sealed.iv }), /could not be decrypted/);
|
|
});
|
|
|
|
test("all three fragment layouts in the wild are accepted", () => {
|
|
// Ours and Monal's: iv(12) || key(32).
|
|
const ours = parseAesgcmUrl(`aesgcm://upload.0ff.space/f/a.png#${IV_HEX}${KEY_HEX}`);
|
|
assert.deepEqual(ours?.iv, IV);
|
|
assert.deepEqual(ours?.key, KEY);
|
|
// The older iv(16) || key(32).
|
|
const iv16 = "000102030405060708090a0b0c0d0e0f";
|
|
const wide = parseAesgcmUrl(`aesgcm://upload.0ff.space/f/a.png#${iv16}${KEY_HEX}`);
|
|
assert.equal(wide?.iv.length, 16);
|
|
assert.deepEqual(wide?.key, KEY);
|
|
// Key only, with the IV Conversations hardcoded for this shape.
|
|
const bare = parseAesgcmUrl(`aesgcm://upload.0ff.space/f/a.png#${KEY_HEX}`);
|
|
assert.deepEqual(bare?.key, KEY);
|
|
assert.deepEqual(bare?.iv, Uint8Array.from({ length: 16 }, (_, i) => i));
|
|
});
|
|
|
|
test("the fetch URL is https and never carries the fragment", () => {
|
|
const parsed = parseAesgcmUrl(` aesgcm://upload.0ff.space/f/a.png#${IV_HEX}${KEY_HEX} `);
|
|
assert.equal(parsed?.url, "https://upload.0ff.space/f/a.png");
|
|
});
|
|
|
|
test("an unusable link is rejected rather than half-parsed", () => {
|
|
assert.equal(parseAesgcmUrl(`https://upload.0ff.space/f/a.png#${IV_HEX}${KEY_HEX}`), null);
|
|
assert.equal(parseAesgcmUrl("aesgcm://upload.0ff.space/f/a.png"), null);
|
|
assert.equal(parseAesgcmUrl("aesgcm://upload.0ff.space/f/a.png#short"), null);
|
|
assert.equal(parseAesgcmUrl(`aesgcm://upload.0ff.space/f/a.png#zz${IV_HEX}${KEY_HEX}`), null);
|
|
assert.equal(parseAesgcmUrl("not a url at all"), null);
|
|
});
|
|
|
|
test("the uploaded name keeps only its extension", () => {
|
|
const name = anonymousName("Holiday in Rome.JPG");
|
|
assert.match(name, /^[0-9a-f-]{36}\.jpg$/);
|
|
assert.doesNotMatch(name, /Rome/i);
|
|
// No extension, or something too odd to be one, leaves just the uuid.
|
|
assert.match(anonymousName("receipt"), /^[0-9a-f-]{36}$/);
|
|
assert.match(anonymousName("archive.tar.gz"), /^[0-9a-f-]{36}\.gz$/);
|
|
assert.match(anonymousName("weird.name with spaces"), /^[0-9a-f-]{36}$/);
|
|
});
|