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>
1129 lines
68 KiB
Markdown
1129 lines
68 KiB
Markdown
# 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.
|