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

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

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

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

1129 lines
68 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# OMEMO end-to-end encryption for 0ff chat (1:1 only)
Implementation plan, researched and approved 2026-10-04. Phase 0 (verify)
and Phase 1 (identity-bootstrap skeleton) are done — see "Phase 1 skeleton
findings" below and `docs/agentic-opencode.md` in blunix for handoff status.
## Context
Users of `chat.0ff.space` noticed that the operator (who also administers the
blunix server) can read all their messages — Prosody stores 1:1 history in
plaintext (MAM, retention `1y`) and the admin has shell access. This is a
real, structural property of the current setup, not a bug: local-only
encryption (e.g. an encrypted IndexedDB) would not fix it, since the server
still needs plaintext to store/relay messages. The only thing that actually
addresses the complaint is end-to-end encryption, where the server only ever
sees ciphertext. This project's own `PLAN.md` already named this ("OMEMO —
hardest part in the browser") and deliberately deferred it past v1; this plan
is that deferred milestone.
Decided with the operator before this plan was written:
- **Crypto library: `libomemo.js`** (github.com/conversejs/libomemo.js, the
library Converse.js itself uses) over hand-rolling X3DH/Double Ratchet from
`@noble/curves` primitives — rolling your own protocol-level crypto for a
feature whose whole point is confidentiality is a bigger risk than taking on
`libomemo.js`'s GPLv3 license and WASM core.
- **Scope: 1:1 chats only.** Group chat (MUC) encryption needs per-device
fan-out to every participant and is substantially harder; it stays
unencrypted, unchanged, for now.
- There's a small, separate change (an `invite()` method in `src/sync/muc.ts`,
plus "Invite…"/"Delete chat" UI in `Chat.svelte`'s menu) for inviting
people into group chats and deleting 1:1 conversations from the list. That
work is unrelated to encryption and is being finished first, on its own —
it touches the same `Chat.svelte` menu area this plan's lock-icon UI sits
near, so expect to rebase one on the other, not redesign either because of
it.
## Architecture: new files, changed files
**New** (all in `src/crypto/`, new directory):
- `omemo-db.ts` — a **second**, separate IndexedDB database (`omemo:<account>`),
opened with the existing helpers in `src/store/idb.ts` (`open`/`request`/`done`/`remove`).
Stores: `identity` (one row: registrationId, identity keypair, our deviceId),
`signedPreKeys`, `preKeys` (~100, generated at bootstrap, `used` flag),
`sessions` (key `` `${bareJid}:${deviceId}:${ns}` ``, opaque ratchet state),
`trust` (same key shape; `tofu`/`verified`/`blocked` + fingerprint), `devices`
(per-peer device-id cache per namespace, so `send()` never blocks on a
network round-trip).
**Critical design point:** this database must be separate from `ChatDB`
(`src/store/db.ts`) specifically so `session.svelte.ts`'s `logout()`
(lines 499-517, which calls `ChatDB.destroy(account)` on purpose — see its
own comment at line 497 — to wipe a shared device's chat history on logout)
does **not** also destroy OMEMO identity/sessions. Losing that identity on
every logout would force key regeneration, which every contact's client
would show as "this person's device key changed" — the one TOFU signal
that's supposed to mean something. This mirrors why `src/xmpp/device.ts`
already keeps `deviceId`/`resource`/`savedToken` in `localStorage`, outside
`ChatDB` — same kind of "must outlive logout" state.
- `omemo-store.ts` — adapts `omemo-db.ts` to whatever storage interface
`libomemo.js` expects (read its actual `.d.ts` once installed — this plan
researched that at README level only, not byte-exact). Watch for
non-structured-cloneable return values (class instances) that need
flattening to plain `Uint8Array`/object shapes before `put()`.
- `omemo.ts` — orchestration: identity bootstrap, device-list/bundle
publish+fetch over PEP, session lookup/creation per namespace,
`encryptFor(peerBareJid, body)`, `decrypt(el, fromBareJid)`, prekey
replenishment and signed-prekey rotation.
- `omemo-wire.ts` — XML only: building/parsing `<encrypted>` envelopes and the
PEP `<bundle>`/device-list payloads. Kept separate from `omemo.ts` the way
`src/xmpp/parse.ts` is already kept separate from `src/sync/sync.ts`.
- `omemo-worker.ts` (if using `libomemo.js`'s v3 worker-offload build, to keep
~111KB of WASM out of the main bundle — confirm this is worth the added
complexity once the library is actually installed).
- `test/omemo.test.ts`, following the existing `node --test` convention
already used by `test/parse.test.ts` / `test/push.test.ts`.
- `src/ui/Fingerprint.svelte` — small sheet listing a peer's device
fingerprints with a "mark verified" toggle (display only for v1, no
QR/compare flow).
**Changed:**
- `src/xmpp/caps.ts` — `BASE_FEATURES` (lines 14-21) gains the devicelist
`+notify` feature(s) for whichever namespace(s) are published (same pattern
already used for `NS_AVATAR_META + "+notify"`, line 20), so contacts' PEP
pushes us live device-list updates.
- `src/xmpp/parse.ts` — `NS` map (lines 4-18) gains the OMEMO namespace
constant(s).
- `src/sync/sync.ts` — `#store()` (lines 157-176) gets an async
decrypt-before-parse step; see "Receive path" below for the exact sequencing
fix needed (today's dedup-after-parse ordering is unsafe for a Double
Ratchet).
- `src/xmpp/session.svelte.ts` — `send()` (lines 116-179); only the 1:1 branch
(lines 155-179) changes, the groupchat branch (lines 123-150) stays
untouched. See "Send path" below.
- `src/store/db.ts` — **no `ChatDB.VERSION` bump.** `StoredMessage` (lines
9-29) gets two new optional fields, `encrypted?: boolean` and
`undecryptable?: boolean`; `putMessage()` (lines 132-186) already
passes through whatever fields it's given, so no logic change needed there.
- `src/ui/Chat.svelte` — a lock icon near the `subtitle` area (lines 34-46)
with three states (not encrypted / encrypted / "a device's key changed —
check it"), opening `Fingerprint.svelte`; a distinct rendering for messages
marked `undecryptable`.
- `src/ui/Settings.svelte` — new "Encryption" section (near the account block,
after the existing settings) showing this device's own fingerprint for
out-of-band comparison.
- `/home/drone/blunix/capabilities/xmpp/chat-client.nix` (lines 29-31ish) —
CSP `script-src 'self'` needs `'wasm-unsafe-eval'` added, required for
`libomemo.js`'s WASM Curve25519 core under CSP3. **Must ship with a
feature-detect-and-disable fallback** (try instantiating a trivial WASM
module at startup; if it's blocked, disable OMEMO for that session and fall
back to plaintext with a one-line notice) rather than assuming every target
browser (down to the stated Safari 14+ minimum) honours the new keyword —
and never widen to the much bigger `'unsafe-eval'` to paper over an old
browser.
## Namespace: legacy OMEMO verified (Phase 0)
Two OMEMO namespaces exist: legacy `eu.siacs.conversations.axolotl` and
`urn:xmpp:omemo:2`. **Verified on 2026-10-04:** Prosody's production PEP
store contains Monal's
`pep_eu.siacs.conversations.axolotl.devicelist/lukas` node and two matching
legacy bundle nodes for `lukas`; it contains no `urn:xmpp:omemo:2` device
node. The same legacy device-list/bundle layout exists for `alfo`. The check
listed store paths/node names only and did not read key material.
Implement legacy `eu.siacs.conversations.axolotl` as the required first path.
Do not add dual publication to `urn:xmpp:omemo:2` until a real interoperability
need appears; it would double protocol state without helping the Monal devices
actually deployed here. Keep the namespace in session/trust/device keys
(`` `${bareJid}:${deviceId}:${ns}` ``) so a later `omemo:2` addition cannot
cross-contaminate a ratchet if a device id collides between namespaces.
## Device list & bundle lifecycle (PEP)
On first-ever login on a browser profile: generate registration id, identity
keypair, one signed prekey, ~100 one-time prekeys (persisted to
`omemo-db.ts` before anything is published); publish the bundle to a
per-device PEP node and the device list to a shared PEP node (fetch-merge-
publish, the same pattern already used for bookmarks in
`src/sync/muc.ts:91-150` — fetch the current list item, add our device id if
missing, publish the merged item back; a blind publish would wipe other
devices, e.g. Monal's own entry).
**Deliberate deviation from the bookmark precedent:** bookmarks publish with
`pubsub#access_model = "whitelist"` (`src/sync/muc.ts:142`). OMEMO bundle and
device-list nodes must be `"open"` — a non-public bundle node is a documented
real-world OMEMO interop failure (seen in a Monal GitHub discussion). Flag
this in review; it's an easy copy-paste mistake from the bookmark code.
Before messaging a peer (or periodically, cached in the `devices` store so
`send()` never blocks on it): fetch their device list; `item-not-found` or no
devicelist feature in disco ⇒ not OMEMO-capable, record that and always send
plaintext to them (this also covers plaza's anonymous guests for free, since
PEP is disabled on the anonymous vhost). For each of their devices with no
existing session, fetch its bundle and build a session consuming one of their
one-time prekeys (mark it locally consumed, never republish it). Also fetch
**our own** bare JID's device list and session with every other device found
there (e.g. Monal on the phone) — required so a message sent from the web
client is readable from the sender's own other devices.
Replenish used prekeys back toward ~100 on each login; rotate the signed
prekey periodically, keeping the previous one valid briefly for messages
already in flight.
## Send path (`session.svelte.ts`, 1:1 branch only) — done 2026-10-04
1. Look up the peer in the `devices` cache. Implemented as a check-and-
refresh-if-cold call inside `send()` itself, not a separate prefetch
triggered when `Chat.svelte` opens the conversation as first drafted
here — that UI hook doesn't exist yet, so the very first message to a
brand-new peer in a session pays for one network round trip; every
later send, including to the same peer, reuses the cached result. See
"Phase 3 findings" below.
2. **Capable:** `omemo.encryptFor(peer, body)` — generate a fresh random
**AES-128-GCM** key+IV via WebCrypto (corrected from this section's
original "AES-256-GCM" — legacy OMEMO's actual, verified convention,
see "Phase 3 findings"), encrypt the body once, then wrap that key
(concatenated with the GCM tag) per recipient device (the peer's
devices *and* our own other devices) via `SessionCipher.encrypt`.
Produce the `<encrypted>` envelope plus a **fixed, content-independent**
XEP-0380 fallback string (never derived from `body` in any way — a
length-correlated fallback would leak exactly what this feature exists
to hide).
If encryption throws (corrupt session, failed bundle fetch mid-send):
**fail closed** — refuse to send; the existing generic `sendError` UI in
`Chat.svelte` already surfaces whatever `send()` throws, so no UI change
was needed. Never silently fall back to plaintext for a peer known to be
encrypted.
3. **Not capable:** unchanged, today's plain `<body>` path.
4. The local echo written to `ChatDB` (today's `db.putMessage`, lines
167-179) keeps storing real plaintext either way, for the sender's own UI,
with `encrypted: true` set when step 2 ran. The groupchat branch (lines
123-150) is untouched.
## Receive path (`sync.ts` / `parse.ts`)
`parseMessage()` (`parse.ts:58-59`) reads `el.getChildText("body")`
synchronously and is shared by 1:1 **and** groupchat, live delivery **and**
MAM replay. Decryption is async, so it can't live inside `parseMessage()`;
it goes in `Sync.#store()` (`sync.ts:157-176`), before the call to
`parseMessage()`:
1. Skip anything `type="groupchat"` outright — never attempt device-session
lookup keyed off a room's bare JID, even defensively.
2. **Move the dedup check earlier.** Today, de-duplication only happens
inside `db.putMessage()` (`store/db.ts:132-150`), *after* parsing — fine
for plaintext, unsafe here: a Double Ratchet advances state on every
successful decrypt, so decrypting a duplicate delivery twice (MAM/carbon
overlap at a page boundary is a real possibility given today's cursor
logic) can desync or exhaust that sender device's ratchet. `#store()` must
compute the message key (`` `${peer}|${sender}|${originId ?? id}` ``, same
formula `db.ts` already uses) and check whether it's already stored
*before* attempting decrypt — only decrypt genuinely new messages.
3. If `<encrypted>` is present on a new message: `omemo.decrypt(el, from)`
reads the sender device id and the `<key>` entry addressed to our own
device id, builds a session via the embedded prekey reference if this is
the first message from that device, unwraps the per-device AES key, then
AES-GCM-decrypts the payload. **Verify the exact wire-level child grouping
(especially `omemo:2`'s per-recipient `<keys jid=…>` grouping and GCM tag
placement) against the current XEP-0384 spec text and a live Monal
interop test before considering this done** — this has historically been a
real interop pitfall between OMEMO implementations, and this plan's own
reading of it is "recalled from research," not spec-verified byte-for-byte.
4. Success: clone `el` with a `<body>` set to the plaintext, feed it into
`parseMessage()` unchanged, tag the result `encrypted: true`.
5. No `<key>` entry for our device, or decrypt failure: still produce a
`StoredMessage` (don't let it vanish) with `encrypted: true,
undecryptable: true` and a placeholder body, rendered distinctly in
`Chat.svelte`. **A brand-new device's first MAM catch-up will hit a
backlog of messages from before it existed — this is expected OMEMO
behaviour (forward secrecy), not a bug**; use the existing `firstEver`
signal already threaded through `#catchUp`/`#initial` (`sync.ts:177-213`)
to show distinct copy ("encrypted message from before this device was
added") instead of a generic "could not decrypt" in that specific case.
Because identity/session state now lives in `omemo-db.ts`, outside what
`logout()` destroys, the same-device logout/login case (re-reading old
history after logging back in on the same browser) keeps working without
special-casing.
## Trust model: silent TOFU
No existing precedent for trust UI in this codebase. Given the stated threat
model is specifically "the admin can passively read already-stored
plaintext" — not an active attacker forging bundles in real time, a far more
conspicuous attack — silent trust-on-first-use (pin the first identity key
seen per device; warn only when a *previously pinned* key changes) matches
the actual threat and avoids forcing a confirmation dialog on every new chat
that most people would rubber-stamp anyway. Manual fingerprint verification
stays available (the `Fingerprint.svelte` sheet) but opt-in. This matches
Monal's own default (no blocking prompt for OMEMO unless "allow unverified
calls" is explicitly off).
## Licensing: resolve before shipping publicly
This repo's `LICENSE` is Zero-Clause BSD and `README.md:50` says "use it
however you like." `libomemo.js` is GPLv3. Bundling it into the built,
distributed `dist/` client creates a combined work; distributing that under a
bare 0BSD claim is not GPLv3-compliant — isolating OMEMO into its own source
files does not avoid this, since copyleft triggers on distributing the
combination, not on source layout. **Required step, not optional:** before
the build that includes `libomemo.js` is ever switched live on
`chat.0ff.space` (fine to use during dev/test on a side-by-side host first),
update `LICENSE`/`README.md` with a short, plain note — first-party source
keeps its 0BSD headers, but the distributed bundle, as a whole, additionally
carries GPLv3 obligations because of `libomemo.js`. This is the operator's
call to finalize the wording of, but it must actually be written, not
skipped.
## blunix: no server-side Prosody changes expected
`mam`, `pep`, `carbons` are already active on the main `chat.${domain}` vhost
(no `modules_disabled` there; the anonymous `anon.${domain}` vhost explicitly
disables `mam`/`pep`, implying they're on by default elsewhere) — OMEMO
device-lists/bundles ride on plain PEP, no `mod_omemo` or similar needed.
Confirm this holds during the Phase 2 live test rather than assuming it; the
one plausible surprise is a PEP node size/item-count limit tripped by a
~100-prekey bundle item.
After `npm install libomemo.js` in `monal-rebuild`: in `blunix`, follow the
README's documented flake-input update step (set `npmDepsHash = lib.fakeHash`,
build once, copy the real hash from the error) — easy to forget, call it out
explicitly when the time comes.
A new `blunix/docs/intent/` record (German, following `chat-web-client.md`'s
exact shape: Ziel / Umfang / Entscheidungen / Risiken / Erfolgskriterien /
Stand) should document this decision once it ships, per blunix's OPENCODE.md
documentation-ownership convention — new GPLv3 dependency, CSP change, and
the licensing-note change all belong in it.
## PLAN.md: new milestone
Add milestone 12 ("OMEMO encryption") to the existing numbered list
(currently ending at 11, pinix), in that file's established style, and update
the "Not in v1" bullet that currently defers OMEMO. Add rows to the
`## Decisions` table for: library choice (`libomemo.js`, GPLv3 — licensing
note), namespace default/verification status, trust model (TOFU). Status-log
entries follow the file's existing format (bold date+milestone lede, then
sub-bullets for what was tested/found/deployed) — one per phase below, as
each actually lands, same way milestones 8-10 built up incrementally.
## Phased rollout (mirrors milestone 7's `chat2.0ff.space` side-by-side)
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 `<bundle>`/`<list>`
build/parse, 6 unit tests including exact-wire-string assertions, not just
round-trips through our own parser — so build and parse can't silently
drift together away from what Monal actually reads) and new functions in
`src/crypto/omemo.ts`: `publishBundle`, `publishDeviceList` (fetch-merge-
publish, mirrors `muc.ts`'s bookmark pattern, never blind-publishes),
`fetchDeviceList`, `fetchBundle`, `replenishPreKeys`, and an orchestrating
`syncOmemo` hooked into `session.svelte.ts`'s `online` handler — every step
independently fail-soft (caught and logged) so a PEP hiccup cannot break
login. `src/xmpp/caps.ts` gained the devicelist `+notify` feature;
`src/xmpp/parse.ts` gained the namespace literals. Independently verified
by the coordinator: `npm run check` 0 errors/warnings, `npm test` 38/38 (32
existing + 6 new, stable across repeated runs), `npm run build` succeeds —
bundle now 412.21 kB/151.07 kB gzip (up from 202.26/65.51), the
`libomemo.js` cost the Phase 0 audit predicted, now actually paid since
`omemo.ts` is wired into the app for the first time via `session.svelte.ts`.
Deviations from the plan/task, noted by the implementer and confirmed on
review:
- Function signatures differ slightly from the task's sketch:
`publishBundle` gained a `bareJid` parameter (a PEP publish needs the IQ
addressed somewhere — the plan's architecture section didn't spell this
out for every function); `replenishPreKeys(db, account)` simplified to
`replenishPreKeys(db)` (the account was unused); `syncOmemo(xmpp,
account, deviceId)` simplified to `syncOmemo(xmpp, account)` since the
device id already comes from the idempotent `bootstrapIdentity()` call
inside it.
- **Publish-options minimalism, a deliberate deviation from the bookmark
precedent's full option set**: only `pubsub#access_model=open` and
`pubsub#persist_items=true` are sent, not bookmarks' `max_items`/
`send_last_published_item=never` — every extra field risks a
`preconditions-not-met` rejection against a node Monal may have already
created with different options, and the devicelist actually *wants* the
server's default send-last-published-item behavior (so contacts get the
current list pushed to them at login), which `never` would have
suppressed. Flagged here since it's an easy-to-miss detail if revisited.
- A small, deliberate two-module import cycle (`omemo.ts` exports
`OMEMO_NS`, `omemo-wire.ts` imports it back) — safe because neither side
dereferences the other's exports outside function bodies; commented
in-place as intentional. Trivially avoidable later by moving the
constant to the leaf module if it ever causes trouble.
- Both PEP nodes publish under item id `"current"` (an ecosystem
convention the plan didn't specify); the device-list republish preserves
whatever item id an existing list already used, rather than assuming
`"current"`.
- **Open risk for the still-pending live test**: if a pre-existing,
Monal-created node's configuration conflicts with either publish-options
field above, the publish fails (fail-soft, logged, plaintext messaging
unaffected) — this is the first thing the operator's manual scratch-
account interop test should check if OMEMO doesn't show up for a
contact.
## Phase 3 findings (2026-10-04, implemented directly — not delegated)
Done hands-on this time, not via herdr, specifically because this phase
touches real encryption rather than scaffolding. Before writing any wire
code, the actual installed `libomemo.js` was probed directly (a throwaway
local Node script, two in-memory sessions, no network, deleted afterward)
to settle three things the shipped `.d.ts` alone leaves ambiguous or that
this plan had only guessed at:
- **`SessionCipher.encrypt()`'s `body` is a binary string** (one JS
character per byte), **not base64** — confirmed by byte-length
comparison (decoding the returned string as base64 produces garbage of
the wrong length; decoding it as a raw byte-per-character string matches
exactly). `omemo-wire.ts` converts via `charCodeAt`, then base64-encodes
the real bytes for the XML; the reverse happens on decode.
- **Legacy `<bundle>` elements never carry a `registrationId`** (unlike
`omemo:2`), yet `PreKeyBundle.registrationId` is a required field for
`SessionBuilder.processPreKey()`. Confirmed empirically that passing `0`
works — session build, encrypt, and decrypt all succeed — because the
library only uses this field for bookkeeping, not the X3DH key agreement
itself.
- **AES-128-GCM, not the plan's originally-written "AES-256-GCM"**: this is
legacy OMEMO's actual, well-known convention (distinct from `omemo:2`,
which does things differently) — a 16-byte key and the 16-byte GCM tag
are concatenated into one 32-byte blob that gets wrapped per recipient
device; `<payload>` carries the ciphertext *without* the tag. Confirmed
the split/rejoin round-trips through WebCrypto before writing any real
code. This corrects the plan's "Send path" section, which had the key
size wrong.
New: `src/crypto/omemo-wire.ts` gained `buildEncryptedMessage`/
`parseEncryptedMessage` for the `<encrypted>` envelope (10 new tests: exact
wire-string assertions, malformed-never-throws, one-bad-key-skipped — same
discipline as the Phase 2 bundle/device-list tests). `src/crypto/omemo.ts`
gained `cachedDeviceList` (the `devices` IndexedDB store Phase 1 only
scaffolded, now actually read/written), `ensureSession` (builds a Double
Ratchet session from a fetched bundle, one randomly chosen one-time
prekey, tolerating an empty prekey set per X3DH), `encryptFor` (the full
send-path: AES-GCM the body once, Double-Ratchet-wrap the key+tag per
device — the peer's devices *and* our own other devices — fail-closed:
throws if not one single device could be encrypted for), and `decrypt` — a
pure inverse of `encryptFor`, **written now but not yet wired into the
receive path** (`sync.ts`, Phase 4's job, with its own dedup-before-decrypt
reordering); its only purpose right now is letting `encryptFor` be verified
end-to-end locally, which two new `test/omemo.test.ts` tests do: a full
plaintext round-trip between two simulated devices, and a check that a
third, separate "own other device" (e.g. Monal on the phone) also receives
a wrapped key it can decrypt. No live network involved in any of it.
`src/store/db.ts`'s `StoredMessage` gained `encrypted?`/`undecryptable?` as
the plan's Architecture section already specified (no `VERSION` bump).
`session.svelte.ts`'s `send()` 1:1 branch: capability checked via the
cache (one network round trip per peer per session, not per message);
capable ⇒ encrypt and fail closed on any error, falling back to plaintext
only when genuinely not capable; not capable ⇒ unchanged. **One gap this
plan never addressed, found and closed while wiring this in**: `send()`'s
1:1 branch also handles *private messages to room occupants*
(`room@muc/nick`, a full JID) — OMEMO device lists are published under a
*bare* JID only, and in a semi-anonymous room an occupant's real bare JID
may not even be known. Treating `peer.includes("/")` (the same test
`isRoom`/the avatar-fetch path already use for this exact distinction) as
"never consider OMEMO here" avoids both a wrong PEP lookup and what would
otherwise have been a hard failure (the peer check's own fetch throws on
anything other than `item-not-found`, by design) breaking private
messaging to room occupants entirely. **A second gap, also not in the
plan**: encrypted file attachments (XEP-0066 `oob`) aren't specified
anywhere in `OMEMO-PLAN.md`. Sending one to an OMEMO-capable contact now
fails closed with a clear error instead of silently sending the attachment
URL in the clear (which would defeat the point for exactly the kind of
content most worth protecting) — a real encrypted-attachment scheme is
left for a later phase, not invented ad hoc here.
## Phase 4 findings (2026-10-04, implemented directly — not delegated)
`Sync.#store()` (`sync.ts`) now: skips OMEMO entirely for `type="groupchat"`
(step 1); checks the new `ChatDB.hasMessage(key)` *before* attempting
decrypt, not after (step 2) — a duplicate delivery never advances the
sender device's Double Ratchet a second time, and `putMessage`'s existing
merge logic already keeps the first copy's real decrypted body for an
exact-key duplicate, so skipping decrypt for one loses nothing; calls the
new `decryptIncoming` (which never throws) and, on success, replaces `body`
with the real plaintext and sets `encrypted: true` (step 3/4); on failure
(no key for this device, or a decrypt error), produces a `StoredMessage`
with `encrypted: true, undecryptable: true` and one of two placeholder
strings depending on a new `firstEver` parameter threaded through
`#query`/`#initial` (step 5) — distinguishing "this device wasn't added
yet when this was sent" (expected OMEMO forward secrecy) from a generic
decrypt failure, exactly as specified. No UI change: `Chat.svelte` renders
these as ordinary message bodies for now (distinct styling for
`undecryptable` is Phase 5's job).
**Deliberately not unit-tested, and why**: `Sync`'s constructor uses
TypeScript constructor parameter properties (`private xmpp: Client, …`),
which Node's `--test` strip-only type stripping cannot handle — the same
constraint `omemo-db.ts`/`omemo-store.ts` were written around in Phase 1.
`Sync` has **no existing tests at all** for this reason; refactoring its
constructor just to add one for this phase would be an unrelated, riskier
change than the feature itself. This matches the codebase's existing
convention for network-integration code (the PEP-calling functions in
`muc.ts`'s bookmarks and `omemo.ts` Phase 2 are equally untested, verified
live instead) — the crypto logic the new glue code calls
(`decryptIncoming`/`decrypt`/`parseEncryptedMessage`) is already
thoroughly unit-tested from Phase 3, so the only genuinely unverified
layer is the `sync.ts` wiring itself, reviewed carefully by hand and by
`npm run check`'s full type-checking of it. The still-outstanding live
tests above are therefore this wiring's **first real verification**, not
just the protocol's.
## Phase 5 findings (2026-10-04, foundation implemented directly, UI delegated via herdr)
**A real correctness bug, found while designing the lock icon's "key
changed" state and fixed before any UI was built on top of it**:
`OmemoStore.isTrustedIdentity()` returned `false` on a changed identity
key, which makes the library's own `SessionCipher.encrypt`/`decrypt`
**throw** ("Identity key changed") — silently *blocking* every further
send/receive with that device until something caught it. That directly
contradicts this plan's own "Trust model: silent TOFU" section, which
calls for *warning*, never *blocking*, given the stated threat model
(passive storage access, not an active real-time attacker). Fixed:
`isTrustedIdentity()` now always returns `true`; `saveIdentity()` alone
detects a change and persists it as a status on the `TrustRecord`
(`"tofu"` | `"changed"` | `"verified"`, replacing the old bare pinned-key
storage) for the UI to read — never blocking the crypto itself. A further
key change always re-flags `"changed"` even over a prior `"verified"`.
New exports in `omemo.ts` for the UI layer to build on: `getTrustStatus`
(one device's pinned key + status), `markVerified` (the "mark verified"
toggle `Fingerprint.svelte` needs), and `fingerprintFor` (wraps the
library's `FingerprintGenerator`, 5200 iterations — Signal/OMEMO's
long-standing convention, unverified against Monal's own display; the
live out-of-band fingerprint comparison itself is that verification, not
a formality). `test/omemo.test.ts` gained 2 new tests (status lifecycle;
fingerprint stability and order-independence) plus fixes to 2 existing
trust tests that asserted the old, incorrect blocking behavior.
Independently verified: `npm run check` 0 errors/warnings, `npm test`
46/46 (run twice), `npm run build` succeeds at 432.81 kB/156.37 kB gzip
(unchanged from Phase 4 — nothing new is wired into the running app yet).
Also added before delegating the UI: `keyFingerprintHex` (a standalone,
non-pairwise identity-key display for Settings — `fingerprintFor` needs
*two* parties and isn't meaningful for just one device alone) and five
thin, UI-facing methods on `Session` (`omemoDevices`, `omemoMarkVerified`,
`omemoFingerprint`, `omemoOwnFingerprint`, `omemoPrefetch`) so the UI layer
never touches `#xmpp`/`OmemoDB` directly, matching how it already never
touches `#muc`/`#sync`. One new test for `keyFingerprintHex`. Independently
verified before delegating the UI: `npm run check` 0 errors/warnings, `npm
test` 47/47 (run twice), `npm run build` succeeds at 435.58 kB/157.07 kB
gzip.
**The UI itself — delegated to OpenCode via herdr (`opencode run`), done
2026-10-04**: `Chat.svelte` gained the lock icon (none / plain "encrypted"
/ a distinct "changed" variant in `var(--danger)`, re-checked whenever a
new message arrives — a new message can be the first thing to pin a
device's key — and primed on open via `omemoPrefetch`), the message bubble
gained a muted-italic style for `undecryptable` messages, and a new
`Fingerprint.svelte` sheet lists each device's trust status and both
fingerprint halves with a "mark verified" button (re-fetches immediately
after marking, so the status updates without reopening). `Settings.svelte`
gained an "Encryption" section showing `omemoOwnFingerprint()`. Reviewed
and independently re-verified by the coordinator (not just the
implementer's self-report, which this time couldn't even finish — the
`opencode-go` provider's own usage limit was hit right after its checkpoint
commit landed, so the code was reviewed cold): `npm run check` 0
errors/warnings, `npm test` 47/47 unchanged (no new unit tests — this
project has no precedent for testing Svelte components, consistent with
the task's own scope), `npm run build` succeeds at 440.55 kB/158.53 kB
gzip, and the dev server was started and curled (HTTP 200, no build-time
errors) as a minimal runtime check. **Not verified**: actual rendering in
a real browser, and the live Monal fingerprint comparison this feature
exists for — both need the operator.
**Real-browser rendering verified (2026-10-05, Playwright CLI, not
committed to the repo)**: two disposable accounts (`testcal-a`,
`testcal-b`, pre-existing LLDAP test accounts with a known password) in
two separate Chromium contexts against the `npm run dev` proxy to
production `chat.0ff.space` — no synthetic/staging server exists, so this
exercised the real Prosody. Confirmed by screenshot: the lock icon renders
in `Chat.svelte`'s header; a message sent from A arrived on B decrypted
correctly (plaintext, correct content, no `undecryptable` styling);
opening the lock icon's `Fingerprint.svelte` sheet lists B's device(s)
with matching "You"/"Them" fingerprint halves and working "Mark verified"
buttons; a message from a now-superseded device correctly rendered the
muted "message sent before this device was added" label instead of
crashing; `Settings.svelte`'s Encryption section shows the own device's
fingerprint. This closes the "real browser" half of Phase 5's open item —
**still open**: the live out-of-band fingerprint comparison against the
operator's actual Monal device, which by definition only the operator can
do. Setup note for next time: npm's own `playwright` package downloads a
foreign-glibc Chromium that doesn't run on NixOS without `nix-ld`; used
`nix-build '<nixpkgs>' -A playwright-driver.browsers` for the browser
binaries instead, with the npm `playwright` package version pinned to
match the driver's (1.61.1, `npm install playwright@1.61.1 --no-save`,
not added to `package.json`). Script kept at
`.playwright-cli/omemo-e2e.mjs` (already `.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.
- `<encrypted>` wire-level byte layout (esp. `omemo:2`'s per-recipient
grouping, GCM tag placement) needs spec/interop verification, not just this
plan's research-level recollection.
- `'wasm-unsafe-eval'` CSP3 support on the stated Safari 14+ minimum is
unconfirmed — ship the feature-detect-and-disable fallback regardless.
- The GPLv3/0BSD licensing-text change is a real decision for the operator to
finalize wording on, not an implementation detail to quietly skip.
- Resolved by the 2026-10-04 audit above: `libomemo.js`'s storage-interface
contract, module shape, and worker-offload build are now read from its
shipped `.d.ts` and real `dist/` output, not assumed from README-level
research. The two corrections (plain-string session serialization, no
namespace in `address`) are folded into "Architecture" and "Audit
findings" above.
## Verification summary
Each phase above has its own test step. The two tests that matter most for
whether this feature actually does what it's for: (a) Phase 3's direct
inspection of Prosody's stored MAM data, confirming the admin genuinely can't
read new 1:1 messages in storage; (b) a live interop test against the
operator's real Monal device before the public switch-over, since Monal
compatibility is the whole point of matching its namespace and wire format.
## Dead-device cleanup (2026-10-07, after the live interop test)
A device id is minted per browser profile in `bootstrapIdentity`, and
nothing ever retracted one. Clearing site data, switching browser, or (as
this project's own Playwright runs showed) opening a fresh browser context
mints another id while the old one stays published forever. The test
account reached **nine** published devices, each of which every contact's
client kept wrapping a per-device key for.
This was never a correctness fault: `encryptFor` skips a device it cannot
build a session with (`ensureSession` returns false, the loop `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
`<uuid>.<original extension>`. The extension has to survive — both clients
derive the media kind from it (`media.ts:attachmentKind`, Monal's
`getMimeTypeOfOriginalFile:`) — so what is protected is the user's filename
and the declared type, not the file *sort*. Ciphertext length is
`plaintext + 16` and is visible to the server; that is inherent to the
scheme and not worth pretending otherwise.
### Server side: nothing to change, verified live
- `upload.0ff.space` already answers with CORS for the file route. Checked
2026-10-08 with `curl -H "Origin: https://chat.0ff.space"
https://upload.0ff.space/file_share/abc/x.bin`:
`access-control-allow-origin: https://chat.0ff.space`,
`access-control-allow-methods: OPTIONS, GET, PUT`,
`access-control-allow-headers: Content-Type, Authorization`. The headers
come from `mod_http_file_share`'s own `provides("http", {cors = …})`,
which is active because `http_file_share_base_url` is unset (only
`http_external_url` is). A `fetch()` of the ciphertext is therefore
possible without touching Prosody.
- The CSP in `blunix:capabilities/xmpp/chat-client.nix:48-50` already has
`connect-src … https://upload.0ff.space`, `img-src … blob:` and
`media-src … blob:`. No header change.
- No upload type restriction: `http_file_share_allowed_file_types` is unset
in `/etc/prosody/prosody.cfg.lua`, so `application/octet-stream` is
accepted. It is not in `safe_file_types`, so the GET is served as
`Content-Disposition: attachment` — irrelevant to `fetch()`, which is how
we read it.
- Size limit `http_file_share_size_limit = 10485760` (10 MiB,
`/etc/prosody/prosody.cfg.lua:136`). The pre-upload check in
`upload.ts:upload` compares `file.size` against the disco `max-file-size`
and must compare `file.size + 16` for the encrypted path, or a file
within 16 bytes of the limit passes the check and fails the PUT.
### Only our own upload host is fetchable, and that is deliberate
`connect-src` lists `upload.0ff.space` and nothing else, so a fetch of a
federated contact's `aesgcm://` URL on *their* server would be blocked by
the browser regardless of what the client attempts. The client therefore
checks the host against `config.mediaHosts` — the same rule
`isTrustedMedia` already applies to inline plaintext media — and, for any
other host, shows the message as an attachment that this client cannot
open rather than issuing a request that CSP will refuse. Our own outgoing
uploads always land on our own host, so both directions work with Monal on
`0ff.space`; a federated encrypted attachment is a stated limitation, not a
bug. Widening `connect-src` to arbitrary hosts to fix it is not acceptable.
### Client-side shape
- New `src/crypto/aesgcm.ts`: `encryptFile(blob)` →
`{ ciphertext, key, iv }`, `decryptFile(bytes, key, iv)`,
`buildAesgcmUrl(getUrl, key, iv)`, `parseAesgcmUrl(url)` (returns the
https URL plus key/iv, or null). Pure WebCrypto — no libsignal, so none
of the WASM/CSP caveats of `omemo.ts` apply and `omemoSupported()` is not
a precondition for *media*, only for the OMEMO text path that carries it.
- `sendFile` (`session.svelte.ts:447`): for a 1:1 peer known capable,
encrypt first, upload the ciphertext under the anonymised name, then send
the `aesgcm://` URL through the existing encrypted `send()` path. The
`oob` argument stays `undefined` on that path; `send()`'s current
`capable && oob` rejection becomes a rejection of *plaintext* `oob` to a
capable peer, which it should have been all along. Rooms and non-capable
contacts keep today's plaintext `oob` behaviour untouched.
- `mediaCapability`: the "aren't supported yet" reason disappears for a
capable peer in the same commit. Unknown/offline capability stays
fail-closed — unchanged, and still never a licence to upload plaintext.
- `media.ts`: `isAttachment` additionally recognises a lone `aesgcm://`
body on an `encrypted` message, so no new `StoredMessage` field and no
change to the key/merge logic in `db.ts`. `attachmentKind` and
`attachmentLabel` already work off the extension, which the anonymised
name keeps.
- `Chat.svelte:881-897`: for an `aesgcm://` row, fetch → decrypt → one
object URL per message, then the existing `<img>`/`<audio>`/`<video>`/
file branches unchanged. Revoke every object URL on component destroy and
when a row leaves a small LRU; all uploads are ≤ 10 MiB, so decryption is
automatic and needs no "tap to load" state.
- **Storage honesty**: the decrypted body stored in IndexedDB *is* the
`aesgcm://` URL, so the file key sits in the local store next to the
decrypted text it belongs to — the same confidentiality tier this client
already accepts for plaintext bodies (see "Open risks"). Stripping the
key would make the attachment unopenable after a reload, so it stays.
- Voice messages keep the existing format ladder (`audio/mp4` first, then
`audio/webm`). A WebM note recorded on desktop still will not play in
Monal on iOS; that is pre-existing and not introduced here.
### Failure and cancellation rules, each distinct
Never a plaintext fallback, and never the ciphertext on screen.
| Situation | Behaviour |
| --- | --- |
| Abort before/while uploading | Nothing is sent; the existing `AbortSignal` path already covers slot and PUT |
| Encryption itself fails | No slot is requested, no PUT is made |
| Slot or PUT fails | Today's error text, unchanged |
| GET 404 / expired (30 days) | "No longer available on the server" — the existing `brokenMedia` state |
| Fragment malformed or wrong length | Render as an unopenable attachment with a distinct reason; no fetch |
| Tag mismatch or truncated file | WebCrypto `decrypt` rejects: "This file could not be decrypted", with a retry; never render partial bytes |
| Host not in `mediaHosts` | Unopenable attachment, no request (see above) |
### What the tests must cover
Round trip against a **fixed** key/IV vector (so the hex layout itself is
asserted, not just self-consistency); all three receive fragment layouts
including the hardcoded-IV legacy one; a flipped byte rejected; the
anonymised filename keeping its extension; the size pre-check including the
16-byte tag; and a stanza-level assertion that the encrypted attachment
carries **no** `jabber:x:oob` child and no second body line. Cosmetic
markup tests are not wanted.
### Acceptance (unchanged from package 9)
Image, generic file and voice round trips between two PWA profiles **and**
real Monal, including a second own device; no plaintext PUT and no `oob`
descriptor for a capable peer; cancellation, failed fetch and integrity
failure each shown correctly; real iPhone recording, permission and
playback. Two copies of this web client and a synthetic microphone do not
substitute for the device half.
## Encrypted attachments implemented (2026-10-08, not yet deployed)
Package 9's code half, built against the section above. New
`src/crypto/aesgcm.ts` (plain WebCrypto: `encryptFile`, `decryptFile`,
`buildAesgcmUrl`, `parseAesgcmUrl`, `anonymousName`, `TAG_BYTES`).
`sendFile` encrypts for a capable 1:1 contact, uploads the ciphertext as
`application/octet-stream` under a `<uuid>.<ext>` name and sends the
`aesgcm://` URL through the existing encrypted `send()` — no `oob`
argument, so the envelope is the only place the URL exists.
`mediaCapability` now answers `{ allowed: true, encrypt: true }` where it
used to explain the limitation, and `media.ts`'s `isAttachment` became
`attachmentUrl(body, oob, encrypted)`, which recognises a lone `aesgcm://`
body only on an encrypted message — so `db.ts`'s chat-list preview and the
notification label show "📷 Photo" instead of a URL carrying a key.
`Chat.svelte` fetches, decrypts and renders through one object URL per
message, with "Decrypting…", a distinct reason per failure and a retry.
**Two deviations from the design above, both deliberate:**
- The design said a small LRU of decrypted blobs. Implemented instead as:
a blob lives exactly as long as its message is in the loaded window, and
every object URL is revoked when the view closes. An LRU can revoke a URL
an `<img>` on screen is still using; this cannot, and the window already
bounds how much is held.
- The design said `upload.ts:upload` should compare `file.size + 16`. That
would have been wrong in the implemented shape: `upload` receives the
*ciphertext*, whose size already includes the tag, so it would have
double-counted. The limit moved into an exported `checkSize`, which
`sendFile` applies to `file.size + TAG_BYTES` before spending the time to
encrypt, and `upload` still applies to what it is actually given.
**Two defects found and closed while building it:**
- `url.protocol = "aesgcm:"` silently does nothing: WHATWG URL refuses to
assign a non-special scheme over a special one (and vice versa), so both
the build and the parse were returning `https://…` URLs. The fixed-vector
test caught it on first run. Both directions now swap the scheme
textually, which is what Monal and Conversations do too.
- **A key leak in the design's own send path.** `send()` encrypts only when
the peer is *currently* capable. If the peer's device list emptied between
the upload and the send, the `aesgcm://` body — which *is* the file key —
would have gone out as cleartext. Closed twice over: the `authorize`
callback that re-runs before the slot and the PUT now rejects a *changed*
encryption decision rather than only a withdrawn one, and `send()` refuses
outright to put an `aesgcm://` body on the wire without an envelope (and
in a room at all).
Verified: `npm run check` clean, `npm test` 86 passing (9 new in
`test/aesgcm.test.ts`, including a vector decrypted from an *independent*
`node:crypto` AES-256-GCM ciphertext, all three receive layouts, flipped
byte / truncation / wrong key, and the anonymised name), `npm run build`
succeeds. The service worker passes cross-origin GETs straight to the
network, so the ciphertext fetch is neither intercepted nor cached.
Not done, and deliberately not claimed: no live browser run, no round trip
against real Monal, no iPhone recording or playback, and nothing deployed —
no client push, no `nix flake update chat-client`, no switch. A stanza-level
"no `oob` child" assertion is not a unit test either: `session.svelte.ts` is
a runes module with no node test harness, so the invariant is covered in
units only through `attachmentUrl` refusing the encrypted shape whenever an
`oob` is present, and belongs to the live acceptance run otherwise.
## Encrypted attachments accepted live (2026-10-08, generation 557)
Deployed and exercised against the deployed bundle; the full record with
every number is in `UX-MEDIA-RESULTS.md`. Client `031766c` pinned by blunix
`d23cb82`, switch succeeded as generation 557, nginx root
`…cjk60a637slh8h2kdb25dmjb860s2839-0ff-chat-0.1.0`, the Nix build, the local
`dist` build and the HTTPS-served bundle byte-identical, `sw.js` unchanged,
CSP unchanged, ten services active, zero failed units, rollback 556.
`ux-media-runner.cjs` (new, Chromium 152, two profiles, audit accounts):
all ten checks pass with zero page errors. What that establishes beyond the
unit tests — every outgoing attachment stanza is OMEMO-encrypted with **no**
`jabber:x:oob` child and no `aesgcm` string anywhere in cleartext XML; the
second profile decrypts image, file and voice independently, the 1×1 PNG
decodes 1×1 and the generic file downloads as the exact plaintext bytes; the
upload sizes are plaintext + 16 to the byte (70→86, 296→312) and the only
plaintext PUT in the run was the group upload, which is plaintext by design;
404 and integrity failure read differently and the retry recovers; a foreign
host is explained with no request made; and the group path kept its `oob`,
its real name and its real media type.
Three checks first failed on the runner's own assumptions, recorded because
they will mislead the next run: Prosody's `cache-control: max-age=31556952,
immutable` means a re-read never reaches the network (so each failure check
sends a fresh attachment); persistent profiles make a bare `waitFor` match
an older attachment (so round trips count); and `connect-src` has no
`blob:`, so an in-page `fetch` of our own object URL is CSP-blocked — the
app never does that, it only assigns to `src`/`href`, so the download check
uses a real browser download.
Open and device-bound: the real Monal round trip in both directions, and
iPhone recording permission, recording and playback. A desktop WebM voice
note still will not play in Monal on iOS; the recorder prefers `audio/mp4`
where offered, which is the iOS case.