monal-rebuild/UX-MEDIA-RESULTS.md
pijukka 3e3540be5f fix(chat): quote an attachment by name, not by its URL
The operator swiped a voice note to quote it and got the raw
aesgcm://…#<key> URL in the quote. db.ts has labelled attachments for the
chat-list preview since package 9, with the comment that the preview never
shows that URL because it carries the file key — the quote path never did
the same.

quotedText now names the attachment, and displayQuote names an aesgcm://
line at render time too, for history written before this and for whatever
a remote client sends. Only aesgcm:// lines are replaced: a plain https
link in a quote may simply be a link somebody quoted.

This was never a leak over the wire. In an encrypted chat the quote rides
in the same envelope as the original, and send() refuses an encrypted
quote into a chat that is not capable. What it was: the file key copied
into a second message's body for nothing, and a wall of hex where a human
expects "Voice message".

Checks: 95 tests, two of them new and specific to this; zero svelte-check
errors; build; SWIPE-QUOTES-ATTACHMENT-BY-NAME uploads a real encrypted
image, swipes it and asserts the preview, the sent quote and the outgoing
stanza; ux-swipe-runner.cjs 12/12 and ux-reactions-runner.cjs 14/14 under
both engines, zero page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 02:46:22 +02:00

17 KiB
Raw Permalink Blame History

Encrypted attachments — 2026-10-08

The operator approved package 9 "design first", then implementation, then the release. Sol High is retained; no delegation. Existing blunix handoff edits remain unstaged there. Baseline release was generation 556, client 8ea43b9.

Format decision

XEP-0454 aesgcm://, not XEP-0447. The full write-up with its source evidence is in OMEMO-PLAN.md, "Encrypted attachments: format decided"; the short version is that Monal's MLMessageProcessor.m recognises an encrypted file transfer only from a decrypted body that is a single whitespace-free aesgcm:// token, its xmpp.m adds the jabber:x:oob child only on the unencrypted branch, and it implements no urn:xmpp:sfs:0 at all. So: the URL alone, no caption, no thumbnail line, no OOB child, AES-256-GCM with a 12-byte IV first and the tag appended, fragment hex(iv)||hex(key) = 88 characters. Receive additionally accepts the 96-character and key-only layouts Conversations' TransportSecurity documents.

No blunix change was needed, verified rather than assumed: upload.0ff.space's file route already answers access-control-allow-origin: https://chat.0ff.space from mod_http_file_share's own cors block, the chat CSP already permits that fetch and blob: media, no upload type restriction is configured, and the 10 MiB http_file_share_size_limit only needed the client to count the 16-byte tag.

Implementation

Client 031766c. New src/crypto/aesgcm.ts on plain WebCrypto, so an attachment never depends on omemoSupported(). sendFile encrypts for a capable 1:1 contact and uploads the ciphertext as application/octet-stream under a <uuid>.<ext> name; mediaCapability reports encrypt instead of the old limitation text; isAttachment became attachmentUrl(body, oob, encrypted) so neither the chat-list preview nor a notification can print a URL carrying a file key; Chat.svelte fetches, decrypts and renders one object URL per message, released when the message leaves the loaded window or the view closes.

Two defects found while building, both written up in OMEMO-PLAN.md, "Encrypted attachments implemented": url.protocol = "aesgcm:" silently does nothing because WHATWG URL refuses a non-special scheme over a special one, and — more seriously — the design's own send path would have put the aesgcm:// body, which is the file key, on the wire in cleartext if the peer's device list emptied between the upload and the send. 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.

Local checks: npm run check clean, npm test 86 passing (9 new in test/aesgcm.test.ts, including a vector decrypted from an independent node:crypto AES-256-GCM ciphertext), npm run build succeeds.

Release and live evidence

Client 031766c pushed to main, pinned by the isolated blunix d23cb82 lock commit. The operator ran the switch; it succeeded as generation 557, system /nix/store/bivnm359j1a1h9awvbbh8nw6h32ch0s6-nixos-system-blunix-26.11.20260910.8ce4ef6, nginx root /nix/store/cjk60a637slh8h2kdb25dmjb860s2839-0ff-chat-0.1.0. The Nix-built bundle, the local dist build and the bundle served over HTTPS are byte-identical (b8e62d8e…), and sw.js is unchanged (e28aaebf…). HTTPS 200, the strict CSP unchanged, and the iPhone black-translucent status metadata retained. Ten checked platform services active, zero failed units. Rollback: 556.

Live acceptance: ux-media-runner.cjs against the deployed bundle with two Linux browser profiles (Chromium 152) and the dedicated audit accounts — all ten checks pass, zero page errors, zero network failures (media-chromium-1791471031068).

  • The encrypted chat offers attachments at all, and the old "aren't supported yet" text is gone.
  • Image, generic file and voice round trips: every outgoing stanza is OMEMO encrypted, carries no jabber:x:oob child and contains no aesgcm string in cleartext XML; the receiving profile decrypts independently. The 1×1 PNG decodes to 1×1 on the other side, and the generic file is saved through a real browser download as the exact plaintext bytes.
  • Metadata: slot requests declare application/octet-stream and a <uuid>.<ext> filename, never the user's name, and the chat-list preview reads "📷 Photo" rather than a URL.
  • Upload sizes prove the payload: plaintext 70 and 296 bytes became 86 and 312 — plaintext + the 16-byte tag, exactly — and the only plaintext PUT in the whole run was the group upload, which is plaintext by design.
  • Cancellation sent no message; in this run it also made no PUT at all, because the abort landed during the slot request.
  • A 404 reads "No longer available on the server." and an integrity failure reads "This file could not be decrypted."; the two are distinct, and the retry recovers the same file once the bytes are intact.
  • An aesgcm:// URL on a foreign host is explained without issuing any request to it, and no request left the app's own origin and upload host for the entire run.
  • The group path is untouched: jabber:x:oob present, no aesgcm, the real media type and the real file name kept, bytes unaltered.

Three of those checks first failed on the runner's own mistakes, not the app's, and the causes are worth recording because they would mislead the next run: Prosody serves uploads with cache-control: max-age=31556952, immutable, so re-reading an already-fetched attachment never reaches the network and a reload-based check asserts nothing — each failure check now sends a fresh attachment; the profiles are persistent, so a bare waitFor on a blob image matches an earlier run's attachment and passes without anything being sent — the round trips now count; and connect-src does not list blob:, so an in-page fetch of the app's own object URL is blocked by CSP, which is why the saved-file check uses a real download instead. The app never fetches its own blob, so this is not a product gap.

The group fixture ux-media-chromium-1791471061479@muc.0ff.space was destroyed at the end of the run (confirmed in the report), the seeded foreign-host message row was deleted, no browsers remained, and the host finished with all services active and zero failed units. Reports, profiles and credentials stay under /tmp/chat-ux-runner, never in Git.

Still open, and only the operator can close it

  • The round trip against real Monal in both directions: send an encrypted photo, file and voice note from the PWA and open them in Monal, and the reverse. Two copies of this web client cannot substitute for it.
  • iPhone media hardware: actual recording permission, recording and playback, plus playback of a Monal-sent voice note.
  • A known pre-existing limitation worth watching there: a WebM voice note recorded on desktop will not play in Monal on iOS. The recorder prefers audio/mp4 where the browser offers it, which is the iOS case.
  • A federated contact's encrypted attachment stays unopenable by design, because connect-src names only our own upload host. Not to be "fixed" by widening the CSP.

Monal interop, 2026-10-08 (partly confirmed, partly my own fault)

Monal → PWA, encrypted attachments: confirmed (corrected 2026-10-08, later the same day; the first version of this section misattributed a text message and is wrong). From lukas@0ff.space/Monal-iOS.5a842ecc at 15:30:48 and 15:31:07 UTC: a PDF and a JPG, both stored encrypted: true with an aesgcm:// body, no oob and undecryptable: false, both fetched HTTP 200 and rendered from blob URLs — the image as an image, the PDF as a file link. A second JPG was fetched as well. The "Jo works" text came from lukas@0ff.space/web.97d38d50, the operator's own web client, not from Monal, so Monal has confirmed the attachment direction, not a text.

The voice note from Monal: resolved on the second attempt, and it works. The first one, reported missing, never reached the archive at all; nothing on this side could have shown it. The operator recorded another one and it arrived at 16:14:00 UTC from lukas@0ff.space/Monal-iOS.5a842ecc: stored encrypted: true, aesgcm:// body, no oob, undecryptable: false, file name C8331F4B-266E-4634-9DE1-E288BD30E54B.m4a — so Monal anonymises its audio path as well as its photo path. It decrypted (AES-GCM verified the tag, which is what a successful decrypt means), rendered as an <audio> element from a blob URL, and no .media-problem branch appeared. That completes the receive direction: photo, document and voice note from real Monal. Checked read-only against the untouched interop profile; what became of the first voice note was never established and I will not guess.

A Monal-side observation, not a defect here: the PDF reached the upload service under its real file name, while the photos carried Monal's anonymised uppercase-UUID names. So Monal 6.4.21 anonymises its photo path but not its document path, and the document name is visible to the server even though the file is encrypted. This client always anonymises.

Two earlier Monal messages are permanently unreadable here, and that is a test-sequencing mistake, not a defect. They arrived at 14:48:32 and 14:50:38 UTC, encrypted to the devices the audit account published at the time. Between acceptance runs the browser profiles were deleted twice (rm -rf media-profiles) to clear stale state — which destroyed the OMEMO identities those messages were encrypted to. The client rendered exactly the right thing, "Encrypted message sent before this device was added." Lesson for the next round: never wipe an audit profile while the operator is testing from their phone against it, and keep the interop profile separate from the runner's.

That separate profile now exists: /tmp/chat-ux-runner/monal-profile/a, not touched by any runner. While setting it up, the 17 orphaned devices left by the wiped profiles were retracted through Settings' own "Your devices" cleanup; ux-audit-20261007-a now publishes exactly one device, 119135999, fingerprint 1F26855F 880CD830 C159E24C 95951685 FD376B60 BE22A594 5BA09200 64DED562. Worth knowing for future automation: each removal goes through a confirm(), and Playwright dismisses dialogs by default, so without page.on('dialog', d => d.accept()) every click is a silent no-op.

PWA → Monal, attachments: sent and verifiably encrypted; display in Monal is the operator's step. From that profile a text, a photo (0ff-chat-test.png, the client's own 192×192 icon), a text file (package9-note.txt) and a recorded voice note went to the operator's account. All four stanzas are confirmed from the server archive — encrypted: true, oob: null, the OMEMO fallback body — at 15:26:47, 15:26:50, 15:26:51 and 15:26:54 UTC. Each also rendered from a decrypted blob on the sender's own side, and the re-download after a reload returned HTTP 200 for all three.

One pleasant surprise: the voice note went out as .m4a — Chromium offered audio/mp4, so the recorder's first preference applied and the "WebM will not play in Monal on iOS" caveat should not bite this file. That caveat still holds for a browser that only offers WebM.

The receiving half is now confirmed against that profile. Remaining, operator-side and only that: open the three attachments sent to Monal and say whether the photo displays, the file opens and the voice note plays.

Also open: the operator reports that sending an attachment from chat.0ff.space does not work for them, with a description still to come. Nothing here reproduces it — the audit profile sent a photo, a file and a voice note from the deployed bundle minutes earlier, all four stanzas encrypted with no oob. The first hypothesis is a stale installed PWA: the service worker is network-first for the page, so a reload picks up a deploy, but an installed iOS PWA resumed from the background keeps its running document and therefore the old bundle. Previous asset index-DlzpKHNm.js, current index-CKa1YQZ8.js; nginx keeps no access log here, so which one a given client loads cannot be checked from the server.

Dead attachment button on iOS — 2026-10-08, fix shipped, cause unproven

The operator reported that pressing the attachment button on chat.0ff.space did nothing at all: no picker, no error.

openPicker was if (await checkMedia()) picker?.click(), and checkMedia calls mediaCapability(peer, true) — a forced PEP device list fetch plus the WASM probe, so a network round trip — before the programmatic picker.click(). iOS Safari ignores a programmatic click on a file input once an await has let the click's user-activation window lapse, and silently: exactly the reported symptom. The same shape also means a slow or stalled capability fetch leaves the button inert with no feedback, since that call has no timeout. Rooms never showed it because mediaCapability returns for a joined room without awaiting anything real, and an encrypted 1:1 chat only got a clickable button with package 9.

Fixed by splitting the decision: a synchronous mediaGate() uses the capability the view already keeps current, so the picker opens — and the microphone is asked for — inside the click; the forced recheck still runs in sendAttachment before anything is uploaded, so unsupported media still never reaches a slot request or a PUT, and a chat known not to accept media still never triggers a permission prompt.

Confirmed on the operator's iPhone: after the switch to generation 558 they report the attachment button working. That is the only evidence that could settle it, for the reason below.

Two honest limits on the automated side. First, the live acceptance run never exercised this path at all: every media check sets files on the input directly, which bypasses the button. A MEDIA-PICKER-OPENS check now presses the real button. Second, that check does not guard the iOS restriction: a control run with the await deliberately restored passes in both Chromium and Linux WebKit, so neither engine enforces Safari's activation window for this. The regression it would catch is a disconnected or broken button, not a lapsed activation window — do not read a green MEDIA-PICKER-OPENS as cover for that class of bug.

Released as generation 558, client 19a71e9, nginx root …war8p4rppi3xidz11i9716yi480c8lhf-0ff-chat-0.1.0; the Nix build and the HTTPS-served bundle are byte-identical (3626926447…), sw.js and the CSP unchanged, ten services active, zero failed units. Rollback: 557.

The full suite re-ran against it: all eleven checks pass, zero page errors, zero network failures, the room fixture destroyed. One check, MEDIA-GROUP-PLAINTEXT, failed on that first re-run and it was again the runner, not the client: chat(room) waits for an enabled composer, which the conversation being navigated away from already satisfies, so the attach landed before the room was joined and the client correctly refused with "Join the room before sending files". It now waits for the header to show participants. Worth remembering: a green run right after a UI change is worth re-reading, because a check can pass or fail for reasons that have nothing to do with the change.

Quoting an attachment names it — 2026-10-09

The operator swiped a voice note to quote it and got the raw aesgcm://…#<key> URL in the quote. db.ts has always labelled an attachment for the chat-list preview, with the comment that the preview never shows that URL because it carries the file key; the quote path did not. quotedText in src/xmpp/replies.ts now labels it, and displayQuote labels an aesgcm:// line at render time as well, for history written earlier and for whatever a remote client sends. Only aesgcm:// lines are replaced: a plain https link in a quote may simply be a link.

This was never a leak over the wire. In an encrypted chat the quote rides in the same OMEMO envelope as the original, and session.send refuses an encrypted quote into a chat that is not capable. What it was: the file key copied into a second message for nothing, and a wall of hex where a human expects "Voice message".

Checked by SWIPE-QUOTES-ATTACHMENT-BY-NAME in ux-swipe-runner.cjs, which uploads a real encrypted image, swipes it, and asserts the preview reads 📷 Photo, the sent quote names the attachment, and neither the quote nor the outgoing stanza carries aesgcm:. Two unit tests in test/replies.test.ts pin the transformation itself.

An unrelated observation, recorded not chased

While verifying the above, full runs of ux-media-runner.cjs flaked: MEDIA-FILE-ROUNDTRIP ("no message went out" although the receiver had the file), MEDIA-GROUP-PLAINTEXT and MEDIA-NO-STRAY-REQUESTS. The same full run against the deployed site fails the same way, so this is not the quote change. Focused runs of the individual checks pass on both. The likely cause is the state the two audit accounts accumulated across many runner invocations in one day — several of these checks count PUTs and assume no other traffic. Worth a clean-account run before reading anything into it.