# 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:`), 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 `` envelopes and the PEP ``/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 `` 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 `` 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 `` is present on a new message: `omemo.decrypt(el, from)` reads the sender device id and the `` 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 `` 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 `` set to the plaintext, feed it into `parseMessage()` unchanged, tag the result `encrypted: true`. 5. No `` 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) 0. **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.** 1. **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). 2. **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). 3. **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. 4. **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. 5. **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. 6. **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. `KeyPair`s/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 ``/`` 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 `` 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; `` 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 `` 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 '' -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 `.gitignore`d). ## 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. - `` 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 `continue`s) 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 `.`. 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 ``/`