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>
17 KiB
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:oobchild and contains noaesgcmstring 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-streamand 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:oobpresent, noaesgcm, 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/mp4where the browser offers it, which is the iOS case. - A federated contact's encrypted attachment stays unopenable by design,
because
connect-srcnames 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.