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

300 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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