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>
69 lines
3.7 KiB
TypeScript
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);
|
|
});
|