monal-rebuild/test/media.test.ts
pijukka 031766cdcc feat(chat): send and read encrypted attachments (XEP-0454)
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>
2026-10-08 13:30:44 +02:00

69 lines
3.7 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { attachmentKind, attachmentLabel, attachmentUrl, fileName, isEncryptedMedia, isTrustedMedia, mediaType } from "../src/media.ts";
const up = (name: string) => `https://upload.0ff.space/file_share/abc123/${name}`;
test("attachment kind from the file extension", () => {
assert.equal(attachmentKind(up("IMG_0042.JPG")), "image");
assert.equal(attachmentKind(up("photo.heic")), "image");
assert.equal(attachmentKind(up("Voice%20Message.m4a")), "audio");
assert.equal(attachmentKind(up("voice-2026-09-28T16-00-00.webm")), "audio");
assert.equal(attachmentKind(up("clip.webm")), "video");
assert.equal(attachmentKind(up("movie.mov")), "video");
assert.equal(attachmentKind(up("report.pdf")), "file");
assert.equal(attachmentKind(up("noextension")), "file");
// An encrypted upload keeps only its extension, which is what both ends
// of the transfer go on (OMEMO-PLAN.md, "Encrypted attachments").
assert.equal(attachmentKind("aesgcm://upload.0ff.space/f/4f3c.png#ab"), "image");
});
test("file names are decoded from the URL path", () => {
assert.equal(fileName(up("Voice%20Message.m4a")), "Voice Message.m4a");
assert.equal(attachmentLabel(up("report.pdf")), "📎 report.pdf");
assert.equal(attachmentLabel(up("a.png")), "📷 Photo");
// A preview or notification label never includes the fragment: it is the
// file's key.
assert.equal(attachmentLabel("aesgcm://upload.0ff.space/f/4f3c.png#ab"), "📷 Photo");
assert.equal(fileName("aesgcm://upload.0ff.space/f/4f3c.bin#ab"), "4f3c.bin");
});
test("only an https body equal to the OOB URL is a plaintext attachment", () => {
assert.equal(attachmentUrl(up("a.png"), up("a.png")), up("a.png"));
assert.equal(attachmentUrl(`look: ${up("a.png")}`, up("a.png")), undefined);
assert.equal(attachmentUrl("http://x/a.png", "http://x/a.png"), undefined);
assert.equal(attachmentUrl(up("a.png"), undefined), undefined);
});
test("an aesgcm body is an attachment only inside the envelope", () => {
const enc = "aesgcm://upload.0ff.space/f/a.png#ab";
assert.equal(attachmentUrl(enc, undefined, true), enc);
// Not encrypted: an aesgcm URL in a cleartext body is just text. And an
// `oob` child means the URL travelled outside the envelope, which is
// never treated as the encrypted shape.
assert.equal(attachmentUrl(enc, undefined, false), undefined);
assert.equal(attachmentUrl(enc, enc, true), undefined);
// Monal's rule: the URL alone, so no caption and no thumbnail line.
assert.equal(attachmentUrl(`${enc} look`, undefined, true), undefined);
assert.equal(attachmentUrl(`${enc}\ndata:image/jpeg;base64,x`, undefined, true), undefined);
assert.equal(isEncryptedMedia(enc), true);
assert.equal(isEncryptedMedia(up("a.png")), false);
});
test("a decrypted blob gets its media type back from the extension", () => {
assert.equal(mediaType(up("a.JPG")), "image/jpeg");
assert.equal(mediaType(up("voice-2026-10-08.webm")), "audio/webm");
assert.equal(mediaType(up("clip.webm")), "video/webm");
assert.equal(mediaType(up("note.m4a")), "audio/mp4");
assert.equal(mediaType(up("report.pdf")), "application/octet-stream");
assert.equal(mediaType("aesgcm://upload.0ff.space/f/4f3c.png#ab"), "image/png");
});
test("media is inline only from our own upload hosts", () => {
const hosts = ["upload.0ff.space"];
assert.equal(isTrustedMedia(up("a.png"), hosts), true);
assert.equal(isTrustedMedia("https://UPLOAD.0ff.space/x/a.png", hosts), true);
assert.equal(isTrustedMedia("https://evil.example/a.png", hosts), false);
assert.equal(isTrustedMedia("https://upload.0ff.space.evil.example/a.png", hosts), false);
assert.equal(isTrustedMedia("http://upload.0ff.space/a.png", hosts), false);
});