monal-rebuild/OMEMO-PLAN.md
pijukka 1802100aa0 test(chat): accept encrypted attachments live, and record the release
New ux-media-runner.cjs exercises package 9 against the deployed bundle
with two Linux browser profiles and the dedicated audit accounts. All ten
checks pass with zero page errors: every outgoing attachment stanza is
OMEMO-encrypted with no jabber❌oob child and no cleartext aesgcm string,
the second profile decrypts image, file and voice independently (the 1x1
PNG decodes 1x1, the generic file downloads as the exact plaintext bytes),
upload sizes are plaintext + 16 to the byte, the only plaintext PUT in the
run is the group upload that is plaintext by design, cancellation sends
nothing, a 404 and an integrity failure read differently with a working
retry, a foreign host is explained without a request, and the group path
keeps its oob, name and media type.

Three checks first failed on the runner's own assumptions rather than the
app's behaviour, recorded in UX-MEDIA-RESULTS.md so the next run is not
misled: Prosody serves uploads immutable so a re-read never reaches the
network, persistent profiles make a bare waitFor match an older
attachment, and connect-src has no blob: so an in-page fetch of our own
object URL is CSP-blocked — the app only assigns it to src/href.

Release: client 031766c pinned by blunix d23cb82, live as generation 557,
Nix build and HTTPS-served bundle byte-identical, sw.js and CSP unchanged,
ten services active, zero failed units, rollback 556. The group fixture was
destroyed and the seeded row removed. Still device-bound: the real Monal
round trip and iPhone recording/playback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 16:53:28 +02:00

68 KiB
Raw Permalink Blame History

OMEMO end-to-end encryption for 0ff chat (1:1 only)

Implementation plan, researched and approved 2026-10-04. Phase 0 (verify) and Phase 1 (identity-bootstrap skeleton) are done — see "Phase 1 skeleton findings" below and docs/agentic-opencode.md in blunix for handoff status.

Context

Users of chat.0ff.space noticed that the operator (who also administers the blunix server) can read all their messages — Prosody stores 1:1 history in plaintext (MAM, retention 1y) and the admin has shell access. This is a real, structural property of the current setup, not a bug: local-only encryption (e.g. an encrypted IndexedDB) would not fix it, since the server still needs plaintext to store/relay messages. The only thing that actually addresses the complaint is end-to-end encryption, where the server only ever sees ciphertext. This project's own PLAN.md already named this ("OMEMO — hardest part in the browser") and deliberately deferred it past v1; this plan is that deferred milestone.

Decided with the operator before this plan was written:

  • Crypto library: libomemo.js (github.com/conversejs/libomemo.js, the library Converse.js itself uses) over hand-rolling X3DH/Double Ratchet from @noble/curves primitives — rolling your own protocol-level crypto for a feature whose whole point is confidentiality is a bigger risk than taking on libomemo.js's GPLv3 license and WASM core.
  • Scope: 1:1 chats only. Group chat (MUC) encryption needs per-device fan-out to every participant and is substantially harder; it stays unencrypted, unchanged, for now.
  • There's a small, separate change (an invite() method in src/sync/muc.ts, plus "Invite…"/"Delete chat" UI in Chat.svelte's menu) for inviting people into group chats and deleting 1:1 conversations from the list. That work is unrelated to encryption and is being finished first, on its own — it touches the same Chat.svelte menu area this plan's lock-icon UI sits near, so expect to rebase one on the other, not redesign either because of it.

Architecture: new files, changed files

New (all in src/crypto/, new directory):

  • omemo-db.ts — a second, separate IndexedDB database (omemo:<account>), opened with the existing helpers in src/store/idb.ts (open/request/done/remove). Stores: identity (one row: registrationId, identity keypair, our deviceId), signedPreKeys, preKeys (~100, generated at bootstrap, used flag), sessions (key `${bareJid}:${deviceId}:${ns}`, opaque ratchet state), trust (same key shape; tofu/verified/blocked + fingerprint), devices (per-peer device-id cache per namespace, so send() never blocks on a network round-trip). Critical design point: this database must be separate from ChatDB (src/store/db.ts) specifically so session.svelte.ts's logout() (lines 499-517, which calls ChatDB.destroy(account) on purpose — see its own comment at line 497 — to wipe a shared device's chat history on logout) does not also destroy OMEMO identity/sessions. Losing that identity on every logout would force key regeneration, which every contact's client would show as "this person's device key changed" — the one TOFU signal that's supposed to mean something. This mirrors why src/xmpp/device.ts already keeps deviceId/resource/savedToken in localStorage, outside ChatDB — same kind of "must outlive logout" state.
  • omemo-store.ts — adapts omemo-db.ts to whatever storage interface libomemo.js expects (read its actual .d.ts once installed — this plan researched that at README level only, not byte-exact). Watch for non-structured-cloneable return values (class instances) that need flattening to plain Uint8Array/object shapes before put().
  • omemo.ts — orchestration: identity bootstrap, device-list/bundle publish+fetch over PEP, session lookup/creation per namespace, encryptFor(peerBareJid, body), decrypt(el, fromBareJid), prekey replenishment and signed-prekey rotation.
  • omemo-wire.ts — XML only: building/parsing <encrypted> envelopes and the PEP <bundle>/device-list payloads. Kept separate from omemo.ts the way src/xmpp/parse.ts is already kept separate from src/sync/sync.ts.
  • omemo-worker.ts (if using libomemo.js's v3 worker-offload build, to keep ~111KB of WASM out of the main bundle — confirm this is worth the added complexity once the library is actually installed).
  • test/omemo.test.ts, following the existing node --test convention already used by test/parse.test.ts / test/push.test.ts.
  • src/ui/Fingerprint.svelte — small sheet listing a peer's device fingerprints with a "mark verified" toggle (display only for v1, no QR/compare flow).

Changed:

  • src/xmpp/caps.ts — BASE_FEATURES (lines 14-21) gains the devicelist +notify feature(s) for whichever namespace(s) are published (same pattern already used for NS_AVATAR_META + "+notify", line 20), so contacts' PEP pushes us live device-list updates.
  • src/xmpp/parse.ts — NS map (lines 4-18) gains the OMEMO namespace constant(s).
  • src/sync/sync.ts — #store() (lines 157-176) gets an async decrypt-before-parse step; see "Receive path" below for the exact sequencing fix needed (today's dedup-after-parse ordering is unsafe for a Double Ratchet).
  • src/xmpp/session.svelte.ts — send() (lines 116-179); only the 1:1 branch (lines 155-179) changes, the groupchat branch (lines 123-150) stays untouched. See "Send path" below.
  • src/store/db.ts — no ChatDB.VERSION bump. StoredMessage (lines 9-29) gets two new optional fields, encrypted?: boolean and undecryptable?: boolean; putMessage() (lines 132-186) already passes through whatever fields it's given, so no logic change needed there.
  • src/ui/Chat.svelte — a lock icon near the subtitle area (lines 34-46) with three states (not encrypted / encrypted / "a device's key changed — check it"), opening Fingerprint.svelte; a distinct rendering for messages marked undecryptable.
  • src/ui/Settings.svelte — new "Encryption" section (near the account block, after the existing settings) showing this device's own fingerprint for out-of-band comparison.
  • /home/drone/blunix/capabilities/xmpp/chat-client.nix (lines 29-31ish) — CSP script-src 'self' needs 'wasm-unsafe-eval' added, required for libomemo.js's WASM Curve25519 core under CSP3. Must ship with a feature-detect-and-disable fallback (try instantiating a trivial WASM module at startup; if it's blocked, disable OMEMO for that session and fall back to plaintext with a one-line notice) rather than assuming every target browser (down to the stated Safari 14+ minimum) honours the new keyword — and never widen to the much bigger 'unsafe-eval' to paper over an old browser.

Namespace: legacy OMEMO verified (Phase 0)

Two OMEMO namespaces exist: legacy eu.siacs.conversations.axolotl and urn:xmpp:omemo:2. Verified on 2026-10-04: Prosody's production PEP store contains Monal's pep_eu.siacs.conversations.axolotl.devicelist/lukas node and two matching legacy bundle nodes for lukas; it contains no urn:xmpp:omemo:2 device node. The same legacy device-list/bundle layout exists for alfo. The check listed store paths/node names only and did not read key material.

Implement legacy eu.siacs.conversations.axolotl as the required first path. Do not add dual publication to urn:xmpp:omemo:2 until a real interoperability need appears; it would double protocol state without helping the Monal devices actually deployed here. Keep the namespace in session/trust/device keys (`${bareJid}:${deviceId}:${ns}`) so a later omemo:2 addition cannot cross-contaminate a ratchet if a device id collides between namespaces.

Device list & bundle lifecycle (PEP)

On first-ever login on a browser profile: generate registration id, identity keypair, one signed prekey, ~100 one-time prekeys (persisted to omemo-db.ts before anything is published); publish the bundle to a per-device PEP node and the device list to a shared PEP node (fetch-merge- publish, the same pattern already used for bookmarks in src/sync/muc.ts:91-150 — fetch the current list item, add our device id if missing, publish the merged item back; a blind publish would wipe other devices, e.g. Monal's own entry).

Deliberate deviation from the bookmark precedent: bookmarks publish with pubsub#access_model = "whitelist" (src/sync/muc.ts:142). OMEMO bundle and device-list nodes must be "open" — a non-public bundle node is a documented real-world OMEMO interop failure (seen in a Monal GitHub discussion). Flag this in review; it's an easy copy-paste mistake from the bookmark code.

Before messaging a peer (or periodically, cached in the devices store so send() never blocks on it): fetch their device list; item-not-found or no devicelist feature in disco ⇒ not OMEMO-capable, record that and always send plaintext to them (this also covers plaza's anonymous guests for free, since PEP is disabled on the anonymous vhost). For each of their devices with no existing session, fetch its bundle and build a session consuming one of their one-time prekeys (mark it locally consumed, never republish it). Also fetch our own bare JID's device list and session with every other device found there (e.g. Monal on the phone) — required so a message sent from the web client is readable from the sender's own other devices.

Replenish used prekeys back toward ~100 on each login; rotate the signed prekey periodically, keeping the previous one valid briefly for messages already in flight.

Send path (session.svelte.ts, 1:1 branch only) — done 2026-10-04

  1. Look up the peer in the devices cache. Implemented as a check-and- refresh-if-cold call inside send() itself, not a separate prefetch triggered when Chat.svelte opens the conversation as first drafted here — that UI hook doesn't exist yet, so the very first message to a brand-new peer in a session pays for one network round trip; every later send, including to the same peer, reuses the cached result. See "Phase 3 findings" below.
  2. Capable: omemo.encryptFor(peer, body) — generate a fresh random AES-128-GCM key+IV via WebCrypto (corrected from this section's original "AES-256-GCM" — legacy OMEMO's actual, verified convention, see "Phase 3 findings"), encrypt the body once, then wrap that key (concatenated with the GCM tag) per recipient device (the peer's devices and our own other devices) via SessionCipher.encrypt. Produce the <encrypted> envelope plus a fixed, content-independent XEP-0380 fallback string (never derived from body in any way — a length-correlated fallback would leak exactly what this feature exists to hide). If encryption throws (corrupt session, failed bundle fetch mid-send): fail closed — refuse to send; the existing generic sendError UI in Chat.svelte already surfaces whatever send() throws, so no UI change was needed. Never silently fall back to plaintext for a peer known to be encrypted.
  3. Not capable: unchanged, today's plain <body> path.
  4. The local echo written to ChatDB (today's db.putMessage, lines 167-179) keeps storing real plaintext either way, for the sender's own UI, with encrypted: true set when step 2 ran. The groupchat branch (lines 123-150) is untouched.

Receive path (sync.ts / parse.ts)

parseMessage() (parse.ts:58-59) reads el.getChildText("body") synchronously and is shared by 1:1 and groupchat, live delivery and MAM replay. Decryption is async, so it can't live inside parseMessage(); it goes in Sync.#store() (sync.ts:157-176), before the call to parseMessage():

  1. Skip anything type="groupchat" outright — never attempt device-session lookup keyed off a room's bare JID, even defensively.
  2. Move the dedup check earlier. Today, de-duplication only happens inside db.putMessage() (store/db.ts:132-150), after parsing — fine for plaintext, unsafe here: a Double Ratchet advances state on every successful decrypt, so decrypting a duplicate delivery twice (MAM/carbon overlap at a page boundary is a real possibility given today's cursor logic) can desync or exhaust that sender device's ratchet. #store() must compute the message key (`${peer}|${sender}|${originId ?? id}`, same formula db.ts already uses) and check whether it's already stored before attempting decrypt — only decrypt genuinely new messages.
  3. If <encrypted> is present on a new message: omemo.decrypt(el, from) reads the sender device id and the <key> entry addressed to our own device id, builds a session via the embedded prekey reference if this is the first message from that device, unwraps the per-device AES key, then AES-GCM-decrypts the payload. Verify the exact wire-level child grouping (especially omemo:2's per-recipient <keys jid=…> grouping and GCM tag placement) against the current XEP-0384 spec text and a live Monal interop test before considering this done — this has historically been a real interop pitfall between OMEMO implementations, and this plan's own reading of it is "recalled from research," not spec-verified byte-for-byte.
  4. Success: clone el with a <body> set to the plaintext, feed it into parseMessage() unchanged, tag the result encrypted: true.
  5. No <key> entry for our device, or decrypt failure: still produce a StoredMessage (don't let it vanish) with encrypted: true, undecryptable: true and a placeholder body, rendered distinctly in Chat.svelte. A brand-new device's first MAM catch-up will hit a backlog of messages from before it existed — this is expected OMEMO behaviour (forward secrecy), not a bug; use the existing firstEver signal already threaded through #catchUp/#initial (sync.ts:177-213) to show distinct copy ("encrypted message from before this device was added") instead of a generic "could not decrypt" in that specific case.

Because identity/session state now lives in omemo-db.ts, outside what logout() destroys, the same-device logout/login case (re-reading old history after logging back in on the same browser) keeps working without special-casing.

Trust model: silent TOFU

No existing precedent for trust UI in this codebase. Given the stated threat model is specifically "the admin can passively read already-stored plaintext" — not an active attacker forging bundles in real time, a far more conspicuous attack — silent trust-on-first-use (pin the first identity key seen per device; warn only when a previously pinned key changes) matches the actual threat and avoids forcing a confirmation dialog on every new chat that most people would rubber-stamp anyway. Manual fingerprint verification stays available (the Fingerprint.svelte sheet) but opt-in. This matches Monal's own default (no blocking prompt for OMEMO unless "allow unverified calls" is explicitly off).

Licensing: resolve before shipping publicly

This repo's LICENSE is Zero-Clause BSD and README.md:50 says "use it however you like." libomemo.js is GPLv3. Bundling it into the built, distributed dist/ client creates a combined work; distributing that under a bare 0BSD claim is not GPLv3-compliant — isolating OMEMO into its own source files does not avoid this, since copyleft triggers on distributing the combination, not on source layout. Required step, not optional: before the build that includes libomemo.js is ever switched live on chat.0ff.space (fine to use during dev/test on a side-by-side host first), update LICENSE/README.md with a short, plain note — first-party source keeps its 0BSD headers, but the distributed bundle, as a whole, additionally carries GPLv3 obligations because of libomemo.js. This is the operator's call to finalize the wording of, but it must actually be written, not skipped.

blunix: no server-side Prosody changes expected

mam, pep, carbons are already active on the main chat.${domain} vhost (no modules_disabled there; the anonymous anon.${domain} vhost explicitly disables mam/pep, implying they're on by default elsewhere) — OMEMO device-lists/bundles ride on plain PEP, no mod_omemo or similar needed. Confirm this holds during the Phase 2 live test rather than assuming it; the one plausible surprise is a PEP node size/item-count limit tripped by a ~100-prekey bundle item.

After npm install libomemo.js in monal-rebuild: in blunix, follow the README's documented flake-input update step (set npmDepsHash = lib.fakeHash, build once, copy the real hash from the error) — easy to forget, call it out explicitly when the time comes.

A new blunix/docs/intent/ record (German, following chat-web-client.md's exact shape: Ziel / Umfang / Entscheidungen / Risiken / Erfolgskriterien / Stand) should document this decision once it ships, per blunix's OPENCODE.md documentation-ownership convention — new GPLv3 dependency, CSP change, and the licensing-note change all belong in it.

PLAN.md: new milestone

Add milestone 12 ("OMEMO encryption") to the existing numbered list (currently ending at 11, pinix), in that file's established style, and update the "Not in v1" bullet that currently defers OMEMO. Add rows to the ## Decisions table for: library choice (libomemo.js, GPLv3 — licensing note), namespace default/verification status, trust model (TOFU). Status-log entries follow the file's existing format (bold date+milestone lede, then sub-bullets for what was tested/found/deployed) — one per phase below, as each actually lands, same way milestones 8-10 built up incrementally.

Phased rollout (mirrors milestone 7's chat2.0ff.space side-by-side)

  1. Verify, no send/receive code — done 2026-10-04: Monal namespace check complete — production PEP evidence selects legacy OMEMO (above). Library audit complete (see "Audit findings" below): es-check es2020 --module passes with libomemo.js present. Licensing-text reviewed and applied by the operator: LICENSE and README.md's license section now carry the combined-work GPLv3 note. Phase 0 is fully done.
  2. Skeleton — done 2026-10-04: identity bootstrap in omemo-db.ts/ omemo-store.ts/omemo.ts, no network, no UI. Automated: identity generation is idempotent across repeated opens (simulating relogin); keys round-trip through IndexedDB as plain cloneable values — both covered by test/omemo.test.ts (4 tests, fake-indexeddb as a new devDependency, since this project had no Node IndexedDB test infrastructure before). See "Phase 1 skeleton findings" below for what changed against this plan's assumptions. Still manual/outstanding: log in twice on a real scratch account and confirm via devtools that the identity row is stable (the automated test covers the same thing against fake-indexeddb, not a real browser).
  3. Device lists & bundles — code done 2026-10-04: caps feature, publish/fetch, open access model, prekey replenishment all implemented — messages still send/receive as plaintext. Automated: omemo-wire.ts's pure build/parse round-trips and exact-wire-string checks (6 tests, see "Phase 2 findings" below). Still outstanding, unchanged from the plan's own wording: the live test — two scratch accounts exchange bundles via a throwaway script; real Monal shows OMEMO available for the contact — needs real credentials and is a manual step for the operator (no code-only agent can run it).
  4. Send-path encryption — code done 2026-10-04: session.svelte.ts's 1:1 send() branch now encrypts for every OMEMO-capable contact (fail-closed on any encryption error; a non-contact peer — a room occupant's full JID — is never considered, see "Phase 3 findings" below). Automated: encryptFor/decrypt round-tripped real plaintext through the actual Double Ratchet + AES-GCM locally, two simulated devices, no network (test/omemo.test.ts) — the strongest local evidence short of a live Monal. Still outstanding, unchanged from the plan's own wording: the acceptance test that actually validates the feature's purpose — send between two scratch accounts, then inspect Prosody's on-disk MAM storage directly (prosodyctl shell or the sqlite/file store) and confirm it holds ciphertext and the fixed fallback string only, never the real text — needs the operator's credentials and server access, not an agent.
  5. Receive-path decryption — code done 2026-10-04: async pre-pass in #store(), the dedup-before-decrypt reordering, new-device/old-history placeholders, all implemented — see "Phase 4 findings" below for what's unit-tested versus deliberately left to manual verification, same as Phase 2's PEP calls. Still outstanding, unchanged from the plan's own wording: account A sends messages before B's device ever logs in — confirm B's first sync shows the "not available on this device" placeholder, then a live message from A decrypts normally; log out/in on the same profile, confirm earlier messages still decrypt. Both need the operator's real accounts/devices.
  6. UX polish — done 2026-10-04: the trust model's own correctness bug is fixed (see "Phase 5 findings" below) and the full UI is built on top of it — lock icon states in Chat.svelte (none / encrypted / changed), Fingerprint.svelte, Settings' "Encryption" section, device-list prefetch on conversation open. See "Phase 5 findings" for the UI's own notes. Real-browser rendering verified 2026-10-05 (Playwright CLI, two disposable accounts against production — see "Phase 5 findings" for detail). Still unverified: the live Monal fingerprint comparison — the operator's job.
  7. Deploy — preparation done 2026-10-04, cutover pending the operator: chat-client.nix CSP change ('wasm-unsafe-eval', with the feature-detect-and-disable fallback this plan calls for — see Phase 5's findings), npmDepsHash fix (found stale while preparing this, broken since Phase 1), docs/intent/omemo-encryption.md all done and pushed. Licensing text was already applied back in Phase 0. blunix's chat-client flake input bumped to this repo's current main; nixos-rebuild build --flake .#blunix verified successful (only nginx
    • the chat package rebuild), generated nginx.conf confirmed to carry the new CSP directive. Not run: nixos-rebuild switch — no cutover, side-by-side or direct, until the operator's live interop tests (still pending from Phases 2-5) actually confirm this works against real Monal. A separate chat2-style side-by-side host was judged unnecessary this time (unlike the original xmpp-web migration): this is an incremental feature on the same already-deployed app, not a wholesale replacement, and the existing tailnet dev-server workflow (npm run dev, already used for the invite/delete-chat feature) covers the same need — revisit this judgment if the operator disagrees.

Audit findings (2026-10-04, delegated to OpenCode via herdr)

libomemo.js was installed temporarily, inspected, and fully removed again (working tree confirmed clean before and after) to verify the assumptions above against the real package:

  • Version/license confirmed: 3.0.0 (first major with TypeScript declarations and worker builds), license field GPL-3.0 as expected. Only runtime dependency: protobufjs ^8.0.0 (BSD-3-Clause).
  • Module shape: ships both ESM (dist/libomemo.esm.js, what Vite picks) and UMD/CJS, plus a 15 KB bundled dist/index.d.ts. No engines field.
  • Worker-offload build confirmed real: exports["./worker-client"] is a genuine subpath (not a separate package), ESM-only, no embedded WASM, no main-thread fallback if the worker fails to load — startWorker(url) is then mandatory. The default . build embeds the WASM as base64 (~85.6 KB decoded) and can optionally offload to a worker with automatic main-thread fallback on load failure.
  • Bundle cost, measured (vite build, main JS chunk): baseline 202.26 kB raw / 65.51 kB gzip; default import 428.14 kB / 154.75 kB (+225.9/+89.2 kB); worker-client import 301.63 kB / 90.63 kB in the main chunk (+99.4/+25.1 kB) plus a separate 165 kB dist/libomemo-worker.js fetched only when OMEMO runs. Net saving from worker-client vs. default: ~126.5 kB raw / ~64.1 kB gzip off the always-loaded chunk, at the cost of serving the worker file and its no-fallback failure mode. This trade-off is real and still open for omemo-worker.ts time; both variants are viable.
  • es-check correction: this project's npm run build (vite build) has no es-check step of its own — PLAN.md's earlier "passes es-check es2020" status entries were from a manual run, not the build script. Build + manual check both pass with libomemo.js present, but only with npx es-check es2020 'dist/assets/*.js' --module — plain script mode fails on one import.meta.url in the library's ESM dist (legal module syntax, illegal script syntax). Use --module for the Phase 0 re-check.
  • Storage-interface shape, corrected from the OMEMOStore interface in dist/index.d.ts (an InMemoryStore reference implementation ships in the same file): all methods accept sync or Promise returns (an async IndexedDB adapter is first-class). Two points correct this plan's earlier, research-level assumptions:
    • Sessions serialize to plain strings (SessionRecord.serialize()), not class instances — the "non-structured-cloneable class instance" worry above does not apply to sessions. KeyPairs/identity keys are plain ArrayBuffer/{pubKey, privKey} objects, cloneable as-is.
    • address is `${bareJid}.${deviceId}`, with no namespace in it — the OMEMO version is a separate constructor argument to SessionBuilder/SessionCipher(store, address, version), not part of the address string. The plan's `${bareJid}:${deviceId}:${ns}` key scheme must therefore be built by the storage adapter itself (e.g. one store instance per namespace, prefixing its own keys), not assumed from the library's own addressing.
    • isTrustedIdentity(address, identityKey, direction) is the library's trust hook that the plan's silent-TOFU model plugs into. Correction from Phase 1 (this audit got it backwards): saveIdentity() returns whether a previously pinned key changed, not whether it was new — a brand-new key returns false. That is in fact the more useful TOFU signal (silent pin, loud re-pin), so the Phase 1 adapter implements the corrected behaviour, not this line's original claim. OMEMOVersion = "eu.siacs.conversations.axolotl" | "urn:xmpp:omemo:2" is accepted directly by KeyHelper/SessionBuilder/SessionCipher calls, confirming legacy-namespace support needs no workaround.

Phase 1 skeleton findings (2026-10-04, delegated to OpenCode via herdr)

src/crypto/omemo-db.ts, omemo-store.ts, omemo.ts, and test/omemo.test.ts (4 tests, fake-indexeddb added as a new devDependency) implement identity bootstrap only — no PEP, no send/receive, no UI, exactly the "Skeleton" rollout step. Independently verified by the coordinator (not just the implementer's self-report): npm run check (0 errors/warnings), npm test (32/32, including the 28 pre-existing tests, stable across repeated runs), npm run build (succeeds; bundle size unchanged at 202.26 kB/65.51 kB gzip since nothing imports src/crypto/ into the app yet — the libomemo.js cost from the Phase 0 audit starts once Phase 2 wires it in).

Corrections found implementing against the real dist/index.d.ts, beyond the saveIdentity() direction already fixed above:

  • isTrustedIdentity() === false makes the library's own SessionCipher.encrypt/decrypt throw ("Identity key changed"), rather than return a status flag. Superseded by Phase 5 (see its "Phase 5 findings"): the original plan here was to catch that throw and render the "key changed" state from it, but that would mean a changed key blocks sending/receiving until caught — the opposite of this plan's own "Trust model: silent TOFU" section, which calls for warning, never blocking, on a changed key. isTrustedIdentity() was corrected to always return true; saveIdentity() alone detects and records a change (as a persisted status, not just a return value) for the UI to read.
  • The OMEMOStore interface's generic store/put/get/remove members are declared sync-only (put(): void), so they cannot be backed by async IndexedDB. Verified directly against the .d.ts plus the library's own call sites that SessionBuilder/SessionCipher never call these four — only the nine named, async-capable methods — so the adapter correctly keeps them as a plain in-memory Record, interface-only filler. The earlier audit's "all methods accept sync or Promise returns" is true only of those nine named methods, not the generic bag.
  • Two Node-compatibility deviations from "match ChatDB's style exactly": OmemoDB/OmemoStore use explicit field declarations instead of constructor parameter properties (constructor(private db)), because Node's --test strip-only type stripping rejects parameter properties — src/sync/muc.ts already sets this precedent in a tested module. Value imports between src/ files need explicit .ts extensions under this setup for Node ESM (also already true of muc.ts:8-9); type-only imports can stay bare.
  • generateSignedPreKey(keyPair, signedKeyId, version) takes the OMEMO-version argument directly (confirms the plan's Architecture section); PreKey/SignedPreKey.keyId are typed number in the library while the store-method parameters are typed KeyId = number | string — the adapter bridges this with Number(keyId).

Phase 2 findings (2026-10-04, delegated to OpenCode via herdr)

New src/crypto/omemo-wire.ts (legacy-namespace <bundle>/<list> build/parse, 6 unit tests including exact-wire-string assertions, not just round-trips through our own parser — so build and parse can't silently drift together away from what Monal actually reads) and new functions in src/crypto/omemo.ts: publishBundle, publishDeviceList (fetch-merge- publish, mirrors muc.ts's bookmark pattern, never blind-publishes), fetchDeviceList, fetchBundle, replenishPreKeys, and an orchestrating syncOmemo hooked into session.svelte.ts's online handler — every step independently fail-soft (caught and logged) so a PEP hiccup cannot break login. src/xmpp/caps.ts gained the devicelist +notify feature; src/xmpp/parse.ts gained the namespace literals. Independently verified by the coordinator: npm run check 0 errors/warnings, npm test 38/38 (32 existing + 6 new, stable across repeated runs), npm run build succeeds — bundle now 412.21 kB/151.07 kB gzip (up from 202.26/65.51), the libomemo.js cost the Phase 0 audit predicted, now actually paid since omemo.ts is wired into the app for the first time via session.svelte.ts.

Deviations from the plan/task, noted by the implementer and confirmed on review:

  • Function signatures differ slightly from the task's sketch: publishBundle gained a bareJid parameter (a PEP publish needs the IQ addressed somewhere — the plan's architecture section didn't spell this out for every function); replenishPreKeys(db, account) simplified to replenishPreKeys(db) (the account was unused); syncOmemo(xmpp, account, deviceId) simplified to syncOmemo(xmpp, account) since the device id already comes from the idempotent bootstrapIdentity() call inside it.
  • Publish-options minimalism, a deliberate deviation from the bookmark precedent's full option set: only pubsub#access_model=open and pubsub#persist_items=true are sent, not bookmarks' max_items/ send_last_published_item=never — every extra field risks a preconditions-not-met rejection against a node Monal may have already created with different options, and the devicelist actually wants the server's default send-last-published-item behavior (so contacts get the current list pushed to them at login), which never would have suppressed. Flagged here since it's an easy-to-miss detail if revisited.
  • A small, deliberate two-module import cycle (omemo.ts exports OMEMO_NS, omemo-wire.ts imports it back) — safe because neither side dereferences the other's exports outside function bodies; commented in-place as intentional. Trivially avoidable later by moving the constant to the leaf module if it ever causes trouble.
  • Both PEP nodes publish under item id "current" (an ecosystem convention the plan didn't specify); the device-list republish preserves whatever item id an existing list already used, rather than assuming "current".
  • Open risk for the still-pending live test: if a pre-existing, Monal-created node's configuration conflicts with either publish-options field above, the publish fails (fail-soft, logged, plaintext messaging unaffected) — this is the first thing the operator's manual scratch- account interop test should check if OMEMO doesn't show up for a contact.

Phase 3 findings (2026-10-04, implemented directly — not delegated)

Done hands-on this time, not via herdr, specifically because this phase touches real encryption rather than scaffolding. Before writing any wire code, the actual installed libomemo.js was probed directly (a throwaway local Node script, two in-memory sessions, no network, deleted afterward) to settle three things the shipped .d.ts alone leaves ambiguous or that this plan had only guessed at:

  • SessionCipher.encrypt()'s body is a binary string (one JS character per byte), not base64 — confirmed by byte-length comparison (decoding the returned string as base64 produces garbage of the wrong length; decoding it as a raw byte-per-character string matches exactly). omemo-wire.ts converts via charCodeAt, then base64-encodes the real bytes for the XML; the reverse happens on decode.
  • Legacy <bundle> elements never carry a registrationId (unlike omemo:2), yet PreKeyBundle.registrationId is a required field for SessionBuilder.processPreKey(). Confirmed empirically that passing 0 works — session build, encrypt, and decrypt all succeed — because the library only uses this field for bookkeeping, not the X3DH key agreement itself.
  • AES-128-GCM, not the plan's originally-written "AES-256-GCM": this is legacy OMEMO's actual, well-known convention (distinct from omemo:2, which does things differently) — a 16-byte key and the 16-byte GCM tag are concatenated into one 32-byte blob that gets wrapped per recipient device; <payload> carries the ciphertext without the tag. Confirmed the split/rejoin round-trips through WebCrypto before writing any real code. This corrects the plan's "Send path" section, which had the key size wrong.

New: src/crypto/omemo-wire.ts gained buildEncryptedMessage/ parseEncryptedMessage for the <encrypted> envelope (10 new tests: exact wire-string assertions, malformed-never-throws, one-bad-key-skipped — same discipline as the Phase 2 bundle/device-list tests). src/crypto/omemo.ts gained cachedDeviceList (the devices IndexedDB store Phase 1 only scaffolded, now actually read/written), ensureSession (builds a Double Ratchet session from a fetched bundle, one randomly chosen one-time prekey, tolerating an empty prekey set per X3DH), encryptFor (the full send-path: AES-GCM the body once, Double-Ratchet-wrap the key+tag per device — the peer's devices and our own other devices — fail-closed: throws if not one single device could be encrypted for), and decrypt — a pure inverse of encryptFor, written now but not yet wired into the receive path (sync.ts, Phase 4's job, with its own dedup-before-decrypt reordering); its only purpose right now is letting encryptFor be verified end-to-end locally, which two new test/omemo.test.ts tests do: a full plaintext round-trip between two simulated devices, and a check that a third, separate "own other device" (e.g. Monal on the phone) also receives a wrapped key it can decrypt. No live network involved in any of it. src/store/db.ts's StoredMessage gained encrypted?/undecryptable? as the plan's Architecture section already specified (no VERSION bump).

session.svelte.ts's send() 1:1 branch: capability checked via the cache (one network round trip per peer per session, not per message); capable ⇒ encrypt and fail closed on any error, falling back to plaintext only when genuinely not capable; not capable ⇒ unchanged. One gap this plan never addressed, found and closed while wiring this in: send()'s 1:1 branch also handles private messages to room occupants (room@muc/nick, a full JID) — OMEMO device lists are published under a bare JID only, and in a semi-anonymous room an occupant's real bare JID may not even be known. Treating peer.includes("/") (the same test isRoom/the avatar-fetch path already use for this exact distinction) as "never consider OMEMO here" avoids both a wrong PEP lookup and what would otherwise have been a hard failure (the peer check's own fetch throws on anything other than item-not-found, by design) breaking private messaging to room occupants entirely. A second gap, also not in the plan: encrypted file attachments (XEP-0066 oob) aren't specified anywhere in OMEMO-PLAN.md. Sending one to an OMEMO-capable contact now fails closed with a clear error instead of silently sending the attachment URL in the clear (which would defeat the point for exactly the kind of content most worth protecting) — a real encrypted-attachment scheme is left for a later phase, not invented ad hoc here.

Phase 4 findings (2026-10-04, implemented directly — not delegated)

Sync.#store() (sync.ts) now: skips OMEMO entirely for type="groupchat" (step 1); checks the new ChatDB.hasMessage(key) before attempting decrypt, not after (step 2) — a duplicate delivery never advances the sender device's Double Ratchet a second time, and putMessage's existing merge logic already keeps the first copy's real decrypted body for an exact-key duplicate, so skipping decrypt for one loses nothing; calls the new decryptIncoming (which never throws) and, on success, replaces body with the real plaintext and sets encrypted: true (step 3/4); on failure (no key for this device, or a decrypt error), produces a StoredMessage with encrypted: true, undecryptable: true and one of two placeholder strings depending on a new firstEver parameter threaded through #query/#initial (step 5) — distinguishing "this device wasn't added yet when this was sent" (expected OMEMO forward secrecy) from a generic decrypt failure, exactly as specified. No UI change: Chat.svelte renders these as ordinary message bodies for now (distinct styling for undecryptable is Phase 5's job).

Deliberately not unit-tested, and why: Sync's constructor uses TypeScript constructor parameter properties (private xmpp: Client, …), which Node's --test strip-only type stripping cannot handle — the same constraint omemo-db.ts/omemo-store.ts were written around in Phase 1. Sync has no existing tests at all for this reason; refactoring its constructor just to add one for this phase would be an unrelated, riskier change than the feature itself. This matches the codebase's existing convention for network-integration code (the PEP-calling functions in muc.ts's bookmarks and omemo.ts Phase 2 are equally untested, verified live instead) — the crypto logic the new glue code calls (decryptIncoming/decrypt/parseEncryptedMessage) is already thoroughly unit-tested from Phase 3, so the only genuinely unverified layer is the sync.ts wiring itself, reviewed carefully by hand and by npm run check's full type-checking of it. The still-outstanding live tests above are therefore this wiring's first real verification, not just the protocol's.

Phase 5 findings (2026-10-04, foundation implemented directly, UI delegated via herdr)

A real correctness bug, found while designing the lock icon's "key changed" state and fixed before any UI was built on top of it: OmemoStore.isTrustedIdentity() returned false on a changed identity key, which makes the library's own SessionCipher.encrypt/decrypt throw ("Identity key changed") — silently blocking every further send/receive with that device until something caught it. That directly contradicts this plan's own "Trust model: silent TOFU" section, which calls for warning, never blocking, given the stated threat model (passive storage access, not an active real-time attacker). Fixed: isTrustedIdentity() now always returns true; saveIdentity() alone detects a change and persists it as a status on the TrustRecord ("tofu" | "changed" | "verified", replacing the old bare pinned-key storage) for the UI to read — never blocking the crypto itself. A further key change always re-flags "changed" even over a prior "verified".

New exports in omemo.ts for the UI layer to build on: getTrustStatus (one device's pinned key + status), markVerified (the "mark verified" toggle Fingerprint.svelte needs), and fingerprintFor (wraps the library's FingerprintGenerator, 5200 iterations — Signal/OMEMO's long-standing convention, unverified against Monal's own display; the live out-of-band fingerprint comparison itself is that verification, not a formality). test/omemo.test.ts gained 2 new tests (status lifecycle; fingerprint stability and order-independence) plus fixes to 2 existing trust tests that asserted the old, incorrect blocking behavior. Independently verified: npm run check 0 errors/warnings, npm test 46/46 (run twice), npm run build succeeds at 432.81 kB/156.37 kB gzip (unchanged from Phase 4 — nothing new is wired into the running app yet).

Also added before delegating the UI: keyFingerprintHex (a standalone, non-pairwise identity-key display for Settings — fingerprintFor needs two parties and isn't meaningful for just one device alone) and five thin, UI-facing methods on Session (omemoDevices, omemoMarkVerified, omemoFingerprint, omemoOwnFingerprint, omemoPrefetch) so the UI layer never touches #xmpp/OmemoDB directly, matching how it already never touches #muc/#sync. One new test for keyFingerprintHex. Independently verified before delegating the UI: npm run check 0 errors/warnings, npm test 47/47 (run twice), npm run build succeeds at 435.58 kB/157.07 kB gzip.

The UI itself — delegated to OpenCode via herdr (opencode run), done 2026-10-04: Chat.svelte gained the lock icon (none / plain "encrypted" / a distinct "changed" variant in var(--danger), re-checked whenever a new message arrives — a new message can be the first thing to pin a device's key — and primed on open via omemoPrefetch), the message bubble gained a muted-italic style for undecryptable messages, and a new Fingerprint.svelte sheet lists each device's trust status and both fingerprint halves with a "mark verified" button (re-fetches immediately after marking, so the status updates without reopening). Settings.svelte gained an "Encryption" section showing omemoOwnFingerprint(). Reviewed and independently re-verified by the coordinator (not just the implementer's self-report, which this time couldn't even finish — the opencode-go provider's own usage limit was hit right after its checkpoint commit landed, so the code was reviewed cold): npm run check 0 errors/warnings, npm test 47/47 unchanged (no new unit tests — this project has no precedent for testing Svelte components, consistent with the task's own scope), npm run build succeeds at 440.55 kB/158.53 kB gzip, and the dev server was started and curled (HTTP 200, no build-time errors) as a minimal runtime check. Not verified: actual rendering in a real browser, and the live Monal fingerprint comparison this feature exists for — both need the operator.

Real-browser rendering verified (2026-10-05, Playwright CLI, not committed to the repo): two disposable accounts (testcal-a, testcal-b, pre-existing LLDAP test accounts with a known password) in two separate Chromium contexts against the npm run dev proxy to production chat.0ff.space — no synthetic/staging server exists, so this exercised the real Prosody. Confirmed by screenshot: the lock icon renders in Chat.svelte's header; a message sent from A arrived on B decrypted correctly (plaintext, correct content, no undecryptable styling); opening the lock icon's Fingerprint.svelte sheet lists B's device(s) with matching "You"/"Them" fingerprint halves and working "Mark verified" buttons; a message from a now-superseded device correctly rendered the muted "message sent before this device was added" label instead of crashing; Settings.svelte's Encryption section shows the own device's fingerprint. This closes the "real browser" half of Phase 5's open item — still open: the live out-of-band fingerprint comparison against the operator's actual Monal device, which by definition only the operator can do. Setup note for next time: npm's own playwright package downloads a foreign-glibc Chromium that doesn't run on NixOS without nix-ld; used nix-build '<nixpkgs>' -A playwright-driver.browsers for the browser binaries instead, with the npm playwright package version pinned to match the driver's (1.61.1, npm install playwright@1.61.1 --no-save, not added to package.json). Script kept at .playwright-cli/omemo-e2e.mjs (already .gitignored).

Open risks (stated plainly, not papered over)

  • Legacy OMEMO is verified for the deployed Monal devices. A future Monal change to omemo:2 would require adding and live-testing that namespace; do not infer compatibility from the legacy path.
  • <encrypted> wire-level byte layout (esp. omemo:2's per-recipient grouping, GCM tag placement) needs spec/interop verification, not just this plan's research-level recollection.
  • 'wasm-unsafe-eval' CSP3 support on the stated Safari 14+ minimum is unconfirmed — ship the feature-detect-and-disable fallback regardless.
  • The GPLv3/0BSD licensing-text change is a real decision for the operator to finalize wording on, not an implementation detail to quietly skip.
  • Resolved by the 2026-10-04 audit above: libomemo.js's storage-interface contract, module shape, and worker-offload build are now read from its shipped .d.ts and real dist/ output, not assumed from README-level research. The two corrections (plain-string session serialization, no namespace in address) are folded into "Architecture" and "Audit findings" above.

Verification summary

Each phase above has its own test step. The two tests that matter most for whether this feature actually does what it's for: (a) Phase 3's direct inspection of Prosody's stored MAM data, confirming the admin genuinely can't read new 1:1 messages in storage; (b) a live interop test against the operator's real Monal device before the public switch-over, since Monal compatibility is the whole point of matching its namespace and wire format.

Dead-device cleanup (2026-10-07, after the live interop test)

A device id is minted per browser profile in bootstrapIdentity, and nothing ever retracted one. Clearing site data, switching browser, or (as this project's own Playwright runs showed) opening a fresh browser context mints another id while the old one stays published forever. The test account reached nine published devices, each of which every contact's client kept wrapping a per-device key for.

This was never a correctness fault: encryptFor skips a device it cannot build a session with (ensureSession returns false, the loop continues) and only throws when every device fails, so sending to an account full of ghosts worked. It is a size, cost and legibility problem — bigger stanzas, a failing bundle fetch per ghost per conversation, and a fingerprint sheet listing devices that no longer exist.

Added retractOwnDevices(xmpp, bareJid, deviceIds, keepDeviceId) in omemo.ts: fetch the list, drop the given ids, republish under the same item id, then delete each removed device's bundle node best-effort (an orphaned bundle is harmless, so a failed delete must not undo a retraction that already succeeded). keepDeviceId is filtered out of the removal set unconditionally — a client that retracted the device it is running on would go silently unreachable — and is re-added if the fetched list somehow lacked it. Exposed as session.omemoOwnDevices() / session.omemoForgetDevices(), which also refreshes the (expiry-less) device cache from the server afterwards, and surfaced in Settings' Encryption section as a "Your devices" list, shown only when there is more than one device, with the device in use labelled and not removable.

Unit-tested: the device in use survives even when passed in the removal list, the republish uses item id "current", each retracted bundle node is deleted, and an unchanged list is not republished at all (which would churn every contact's devicelist subscription).

Verified live against production Prosody: the test account's list went from 9 devices to 1, the stale bundle nodes were deleted, and only the device in use remained.

Known limitation, deliberately not solved: the list shows bare device ids with no "last seen", so a user cannot tell which ghost was which browser. Legacy OMEMO has no device-label field to carry that, and inventing one would be a private extension no other client reads. One consequence worth knowing: IndexedDB is per origin, so the same browser profile against localhost:5173 and against chat.0ff.space holds two separate identities and publishes two devices — correct browser behaviour, indistinguishable in this UI.

Fingerprint display corrected (2026-10-07, confirmed against Monal)

The one plan item that had never been checked against Monal turned out to hold two defects, both of which defeated the feature's only purpose — comparing a fingerprint with what the other person's app shows.

  1. keyFingerprintHex rendered one byte too many. A Curve25519 identity key is stored as 33 bytes: a 0x05 DJB type byte then the 32 key bytes. Rendering all 33 produced 66 hex characters — eight groups of eight plus a stray two-character group — and shifted every character after the first two one byte out of step with the other app. The operator's Monal (iPhone) displays DA02CF0F… for a key whose stored bytes begin 05DA02CF, so the type byte is not shown. Now stripped, giving exactly 8 groups of 8; a buffer that is not 33 bytes or does not start with 0x05 is still rendered whole rather than silently losing a byte.
  2. The per-device rows showed a libsignal safety number, not a fingerprint. fingerprintFor wrapped FingerprintGenerator.createFor, which sorts its two halves so both parties compute an identical string — which is why the sheet's "You" and "Them" rows always displayed the same value. The 2026-10-05 Playwright run recorded those "matching halves" as evidence the feature worked; it was in fact the symptom. Worse, neither Monal nor Conversations has a safety-number screen, so there was nothing on the other side to compare it against. The sheet now shows each peer device's own identity-key fingerprint in the shared hex convention, and fingerprintFor/FINGERPRINT_ITERATIONS/the FingerprintGenerator import are gone.

Verified in a browser against the live devices: the sheet shows DA02CF0F C10B1FE3 09E99D64 99199005 EBBFFF5A 11E4AF3E 37CDD75D 48A1FA47 for the operator's Monal device, matching what Monal itself displays, character for character. The hint text now says to compare out of band (call or in person) rather than "in this app".

With this, every phase of this plan is verified, including against a foreign client. Nothing on it is outstanding.

Encrypted attachments: format decided (2026-10-08, design only)

ux-implementation-plan.md's package 9 required a compatibility decision before any code. This section is that decision, with the evidence it rests on. Nothing is implemented yet; session.svelte.ts:217 still fails closed for a capable peer, and mediaCapability still explains the limit.

The format is XEP-0454 (aesgcm://), not XEP-0447

Read from Monal's own source (develop, fetched 2026-10-08): MLMessageProcessor.m:702 classifies an encrypted message as a file transfer only when the decrypted body is a single token with no whitespace whose lowercase form starts with aesgcm://. Monal implements no stateless file sharing (urn:xmpp:sfs:0) at all, so an SFS element would arrive there as an ordinary text message showing a bare URL. XEP-0447 was the research input the plan asked for; it loses the only interop partner that matters, so it is rejected.

Three consequences of that one if:

  • The encrypted body is the URL and nothing else — no caption, no second line, and in particular no XEP-0454 thumbnail (the XEP allows a JPEG data URI after a newline; Monal's whitespace test would then misclassify the whole message as text).
  • There is no jabber:x:oob element on an encrypted attachment. Monal's own send path proves this is the intent: xmpp.m:4081 calls setOobUrl: only in the unencrypted branch, while the encrypted branch hands the URL to encryptMessage as the plaintext payload. An oob child would sit outside the envelope and leak the URL — with the key in its fragment — to the server and every intermediary.
  • A caption therefore has to be a separate message, if we ever want one.

Wire format (send), byte for byte

Monal's AESGcm.m and MLFiletransfer.m:857-911:

  • AES-256-GCM, random 32-byte key, random 12-byte IV, 128-bit tag.
  • The tag is appended to the ciphertext before upload; AESGcm.m's decrypt: concatenates iv || body || auth and notes "if auth is nil assume it already was appended to body". WebCrypto's crypto.subtle.encrypt({name:"AES-GCM", iv}, …) already returns ciphertext || tag, and decrypt already expects that layout, so the browser needs no splicing on either side.
  • The URL is the XEP-0363 GET URL with the scheme replaced by aesgcm and the fragment set to hex(iv) || hex(key) — 24 + 64 = 88 lowercase hex characters (MLFiletransfer.m:908). Note the order: IV first. Monal's own key property is key+tag; it slices the first 32 bytes back out "(conversations compatibility)".

Wire format (receive): three fragment layouts

Monal accepts a fragment of length ≥ 88 and reads IV = first 24 hex chars, key = the next 64 (MLFiletransfer.m:346-356). Conversations is broader — AesGcmURL.IV_KEY matches 44 or 48 bytes, and im/conversations/android/model/TransportSecurity.java splits:

Anchor bytes Hex chars Layout
48 96 iv(16) || key(32)
44 88 iv(12) || key(32) — what we send
≥ 32 ≥ 64 key(32), IV fixed to 000102…0e0f

We send only the 88-character form and accept all three on receive. The third is a legacy shape with a hardcoded IV; accepting it costs one branch and makes old history readable.

Metadata protection

Mirror Monal (MLFiletransfer.m:875-881): request the slot with content-type application/octet-stream and filename <uuid>.<original extension>. The extension has to survive — both clients derive the media kind from it (media.ts:attachmentKind, Monal's getMimeTypeOfOriginalFile:) — so what is protected is the user's filename and the declared type, not the file sort. Ciphertext length is plaintext + 16 and is visible to the server; that is inherent to the scheme and not worth pretending otherwise.

Server side: nothing to change, verified live

  • upload.0ff.space already answers with CORS for the file route. Checked 2026-10-08 with curl -H "Origin: https://chat.0ff.space" https://upload.0ff.space/file_share/abc/x.bin: access-control-allow-origin: https://chat.0ff.space, access-control-allow-methods: OPTIONS, GET, PUT, access-control-allow-headers: Content-Type, Authorization. The headers come from mod_http_file_share's own provides("http", {cors = …}), which is active because http_file_share_base_url is unset (only http_external_url is). A fetch() of the ciphertext is therefore possible without touching Prosody.
  • The CSP in blunix:capabilities/xmpp/chat-client.nix:48-50 already has connect-src … https://upload.0ff.space, img-src … blob: and media-src … blob:. No header change.
  • No upload type restriction: http_file_share_allowed_file_types is unset in /etc/prosody/prosody.cfg.lua, so application/octet-stream is accepted. It is not in safe_file_types, so the GET is served as Content-Disposition: attachment — irrelevant to fetch(), which is how we read it.
  • Size limit http_file_share_size_limit = 10485760 (10 MiB, /etc/prosody/prosody.cfg.lua:136). The pre-upload check in upload.ts:upload compares file.size against the disco max-file-size and must compare file.size + 16 for the encrypted path, or a file within 16 bytes of the limit passes the check and fails the PUT.

Only our own upload host is fetchable, and that is deliberate

connect-src lists upload.0ff.space and nothing else, so a fetch of a federated contact's aesgcm:// URL on their server would be blocked by the browser regardless of what the client attempts. The client therefore checks the host against config.mediaHosts — the same rule isTrustedMedia already applies to inline plaintext media — and, for any other host, shows the message as an attachment that this client cannot open rather than issuing a request that CSP will refuse. Our own outgoing uploads always land on our own host, so both directions work with Monal on 0ff.space; a federated encrypted attachment is a stated limitation, not a bug. Widening connect-src to arbitrary hosts to fix it is not acceptable.

Client-side shape

  • New src/crypto/aesgcm.ts: encryptFile(blob) → { ciphertext, key, iv }, decryptFile(bytes, key, iv), buildAesgcmUrl(getUrl, key, iv), parseAesgcmUrl(url) (returns the https URL plus key/iv, or null). Pure WebCrypto — no libsignal, so none of the WASM/CSP caveats of omemo.ts apply and omemoSupported() is not a precondition for media, only for the OMEMO text path that carries it.
  • sendFile (session.svelte.ts:447): for a 1:1 peer known capable, encrypt first, upload the ciphertext under the anonymised name, then send the aesgcm:// URL through the existing encrypted send() path. The oob argument stays undefined on that path; send()'s current capable && oob rejection becomes a rejection of plaintext oob to a capable peer, which it should have been all along. Rooms and non-capable contacts keep today's plaintext oob behaviour untouched.
  • mediaCapability: the "aren't supported yet" reason disappears for a capable peer in the same commit. Unknown/offline capability stays fail-closed — unchanged, and still never a licence to upload plaintext.
  • media.ts: isAttachment additionally recognises a lone aesgcm:// body on an encrypted message, so no new StoredMessage field and no change to the key/merge logic in db.ts. attachmentKind and attachmentLabel already work off the extension, which the anonymised name keeps.
  • Chat.svelte:881-897: for an aesgcm:// row, fetch → decrypt → one object URL per message, then the existing <img>/<audio>/<video>/ file branches unchanged. Revoke every object URL on component destroy and when a row leaves a small LRU; all uploads are ≤ 10 MiB, so decryption is automatic and needs no "tap to load" state.
  • Storage honesty: the decrypted body stored in IndexedDB is the aesgcm:// URL, so the file key sits in the local store next to the decrypted text it belongs to — the same confidentiality tier this client already accepts for plaintext bodies (see "Open risks"). Stripping the key would make the attachment unopenable after a reload, so it stays.
  • Voice messages keep the existing format ladder (audio/mp4 first, then audio/webm). A WebM note recorded on desktop still will not play in Monal on iOS; that is pre-existing and not introduced here.

Failure and cancellation rules, each distinct

Never a plaintext fallback, and never the ciphertext on screen.

Situation Behaviour
Abort before/while uploading Nothing is sent; the existing AbortSignal path already covers slot and PUT
Encryption itself fails No slot is requested, no PUT is made
Slot or PUT fails Today's error text, unchanged
GET 404 / expired (30 days) "No longer available on the server" — the existing brokenMedia state
Fragment malformed or wrong length Render as an unopenable attachment with a distinct reason; no fetch
Tag mismatch or truncated file WebCrypto decrypt rejects: "This file could not be decrypted", with a retry; never render partial bytes
Host not in mediaHosts Unopenable attachment, no request (see above)

What the tests must cover

Round trip against a fixed key/IV vector (so the hex layout itself is asserted, not just self-consistency); all three receive fragment layouts including the hardcoded-IV legacy one; a flipped byte rejected; the anonymised filename keeping its extension; the size pre-check including the 16-byte tag; and a stanza-level assertion that the encrypted attachment carries no jabber:x:oob child and no second body line. Cosmetic markup tests are not wanted.

Acceptance (unchanged from package 9)

Image, generic file and voice round trips between two PWA profiles and real Monal, including a second own device; no plaintext PUT and no oob descriptor for a capable peer; cancellation, failed fetch and integrity failure each shown correctly; real iPhone recording, permission and playback. Two copies of this web client and a synthetic microphone do not substitute for the device half.

Encrypted attachments implemented (2026-10-08, not yet deployed)

Package 9's code half, built against the section above. New src/crypto/aesgcm.ts (plain WebCrypto: encryptFile, decryptFile, buildAesgcmUrl, parseAesgcmUrl, anonymousName, TAG_BYTES). sendFile encrypts for a capable 1:1 contact, uploads the ciphertext as application/octet-stream under a <uuid>.<ext> name and sends the aesgcm:// URL through the existing encrypted send() — no oob argument, so the envelope is the only place the URL exists. mediaCapability now answers { allowed: true, encrypt: true } where it used to explain the limitation, and media.ts's isAttachment became attachmentUrl(body, oob, encrypted), which recognises a lone aesgcm:// body only on an encrypted message — so db.ts's chat-list preview and the notification label show "📷 Photo" instead of a URL carrying a key. Chat.svelte fetches, decrypts and renders through one object URL per message, with "Decrypting…", a distinct reason per failure and a retry.

Two deviations from the design above, both deliberate:

  • The design said a small LRU of decrypted blobs. Implemented instead as: a blob lives exactly as long as its message is in the loaded window, and every object URL is revoked when the view closes. An LRU can revoke a URL an <img> on screen is still using; this cannot, and the window already bounds how much is held.
  • The design said upload.ts:upload should compare file.size + 16. That would have been wrong in the implemented shape: upload receives the ciphertext, whose size already includes the tag, so it would have double-counted. The limit moved into an exported checkSize, which sendFile applies to file.size + TAG_BYTES before spending the time to encrypt, and upload still applies to what it is actually given.

Two defects found and closed while building it:

  • url.protocol = "aesgcm:" silently does nothing: WHATWG URL refuses to assign a non-special scheme over a special one (and vice versa), so both the build and the parse were returning https://… URLs. The fixed-vector test caught it on first run. Both directions now swap the scheme textually, which is what Monal and Conversations do too.
  • A key leak in the design's own send path. send() encrypts only when the peer is currently capable. If the peer's device list emptied between the upload and the send, the aesgcm:// body — which is the file key — would have gone out as cleartext. Closed twice over: the authorize callback that re-runs before the slot and the PUT now rejects a changed encryption decision rather than only a withdrawn one, and send() refuses outright to put an aesgcm:// body on the wire without an envelope (and in a room at all).

Verified: 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, all three receive layouts, flipped byte / truncation / wrong key, and the anonymised name), npm run build succeeds. The service worker passes cross-origin GETs straight to the network, so the ciphertext fetch is neither intercepted nor cached.

Not done, and deliberately not claimed: no live browser run, no round trip against real Monal, no iPhone recording or playback, and nothing deployed — no client push, no nix flake update chat-client, no switch. A stanza-level "no oob child" assertion is not a unit test either: session.svelte.ts is a runes module with no node test harness, so the invariant is covered in units only through attachmentUrl refusing the encrypted shape whenever an oob is present, and belongs to the live acceptance run otherwise.

Encrypted attachments accepted live (2026-10-08, generation 557)

Deployed and exercised against the deployed bundle; the full record with every number is in UX-MEDIA-RESULTS.md. Client 031766c pinned by blunix d23cb82, switch succeeded as generation 557, nginx root …cjk60a637slh8h2kdb25dmjb860s2839-0ff-chat-0.1.0, the Nix build, the local dist build and the HTTPS-served bundle byte-identical, sw.js unchanged, CSP unchanged, ten services active, zero failed units, rollback 556.

ux-media-runner.cjs (new, Chromium 152, two profiles, audit accounts): all ten checks pass with zero page errors. What that establishes beyond the unit tests — every outgoing attachment stanza is OMEMO-encrypted with no jabber:x:oob child and no aesgcm string anywhere in cleartext XML; the second profile decrypts image, file and voice independently, the 1×1 PNG decodes 1×1 and the generic file downloads as the exact plaintext bytes; the upload sizes are plaintext + 16 to the byte (70→86, 296→312) and the only plaintext PUT in the run was the group upload, which is plaintext by design; 404 and integrity failure read differently and the retry recovers; a foreign host is explained with no request made; and the group path kept its oob, its real name and its real media type.

Three checks first failed on the runner's own assumptions, recorded because they will mislead the next run: Prosody's cache-control: max-age=31556952, immutable means a re-read never reaches the network (so each failure check sends a fresh attachment); persistent profiles make a bare waitFor match an older attachment (so round trips count); and connect-src has no blob:, so an in-page fetch of our own object URL is CSP-blocked — the app never does that, it only assigns to src/href, so the download check uses a real browser download.

Open and device-bound: the real Monal round trip in both directions, and iPhone recording permission, recording and playback. A desktop WebM voice note still will not play in Monal on iOS; the recorder prefers audio/mp4 where offered, which is the iOS case.