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>
68 KiB
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/curvesprimitives — rolling your own protocol-level crypto for a feature whose whole point is confidentiality is a bigger risk than taking onlibomemo.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 insrc/sync/muc.ts, plus "Invite…"/"Delete chat" UI inChat.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 sameChat.sveltemenu 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 insrc/store/idb.ts(open/request/done/remove). Stores:identity(one row: registrationId, identity keypair, our deviceId),signedPreKeys,preKeys(~100, generated at bootstrap,usedflag),sessions(key`${bareJid}:${deviceId}:${ns}`, opaque ratchet state),trust(same key shape;tofu/verified/blocked+ fingerprint),devices(per-peer device-id cache per namespace, sosend()never blocks on a network round-trip). Critical design point: this database must be separate fromChatDB(src/store/db.ts) specifically sosession.svelte.ts'slogout()(lines 499-517, which callsChatDB.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 whysrc/xmpp/device.tsalready keepsdeviceId/resource/savedTokeninlocalStorage, outsideChatDB— same kind of "must outlive logout" state.omemo-store.ts— adaptsomemo-db.tsto whatever storage interfacelibomemo.jsexpects (read its actual.d.tsonce 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 plainUint8Array/object shapes beforeput().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 fromomemo.tsthe waysrc/xmpp/parse.tsis already kept separate fromsrc/sync/sync.ts.omemo-worker.ts(if usinglibomemo.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 existingnode --testconvention already used bytest/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+notifyfeature(s) for whichever namespace(s) are published (same pattern already used forNS_AVATAR_META + "+notify", line 20), so contacts' PEP pushes us live device-list updates.src/xmpp/parse.ts—NSmap (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— noChatDB.VERSIONbump.StoredMessage(lines 9-29) gets two new optional fields,encrypted?: booleanandundecryptable?: 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 thesubtitlearea (lines 34-46) with three states (not encrypted / encrypted / "a device's key changed — check it"), openingFingerprint.svelte; a distinct rendering for messages markedundecryptable.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) — CSPscript-src 'self'needs'wasm-unsafe-eval'added, required forlibomemo.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
- Look up the peer in the
devicescache. Implemented as a check-and- refresh-if-cold call insidesend()itself, not a separate prefetch triggered whenChat.svelteopens 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. - 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) viaSessionCipher.encrypt. Produce the<encrypted>envelope plus a fixed, content-independent XEP-0380 fallback string (never derived frombodyin 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 genericsendErrorUI inChat.sveltealready surfaces whateversend()throws, so no UI change was needed. Never silently fall back to plaintext for a peer known to be encrypted. - Not capable: unchanged, today's plain
<body>path. - The local echo written to
ChatDB(today'sdb.putMessage, lines 167-179) keeps storing real plaintext either way, for the sender's own UI, withencrypted: trueset 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():
- Skip anything
type="groupchat"outright — never attempt device-session lookup keyed off a room's bare JID, even defensively. - 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 formuladb.tsalready uses) and check whether it's already stored before attempting decrypt — only decrypt genuinely new messages. - 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 (especiallyomemo: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. - Success: clone
elwith a<body>set to the plaintext, feed it intoparseMessage()unchanged, tag the resultencrypted: true. - No
<key>entry for our device, or decrypt failure: still produce aStoredMessage(don't let it vanish) withencrypted: true, undecryptable: trueand a placeholder body, rendered distinctly inChat.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 existingfirstEversignal 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)
- 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 --modulepasses withlibomemo.jspresent. Licensing-text reviewed and applied by the operator:LICENSEandREADME.md's license section now carry the combined-work GPLv3 note. Phase 0 is fully done. - 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 bytest/omemo.test.ts(4 tests,fake-indexeddbas 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 theidentityrow is stable (the automated test covers the same thing againstfake-indexeddb, not a real browser). - Device lists & bundles — code done 2026-10-04: caps feature,
publish/fetch,
openaccess 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). - Send-path encryption — code done 2026-10-04:
session.svelte.ts's 1:1send()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/decryptround-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 shellor 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. - 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. - 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. - Deploy — preparation done 2026-10-04, cutover pending the operator:
chat-client.nixCSP change ('wasm-unsafe-eval', with the feature-detect-and-disable fallback this plan calls for — see Phase 5's findings),npmDepsHashfix (found stale while preparing this, broken since Phase 1),docs/intent/omemo-encryption.mdall done and pushed. Licensing text was already applied back in Phase 0.blunix'schat-clientflake input bumped to this repo's currentmain;nixos-rebuild build --flake .#blunixverified successful (only nginx- the chat package rebuild), generated
nginx.confconfirmed 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 separatechat2-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.
- the chat package rebuild), generated
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 fieldGPL-3.0as 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 bundleddist/index.d.ts. Noenginesfield. - 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 kBdist/libomemo-worker.jsfetched 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 foromemo-worker.tstime; both variants are viable. es-checkcorrection: this project'snpm run build(vite build) has noes-checkstep of its own — PLAN.md's earlier "passeses-check es2020" status entries were from a manual run, not the build script. Build + manual check both pass withlibomemo.jspresent, but only withnpx es-check es2020 'dist/assets/*.js' --module— plain script mode fails on oneimport.meta.urlin the library's ESM dist (legal module syntax, illegal script syntax). Use--modulefor the Phase 0 re-check.- Storage-interface shape, corrected from the
OMEMOStoreinterface indist/index.d.ts(anInMemoryStorereference 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 plainArrayBuffer/{pubKey, privKey}objects, cloneable as-is. addressis`${bareJid}.${deviceId}`, with no namespace in it — the OMEMO version is a separate constructor argument toSessionBuilder/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 returnsfalse. 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 byKeyHelper/SessionBuilder/SessionCiphercalls, confirming legacy-namespace support needs no workaround.
- Sessions serialize to plain strings (
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() === falsemakes the library's ownSessionCipher.encrypt/decryptthrow ("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 returntrue;saveIdentity()alone detects and records a change (as a persisted status, not just a return value) for the UI to read.- The
OMEMOStoreinterface's genericstore/put/get/removemembers are declared sync-only (put(): void), so they cannot be backed by async IndexedDB. Verified directly against the.d.tsplus the library's own call sites thatSessionBuilder/SessionCiphernever call these four — only the nine named, async-capable methods — so the adapter correctly keeps them as a plain in-memoryRecord, 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/OmemoStoreuse explicit field declarations instead of constructor parameter properties (constructor(private db)), because Node's--teststrip-only type stripping rejects parameter properties —src/sync/muc.tsalready sets this precedent in a tested module. Value imports betweensrc/files need explicit.tsextensions under this setup for Node ESM (also already true ofmuc.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.keyIdare typednumberin the library while the store-method parameters are typedKeyId = number | string— the adapter bridges this withNumber(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:
publishBundlegained abareJidparameter (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 toreplenishPreKeys(db)(the account was unused);syncOmemo(xmpp, account, deviceId)simplified tosyncOmemo(xmpp, account)since the device id already comes from the idempotentbootstrapIdentity()call inside it. - Publish-options minimalism, a deliberate deviation from the bookmark
precedent's full option set: only
pubsub#access_model=openandpubsub#persist_items=trueare sent, not bookmarks'max_items/send_last_published_item=never— every extra field risks apreconditions-not-metrejection 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), whichneverwould have suppressed. Flagged here since it's an easy-to-miss detail if revisited. - A small, deliberate two-module import cycle (
omemo.tsexportsOMEMO_NS,omemo-wire.tsimports 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()'sbodyis 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.tsconverts viacharCodeAt, then base64-encodes the real bytes for the XML; the reverse happens on decode.- Legacy
<bundle>elements never carry aregistrationId(unlikeomemo:2), yetPreKeyBundle.registrationIdis a required field forSessionBuilder.processPreKey(). Confirmed empirically that passing0works — 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:2would 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.tsand realdist/output, not assumed from README-level research. The two corrections (plain-string session serialization, no namespace inaddress) 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.
keyFingerprintHexrendered one byte too many. A Curve25519 identity key is stored as 33 bytes: a0x05DJB 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) displaysDA02CF0F…for a key whose stored bytes begin05DA02CF, 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 with0x05is still rendered whole rather than silently losing a byte.- The per-device rows showed a libsignal safety number, not a
fingerprint.
fingerprintForwrappedFingerprintGenerator.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, andfingerprintFor/FINGERPRINT_ITERATIONS/theFingerprintGeneratorimport 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:oobelement on an encrypted attachment. Monal's own send path proves this is the intent:xmpp.m:4081callssetOobUrl:only in the unencrypted branch, while the encrypted branch hands the URL toencryptMessageas the plaintext payload. Anoobchild 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'sdecrypt:concatenatesiv || body || authand notes "if auth is nil assume it already was appended to body". WebCrypto'scrypto.subtle.encrypt({name:"AES-GCM", iv}, …)already returnsciphertext || tag, anddecryptalready 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
aesgcmand the fragment set tohex(iv) || hex(key)— 24 + 64 = 88 lowercase hex characters (MLFiletransfer.m:908). Note the order: IV first. Monal's ownkeyproperty 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.spacealready answers with CORS for the file route. Checked 2026-10-08 withcurl -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 frommod_http_file_share's ownprovides("http", {cors = …}), which is active becausehttp_file_share_base_urlis unset (onlyhttp_external_urlis). Afetch()of the ciphertext is therefore possible without touching Prosody.- The CSP in
blunix:capabilities/xmpp/chat-client.nix:48-50already hasconnect-src … https://upload.0ff.space,img-src … blob:andmedia-src … blob:. No header change. - No upload type restriction:
http_file_share_allowed_file_typesis unset in/etc/prosody/prosody.cfg.lua, soapplication/octet-streamis accepted. It is not insafe_file_types, so the GET is served asContent-Disposition: attachment— irrelevant tofetch(), 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 inupload.ts:uploadcomparesfile.sizeagainst the discomax-file-sizeand must comparefile.size + 16for 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 ofomemo.tsapply andomemoSupported()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 theaesgcm://URL through the existing encryptedsend()path. Theoobargument staysundefinedon that path;send()'s currentcapable && oobrejection becomes a rejection of plaintextoobto a capable peer, which it should have been all along. Rooms and non-capable contacts keep today's plaintextoobbehaviour 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:isAttachmentadditionally recognises a loneaesgcm://body on anencryptedmessage, so no newStoredMessagefield and no change to the key/merge logic indb.ts.attachmentKindandattachmentLabelalready work off the extension, which the anonymised name keeps.Chat.svelte:881-897: for anaesgcm://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/mp4first, thenaudio/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:uploadshould comparefile.size + 16. That would have been wrong in the implemented shape:uploadreceives the ciphertext, whose size already includes the tag, so it would have double-counted. The limit moved into an exportedcheckSize, whichsendFileapplies tofile.size + TAG_BYTESbefore spending the time to encrypt, anduploadstill 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 returninghttps://…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, theaesgcm://body — which is the file key — would have gone out as cleartext. Closed twice over: theauthorizecallback that re-runs before the slot and the PUT now rejects a changed encryption decision rather than only a withdrawn one, andsend()refuses outright to put anaesgcm://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.