# 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 `.` 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 `.` 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 `