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>
300 lines
17 KiB
Markdown
300 lines
17 KiB
Markdown
# 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.
|