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>
771 lines
47 KiB
Markdown
771 lines
47 KiB
Markdown
# monal-rebuild — plan
|
||
|
||
A small, clean XMPP web client in the spirit of Monal, replacing the stock
|
||
`nioc/xmpp-web` 0.12.0 build on blunix's `chat.0ff.space`. Planned
|
||
2026-09-28.
|
||
|
||
## Why
|
||
|
||
`xmpp-web` works, but:
|
||
|
||
- it looks like Bulma, not like a messenger — blunix patches its CSS with
|
||
hex-string substitutions and `display: none` on upstream markup
|
||
(`blunix:localServices/xmpp-web.nix`), which breaks on every upgrade;
|
||
- it has no "stay logged in" (noted in `pinix:xmpp/web.nix`);
|
||
- there is no conversation list with previews/unread counts, which is what
|
||
makes Monal feel like Monal.
|
||
|
||
## Target and scope
|
||
|
||
**v1 targets blunix, `chat.0ff.space` only.** `plaza.0ff.space` (anonymous
|
||
guest lobby) stays on xmpp-web until this client is solid. pinix is out of
|
||
scope for this project (2026-10-07): if `chat.0ff.space` proves itself in
|
||
daily use, replacing `pinix:xmpp/web.nix`'s xmpp-web happens in a separate
|
||
project, not here.
|
||
|
||
What blunix already provides (checked 2026-09-28 via `nix eval`):
|
||
|
||
| Item | State |
|
||
| --- | --- |
|
||
| Prosody | 13.0.6 |
|
||
| Modules | mam, carbons, smacks, csi, bookmarks, pep, blocklist, cloud_notify, vcard_legacy, http file share |
|
||
| Auth | LDAP (LLDAP), main vhost `0ff.space` |
|
||
| WebSocket | `wss://chat.0ff.space/xmpp-websocket` → Prosody :5280 via nginx :8080 |
|
||
| Upload | `upload.0ff.space` (XEP-0363) |
|
||
| MUC | `muc.0ff.space`, members-only, muc_mam (30 d), muc_moderation |
|
||
| Exposure | **public** (Traefik `websecure`) — login faces the internet |
|
||
| MAM retention (1:1) | not set → Prosody default, **1 week** |
|
||
|
||
## v1 features
|
||
|
||
- Login with JID + password; opt-in "stay logged in" (see Auth below)
|
||
- Stream management (XEP-0198) resume and automatic reconnect
|
||
- Conversation list: avatar, name, last-message preview, time, unread badge,
|
||
sorted by activity — 1:1 chats and rooms in one list
|
||
- 1:1 chat: bubbles grouped by sender/time, delivery receipts (XEP-0184),
|
||
chat markers (XEP-0333), typing notifications (XEP-0085), message
|
||
correction display (XEP-0308)
|
||
- Rooms (MUC): join from bookmarks (XEP-0402), read/send, nicknames,
|
||
room history via muc_mam, mention highlighting
|
||
- Carbons (XEP-0280) so messages sent from Monal show up here
|
||
- Media: upload images/files (XEP-0363), inline image preview, voice
|
||
message recording (MediaRecorder; Safari `audio/mp4`, others
|
||
`audio/webm` — same caveats as pinix's jev-panel)
|
||
- Contacts: roster, avatars (XEP-0084 / vCard fallback), add/remove contact
|
||
- Settings: account, notifications (in-page only), theme (system/light/dark),
|
||
logout
|
||
- PWA: manifest + service worker for the app shell (installable on home
|
||
screen); no offline sending
|
||
- Layout: single pane on phones, two panes (list | chat) on desktop —
|
||
both first-class
|
||
|
||
## Not in v1
|
||
|
||
- OMEMO (end-to-end encryption) — deferred past v1 for exactly this reason;
|
||
approved as milestone 12, see `OMEMO-PLAN.md`
|
||
- ~~Web push notifications~~ and ~~Jingle audio/video calls~~: moved into
|
||
milestones 8–10 (decided 2026-09-28)
|
||
- Guest/anonymous access (plaza keeps xmpp-web)
|
||
- Room administration beyond what moderation needs; creating rooms
|
||
|
||
## Stack
|
||
|
||
- **Svelte 5 + TypeScript**, built with **Vite** — compiles to plain DOM
|
||
code, tiny runtime, no CSS framework. Budget: ~100–150 KB gzipped total.
|
||
- **xmpp.js** (`@xmpp/client`, websocket transport only) for the protocol.
|
||
Note: current releases are ESM-only and declare Node ≥ 22 — a build-time
|
||
constraint; verify the output transpiles to the browser target.
|
||
- **IndexedDB** (via a thin wrapper such as `idb`) for the local store.
|
||
- Plain CSS with custom properties; the 0ff.space launcher palette and
|
||
dotted background come in as variables from the host config, not as
|
||
overrides.
|
||
- **Browser target (decided): `es2020`** — Chrome/WebView 80+, Safari 14+,
|
||
Firefox 78+ (Vite's `build.target` names es2020 plus these browser
|
||
versions). That covers phones from roughly 2017 on with an updated
|
||
Chrome. No legacy plugin unless a real device fails.
|
||
|
||
## Architecture
|
||
|
||
```
|
||
src/
|
||
xmpp/ connection, auth, reconnect, stanza handlers per XEP
|
||
store/ IndexedDB schema, message/conversation/roster stores
|
||
sync/ MAM catch-up, carbons, receipts/markers bookkeeping
|
||
ui/ Svelte components: ChatList, Chat, Room, Contact, Settings
|
||
config.ts reads window.config (generated per host by Nix)
|
||
```
|
||
|
||
**Data layer first.** The conversation list is driven by the local store,
|
||
not by live queries:
|
||
|
||
1. On login, run one global MAM query on the account archive starting
|
||
after the last stored archive id (`<after>` / RSM), page until done.
|
||
2. For each joined room, the same against the room's muc_mam.
|
||
3. Live messages and carbons write to the store; the UI subscribes to the
|
||
store.
|
||
4. Conversations table keeps last message, unread count and last-read
|
||
marker, so the list renders instantly from IndexedDB before the network
|
||
is up.
|
||
|
||
Because 1:1 retention is only one week on the server, the local store is
|
||
what gives long history on a device. Additionally raise retention on the
|
||
server (see Server changes).
|
||
|
||
## Auth / staying logged in
|
||
|
||
`chat.0ff.space` is public, so:
|
||
|
||
- **Decided: SASL2 + FAST** (XEP-0388 / XEP-0484). With "stay logged in"
|
||
ticked, the browser keeps a revocable token, never the password; without
|
||
it, nothing is persisted. Verified 2026-09-28:
|
||
- Prosody 13.0.6 core has no SASL2, but the community-modules revision
|
||
nixpkgs pins (`15a7749c7acb`) has `mod_sasl2`, `mod_sasl2_bind2`,
|
||
`mod_sasl2_fast`, `mod_sasl2_sm`, all documented as working on 13.
|
||
blunix already installs community modules via
|
||
`withOnlyInstalledCommunityModules`, so it's a list addition.
|
||
- Works with LDAP: tokens live in Prosody's own store and are checked
|
||
by HMAC, not against LDAP. Caveat: `mod_auth_ldap` has no
|
||
`get_account_info`, so **an LDAP password change does not revoke
|
||
existing tokens**. They expire after `sasl2_fast_token_ttl` without
|
||
use (default 21 days). Mitigation: logout in the client invalidates
|
||
its token; consider a shorter TTL.
|
||
- xmpp.js ships `@xmpp/sasl2` and `@xmpp/sasl-ht-sha-256-none`, and
|
||
`@xmpp/client` depends on both.
|
||
- Password is never written to IndexedDB/localStorage.
|
||
- Strict CSP (`default-src 'self'; connect-src 'self' wss://chat.0ff.space
|
||
https://upload.0ff.space; img-src 'self' https://upload.0ff.space blob:
|
||
data:; media-src 'self' https://upload.0ff.space blob:`), no third-party
|
||
requests, no inline scripts.
|
||
- Matching rule in the client (milestone 5): attachments are shown inline
|
||
only from `config.mediaHosts` (default `upload.<domain>`); media from
|
||
any other host stays a link, so a stranger's message can't make the
|
||
browser fetch from their server (IP leak) and nothing breaks under the
|
||
CSP.
|
||
|
||
## Server changes (blunix)
|
||
|
||
Small, in `localServices/prosody/setup-prosody.nix`:
|
||
|
||
- `archive_expires_after = "1y"` on the main vhost (decided: one year;
|
||
the local store keeps longer history per device anyway).
|
||
- Add `https://chat2.0ff.space` to `cross_domain_websocket` for the
|
||
side-by-side period; otherwise Prosody refuses the websocket.
|
||
- Add `sasl2`, `sasl2_bind2`, `sasl2_fast`, `sasl2_sm` to
|
||
`withOnlyInstalledCommunityModules` and enable them on the main vhost.
|
||
|
||
## Packaging and deploy
|
||
|
||
- Build from source in Nix with `buildNpmPackage` (lockfile hash pinned).
|
||
- Per-host `config.js` generated like today's `local.js`: websocket URL,
|
||
default domain, MUC domain, upload host, palette, app name.
|
||
- blunix: new nginx vhost + Traefik router `chat2.0ff.space` next to the
|
||
existing ones in `localServices/xmpp-web.nix` /
|
||
`localServices/traefik/prosody.nix`; after sign-off, point `chat.` at
|
||
the new build and drop the xmpp-web `chat` build (keep `plaza`).
|
||
- Record the decision in `blunix:docs/intent/` when it goes live, following
|
||
that repo's convention.
|
||
|
||
## Milestones
|
||
|
||
1. **Skeleton** — Vite + Svelte + TS project, config loading, login,
|
||
connect/disconnect over websocket against `chat.0ff.space`; verify on
|
||
the oldest target device.
|
||
2. **Data layer** — IndexedDB schema, roster, MAM catch-up, carbons,
|
||
live messages into the store.
|
||
3. **Core UI** — conversation list, 1:1 chat view, sending, receipts,
|
||
markers, typing; responsive one/two-pane layout.
|
||
4. **Rooms** — bookmarks, join, room history, send, mentions.
|
||
5. **Media** — upload, inline images, voice messages.
|
||
6. **Polish** — avatars, settings, theme, PWA, stay-logged-in.
|
||
7. **Nix + deploy** — `buildNpmPackage`, `chat2.0ff.space` side by side,
|
||
Prosody changes, test on phone + desktop, then switch `chat.`.
|
||
8. **Push** — notifications and the icon badge with the app closed; iPhone
|
||
(home-screen app, iOS 16.4+) and Android first, qutebrowser left out
|
||
(QtWebEngine has no Web Push). Two deploys:
|
||
- A: Prosody on Lua 5.4 (nixpkgs' default is 5.2; `mod_push2` wants
|
||
5.3+). Checked on tnix: builds with a stub for `luabitop` (broken on
|
||
5.4, unused from 5.3 on) and `buildPackages.lua` overridden, native
|
||
libs and P-256/HKDF load, `mod_push2` parses.
|
||
- B: `mod_push2` (Push 2.0, RFC 8291 encryption + VAPID JWT done by
|
||
Prosody) plus a small local relay module on `push.0ff.space` that
|
||
POSTs the ready payload to the browser's push service (allowlist:
|
||
Apple, FCM, Mozilla). Client: per-device VAPID key in WebCrypto,
|
||
`pushManager.subscribe`, `<enable>` at every login, `<disable>` at
|
||
logout; `sw.js` shows the notification (always — iOS revokes silent
|
||
pushes) and sets the badge from its own pending counter.
|
||
- Coexists with Monal's `mod_cloud_notify`: push2's hooks only act on
|
||
sessions registered with push2 (read in the source).
|
||
- Pushes for new archived messages need a session on the server; a
|
||
closed tab's hibernated session counts, extended by push2 (72 h
|
||
default) — to verify live.
|
||
9. **Calls, server** — coturn (`use-auth-secret`, secret in agenix) and
|
||
Prosody's `turn_external` (XEP-0215); UDP 3478 + a small relay range
|
||
(approved 2026-09-28). Also helps Monal-to-Monal calls.
|
||
10. **Calls, client** — Jingle Message Initiation, Jingle RTP/ICE/DTLS
|
||
over WebRTC; audio first (acceptance: a call with Monal on the
|
||
iPhone), then video. A closed web app can only show a notification
|
||
for an incoming call, no CallKit-style ringing.
|
||
12. **OMEMO encryption** — 1:1 end-to-end encryption via `libomemo.js`,
|
||
phased rollout per `OMEMO-PLAN.md` (approved 2026-10-04).
|
||
|
||
Milestone 11 was *pinix* (a second config replacing that host's xmpp-web).
|
||
It was removed on 2026-10-07 and moved out of this project; see Target and
|
||
scope. The number is left vacant so the status entries below, which all say
|
||
"milestone 12" for OMEMO, keep referring to the same thing.
|
||
|
||
## Decisions (2026-09-28)
|
||
|
||
| Question | Decision |
|
||
| --- | --- |
|
||
| Browser target | `es2020` (see Stack) |
|
||
| Server MAM retention | `1y` |
|
||
| App name | working name `0ff chat`; config-driven per host, like today |
|
||
| Side-by-side hostname | `chat2.0ff.space` |
|
||
| Staying logged in | SASL2 + FAST tokens (verified, see Auth) |
|
||
| OMEMO library (2026-10-04) | `libomemo.js` (GPLv3) — accepted over hand-rolled crypto; see `OMEMO-PLAN.md` |
|
||
| OMEMO namespace (2026-10-04) | legacy `eu.siacs.conversations.axolotl`, verified against production PEP data; `urn:xmpp:omemo:2` deferred |
|
||
| OMEMO trust model (2026-10-04) | silent TOFU (pin on first use); manual fingerprint verification stays opt-in |
|
||
|
||
## Status
|
||
|
||
- **2026-09-28, milestone 1 done:** project skeleton, config loading,
|
||
login form, FAST token handling (`src/xmpp/session.svelte.ts`), English
|
||
UI. Bundle 32 KB gzipped, passes `es-check es2020`. Logged in as
|
||
`lukas@0ff.space` via `npm run dev`: online.
|
||
- Server bug found on the way: blunix's nginx forwarded
|
||
`X-Forwarded-Proto: http`, so Prosody offered no SASL on the chat.
|
||
and plaza. websockets (both web clients were unusable). Fixed in
|
||
blunix `e203240` (main) / `078b959` (capability-layout, deployed);
|
||
afterwards chat offers PLAIN, plaza ANONYMOUS.
|
||
- Not yet exercised: FAST tokens ("stay logged in") — the server
|
||
modules come with milestone 7; testing on an old device.
|
||
- **2026-09-28, milestone 2 done:** IndexedDB store per account
|
||
(`src/store/`), sync engine (`src/sync/sync.ts`): roster + pushes,
|
||
carbons, live messages, receipts/markers, MAM catch-up from the last
|
||
archive id. 8 parser tests (`npm test`); store logic checked in a real
|
||
browser. Tested against `lukas@0ff.space` with messages from plaza
|
||
guests: live delivery with unread badge; a message sent while the tab
|
||
was closed arrives via catch-up with badge 1; nothing doubles across
|
||
logins; reading in Monal clears the badge here (own displayed marker
|
||
via MAM).
|
||
- Moved: room MAM catch-up to milestone 4 (was listed under the data
|
||
layer), together with joining rooms.
|
||
- Receipts/markers are matched on the message's `origin-id ?? id`.
|
||
- Known gap for milestone 4: MUC private messages would share the room
|
||
JID as conversation key with the room itself.
|
||
- Finding, not a client bug: the server has **no roster stored for any
|
||
account** (`/var/lib/prosody/0ff%2espace/` has no `roster/`), so the
|
||
client correctly shows 0 contacts while Monal shows a locally cached
|
||
list. Probably lost in the 2026-09-06 pinix → blunix move
|
||
(unverified). Re-adding contacts from Monal stores them server-side.
|
||
- **2026-09-28, milestone 3 done:** chat list, chat view, sending,
|
||
receipts, displayed markers, typing states, one/two-pane layout, plus
|
||
contacts pulled forward from milestone 6: add to the server roster and
|
||
answer contact requests. Tested live against a plaza guest: read marker
|
||
on opening a chat, ✓ → ✓✓ → cyan ✓✓, composing/paused sent, contact
|
||
request banner and accept. `agent@0ff.space` added from the web client
|
||
and a contact added in Monal both show up on the other side — Prosody
|
||
now stores a roster for `lukas@0ff.space`. Fixed before the live test:
|
||
loading older messages jumped back to the bottom (checked with 150
|
||
messages); logout now asks first.
|
||
- Not tested live: an incoming "typing…" (the guest typed before login).
|
||
- No UI to remove a contact yet (milestone 6).
|
||
- **2026-09-28, milestone 4 done:** bookmarks (XEP-0402), autojoin,
|
||
room MAM catch-up, instant-room creation (status 201), nick handling,
|
||
mentions, room PMs as their own conversations, and — found on the way
|
||
— handling of resumed streams (xmpp.js emits no "online" on an
|
||
XEP-0198 resume, so the UI stayed on "Reconnecting…" after a blip;
|
||
fixed in `9946cb8`). Verified on the server for `testom@muc.0ff.space`:
|
||
bookmark stored with autojoin; a message from the web client; a second
|
||
one after closing the tab and logging in again (auto-rejoin); "Text
|
||
from Monal" from the same bookmark in Monal.
|
||
- Not tested live (covered by parser tests and a sample-data render):
|
||
messages from other occupants, mentions, room PMs — new rooms are
|
||
members-only, so an anonymous test guest can't join.
|
||
- Known gaps: a room PM's typing state is keyed by the room; ticks on
|
||
room PMs stop at ✓ (receipts looked up under the room JID); system
|
||
messages from the bare room JID show the JID as nick; no leave-room
|
||
or remove-bookmark action; bookmarks without autojoin aren't listed.
|
||
- Finding, not a client bug: **no persistent rooms were left on
|
||
`muc.0ff.space`** (no `config/`, empty `persistent.dat`; only
|
||
`muc_log` survived). `lobby@muc` (plaza's guest lobby) and
|
||
`update@muc` (Forgejo notifications) don't exist, and no bookmarks
|
||
were stored server-side. Same probable cause as the roster. Decided
|
||
2026-09-28: leave the lost rooms for now.
|
||
- **2026-09-28, milestone 5 done:** HTTP upload (XEP-0363) with disco,
|
||
progress and cancel; attach, paste, drag and drop; voice messages;
|
||
inline images/audio/video/file links. Verified on the server
|
||
(metadata only): a PNG, a 4.1 MB PDF, a 5.1 MB JPEG and a voice message
|
||
recorded by the desktop browser as **AAC in MP4** (`audio/mp4`, 54 KB)
|
||
uploaded from the web client; a photo from Monal shows inline in the
|
||
web client; the voice message plays in Monal on iOS. First try failed
|
||
("no slot": the reply's `<slot>` was read with `iqCaller.get`, which
|
||
only returns a child named like the request) — fixed in `2f62166`.
|
||
- Rule chosen: attachments render inline only from `config.mediaHosts`
|
||
(see Auth); expired files fall back to a link.
|
||
- Server limits: 10 MB per file, 100 MB per user per day, and **uploads
|
||
are deleted after 1 week** (`http_file_share_expires_after` default) —
|
||
to decide with the other retention settings in milestone 7.
|
||
- Not tested live: screenshot paste; drag and drop (no file manager
|
||
at hand).
|
||
- **2026-09-28, milestone 6 done:**
|
||
settings page (theme, notifications, login-token status, log out),
|
||
notifications + unread count in the tab title, remove contact, leave
|
||
room, avatars (PEP → vCard, cached; DB schema v2, upgrade checked),
|
||
PWA (manifest, icons, service worker; offline shell checked in a
|
||
production preview). Pending: the user's run of the M6 test table
|
||
(settings, theme, notification via a plaza guest, remove the leftover
|
||
guest contact `kg4i…@anon.0ff.space`, optionally leave `testom`,
|
||
avatar set in Monal, `npm run build && npm run preview` install).
|
||
- Live run (qutebrowser 3.7 on tnix): settings, theme, remove contact
|
||
and leave room work. Avatar set in Monal showed only after adding
|
||
entity caps with PEP `+notify` (`e6118ed`). Unread count in the tab
|
||
title works. Pop-ups didn't show at first: QtWebEngine reports the
|
||
permission as `default` on every page load until the page asks again
|
||
(qutebrowser answers from `content.notifications.enabled`); fixed in
|
||
`8cd46c3` by asking at startup, plus an "Allow notifications" button.
|
||
A direct `new Notification()` reached the desktop daemon; after the
|
||
fix a message from a plaza guest popped up too.
|
||
- PWA checked in headless Chromium 154 against `vite preview`: no
|
||
installability errors, manifest parsed, `sw.js` active and in control,
|
||
and with the server stopped the login screen still loads from the
|
||
`shell-v1` cache. Not tried: installing on a phone.
|
||
- Follow-ups for milestone 7: generate the manifest name per host
|
||
(static now); decide how `sw.js` cache names get bumped; before
|
||
switching `chat.`, check whether the xmpp-web build there registered
|
||
a service worker that would keep serving the old app.
|
||
- Avatars follow PEP (`+notify` via entity caps), so changes made in
|
||
Monal arrive live and at every login. Option for milestone 7: a
|
||
server-side job copying the LLDAP photo to the account's XMPP avatar
|
||
(decided 2026-09-28: XMPP stays the source clients read).
|
||
- **2026-09-28, milestone 7 done: `chat.0ff.space` switched**, the user
|
||
confirmed login, rooms, avatars and the iPhone app. blunix (`capability-layout`, deployed):
|
||
`capabilities/xmpp/chat-client.nix` builds this repo's `package.nix`
|
||
from the public flake input `chat-client` (repo made public, 0BSD);
|
||
strict CSP; xmpp-web builds only plaza; decision record
|
||
`blunix:docs/intent/chat-web-client.md`. Three deploys:
|
||
`69a5de2` chat2 side by side + `archive_expires_after = "1y"` + uploads
|
||
30 days; `4bcdbdb` SASL2/Bind2/FAST/SM; `7af917a` + `7632459` the switch
|
||
(chat2 removed) and the app badge (`3fbd363`).
|
||
- Verified on chat2 by the user: login, messages, photo, room, avatars,
|
||
voice message, iPhone, and "stay logged in" across closing the tab.
|
||
Monal logs in over SASL2 and sends/receives (reply to a guest seen).
|
||
- Verified from tnix: headers/CSP, websocket features (FAST offers
|
||
`HT-SHA-256-NONE` only when the stream header carries `from`; xmpp.js
|
||
sends it), PWA installable and offline shell in headless Chromium.
|
||
- Corrections to the plan: Prosody accepts the websocket from any
|
||
Origin (`cross_domain_websocket` is CORS only), and nixpkgs' nginx
|
||
puts the store hash into ETags, so no cache-busting is needed.
|
||
- The old xmpp-web on `chat.` had a Workbox service worker at `/sw.js`;
|
||
ours replaces it at the same path, so browsers show xmpp-web at most
|
||
once more.
|
||
- Badge (Badging API) only updates while the app runs; live counts need
|
||
web push (not in v1).
|
||
- **2026-09-29, milestone 8 (push) done on the iPhone:** with the
|
||
home-screen app closed, two guest messages arrived as notifications,
|
||
the icon counted 1 → 2 and went back to the real unread count after
|
||
opening; Monal's own push kept working. blunix: `59db461` Prosody on
|
||
Lua 5.4, `0ec011d` `mod_push2` + `mod_track_muc_joins` + local
|
||
`mod_webpush_relay` on `push.0ff.space` (senders limited to
|
||
`0ff.space`, https to the Apple/FCM/Mozilla/WNS push services only),
|
||
`f58c28a` `client_https_ssl` with the CA bundle — the first live pushes
|
||
failed "certificate verify failed" because Prosody's default
|
||
`capath /etc/ssl/certs` has no hashed links on NixOS. Client
|
||
`cd29bb5`: per-device VAPID key, subscribe + `<enable>` after each
|
||
login, `<disable>` on logout, `sw.js` shows the push and counts the
|
||
badge.
|
||
- Tested before deploying against a local Prosody from the same
|
||
package: decrypted payload, valid VAPID JWT, hibernated session,
|
||
forged notification refused.
|
||
- `mod_push2` findings: its `archived` match profiles never match
|
||
(`stana-id` typo), so the client uses `important`; it needs
|
||
Lua 5.3+ and `mod_track_muc_joins`.
|
||
- Not tested: Android. Known limits: titles are the JID's local part
|
||
(no roster names in the service worker); with Monal installed too,
|
||
the iPhone shows both apps' notifications.
|
||
- **2026-09-29, milestone 9 (TURN) done:** blunix `4e78cef`: coturn on
|
||
`turn.0ff.space` (public IPv4, UDP/TCP 3478, relay UDP 49160–49259, no
|
||
TURNS, no TCP relay, `use-auth-secret` with agenix `turnSecret.age`) and
|
||
Prosody's `turn_external` on the `0ff.space` vhost only (secret via
|
||
systemd `LoadCredential` + `Credential()`). Verified from tnix with
|
||
`turnutils_uclient` and credentials taken from Prosody's
|
||
`external_services`: UDP and TCP allocations, relay-to-relay 20/20
|
||
packets; wrong password refused; peers `127.0.0.1`, `100.96.0.6`
|
||
(tailnet) and `192.168.1.1` refused with 403 (`denied-peer-ip`).
|
||
`anon.`, `bot.` and `muc.` have no `turn_external`.
|
||
- Not tested: the XEP-0215 IQ from a real client (Monal or 0ff chat) —
|
||
comes with milestone 10; a Monal-to-Monal call over the relay.
|
||
- Accepted: relaying to blunix's own public IP stays allowed (needed
|
||
for relay-to-relay), which exposes its UDP listeners (AdGuard 53,
|
||
tailscaled) to account holders.
|
||
- **2026-09-29, milestone 10 (calls), audio done:** confirmed by the user:
|
||
audio calls work both ways between the web client (dev server on tnix
|
||
against `chat.0ff.space`, as `calltest@0ff.space`) and Monal on the
|
||
iPhone (as `lukas@0ff.space`). Client `65f6bc7`, `9c70628`:
|
||
`src/call/jingle.ts` (SDP ↔ Jingle), `src/call/call.svelte.ts` (JMI,
|
||
Jingle, WebRTC, ICE servers via XEP-0215), `src/ui/CallView.svelte`.
|
||
- Signaling taken from Monal's source (`MLCall.m`,
|
||
`rust/sdp-to-jingle`): propose to the bare JID, UUID id = Jingle sid,
|
||
the rest to the full JID with a store hint; proposer sends
|
||
session-initiate; transport-info repeats ufrag/pwd (else Monal answers
|
||
not-authorized); candidates need `generation`. Monal accepts calls
|
||
without OMEMO unless "allow unverified calls" is off, and rings only
|
||
for contacts with a presence subscription — the client does the same.
|
||
- Checked before the live test: Monal's own converter reads our
|
||
session-initiate, session-accept and transport-info; Chromium answers
|
||
SDP built from Monal's XML (Opus moved first: older Monal builds list
|
||
payload types in hash order). Local E2E with two headless Chromium
|
||
pages and a local Prosody: calls both ways with audio, hang-up,
|
||
decline; two tabs of one account: answer/decline on one stops the
|
||
other, a cancelled call is missed on both.
|
||
- Not tested yet: web → Monal with the iPhone locked; incoming call push
|
||
to the closed web app; the deployed build (still on the dev server).
|
||
- Open: video (the client answers a video call with audio and shows the
|
||
peer's video if sent, but offers none) — implemented, see below.
|
||
- Deployed 2026-09-29 (blunix `c8583d3`); the user confirmed calls on
|
||
`chat.0ff.space`. Locked-iPhone test (Monal switched off, only the
|
||
home-screen app): Prosody delivered two web pushes to Apple (201).
|
||
Opening the app later resumed its session, which replayed the propose
|
||
and the retract; the app rang for a call already ended. Fixed in
|
||
`5ac4d39` (called-off ids never ring; the retract push shows "Missed
|
||
call"; call pushes don't count on the badge), to deploy with blunix
|
||
`26664d9`. A closed web app can only show a notification, not ring.
|
||
- Web push for calls on the iPhone, 2026-09-29: the notifications were
|
||
missing because deleting the home-screen app deletes its notification
|
||
permission and push subscription, and iOS ignores the permission
|
||
request at startup (it needs the tap on "Allow notifications" in
|
||
Settings). Updating needs no reinstall: close and reopen the app
|
||
(`sw.js` fetches the page network-first). Each reinstall leaves a
|
||
stale `push2` registration whose endpoint iOS still delivers; two were
|
||
removed by hand (`prosodyctl shell`, store edit + session destroy +
|
||
`module:reload("push2")`). Then every call showed twice: `mod_push2`
|
||
pushes a JMI propose/retract from the archive hook and again from the
|
||
hibernated session's queue. Patched in blunix `878727d`; the user
|
||
confirmed one notification per call. `sw.js` also closes an older
|
||
notification of the same conversation first (`9341752`).
|
||
- Stale registrations: decided 2026-09-29 against expiry (it would
|
||
silence an app that just wasn't opened); blunix `efc5d93` adds
|
||
`mod_push2_same_device`, which drops older registrations with the same
|
||
SASL2 user-agent device string when a client enables push. Tested
|
||
locally; live check pending. Next: video.
|
||
- Live test with other people, 2026-09-29: web app on the user's iPhone
|
||
to `fanny@0ff.space` (iPhone) worked both ways. To `esther@0ff.space`
|
||
(old Android, web app then home-screen app) the user heard her, she
|
||
never heard the user, from the start — the connection works, so her
|
||
phone didn't play the audio (possibly her phone's settings). Not
|
||
reproduced; `cfeb302` keeps the audio element in the page (some old
|
||
Android browsers stay silent with a detached one) and shows "Tap for
|
||
sound" when `play()` is refused. Retest with Esther when possible.
|
||
- Same test: calls played on the loudspeaker and the app rotated.
|
||
`ed73cb8`: on iOS 17+ `navigator.audioSession.type = "play-and-record"`
|
||
during a call (meant for the earpiece, unverified) with a Speaker
|
||
button (`"auto"`); no button elsewhere (a web page can't pick the
|
||
earpiece on Android). Screen Wake Lock during calls (auto-lock cuts a
|
||
web app's microphone on iOS). Manifest `"orientation": "portrait"`,
|
||
honoured by installed Android apps (after a reinstall), ignored on
|
||
iOS. Awaiting the user's test on the iPhone.
|
||
- **2026-09-29, milestone 10, video implemented (not yet confirmed):**
|
||
`e9adbf9`, `15ea77b`. Video-call button next to the phone button;
|
||
the propose announces video only when a camera opened (Monal aborts a
|
||
call whose Jingle has video the propose didn't announce), else the call
|
||
stays audio. Incoming video calls are answered with the camera (audio
|
||
only without one). Camera on/off (`track.enabled`), front/back switch
|
||
(`replaceTrack`, button only with more than one camera), self-view top
|
||
right, mirrored for the front camera; video calls start on the
|
||
loudspeaker like Monal. Caps now list `urn:xmpp:jingle:apps:rtp:video`.
|
||
- Monal writes H.264 `profile_level_id` twice; the SDP gets it once.
|
||
- Checked locally: Monal's own converter reads our audio+video offer and
|
||
answer (both sendrecv); Chromium answers Monal-style video Jingle
|
||
(from Monal's `parse_sdp`) with audio+video sendrecv. E2E with two
|
||
headless Chromium pages and a local Prosody: video both ways (~100
|
||
frames in 5 s each), camera off, no live tracks after hang-up, audio
|
||
calls still have no video section. Camera switch picks the other
|
||
device and the sender carries it, but headless Chromium's fake cameras
|
||
stop feeding the encoder after `replaceTrack` (a canvas track works),
|
||
so media after a switch is unverified.
|
||
- Not tested: Safari's and real Monal video SDP, switching with real
|
||
cameras, a video call to a device without a camera. Awaiting the live
|
||
test with Monal on the iPhone.
|
||
- Deployed 2026-09-29 (blunix `dccc145`). Confirmed by the user: video
|
||
calls work both ways between the web client (`calltest@0ff.space` in a
|
||
desktop browser) and the home-screen app on the iPhone, and between the
|
||
web client and Monal on the iPhone.
|
||
- **2026-10-04, development moved from tnix to blunix:** checkout
|
||
`~/monal-rebuild` on blunix (`blunix:capabilities/dev/` installs
|
||
nodejs), so the operator can work from the iPhone over the tailnet. The
|
||
tnix clone stays as fallback.
|
||
- **2026-10-04, room invites and conversation deletion implemented:** room
|
||
occupants can ask the room to mediate an invitation; the service handles
|
||
membership for members-only rooms and reports an immediate policy refusal
|
||
in the form. 1:1 conversations can be removed from the list without
|
||
deleting their stored messages or the contact, including while offline. A
|
||
later incoming message recreates the conversation entry. Static checks and
|
||
the production build pass. **Live-confirmed by the operator on
|
||
`chat.0ff.space`:** deleting a 1:1 chat, leaving a room, and inviting a
|
||
second account (`calltest`, entered without `@0ff.space` — the invite
|
||
still worked, so a bare local part resolves fine, worth noting but not
|
||
acted on since nothing broke) into a room all work. This milestone's
|
||
live checks are complete.
|
||
- **2026-10-04, milestone 12 (OMEMO), Phase 0 verify done:** plan approved
|
||
and saved to `OMEMO-PLAN.md`. Namespace confirmed from production
|
||
Prosody's PEP store: Monal publishes legacy
|
||
`eu.siacs.conversations.axolotl` device lists/bundles only, no `omemo:2`
|
||
node — legacy is the required first path. `libomemo.js` 3.0.0 audited
|
||
(delegated via herdr to OpenCode, in a temporary install fully reverted
|
||
afterwards): license confirmed `GPL-3.0` (only transitive dependency
|
||
`protobufjs`, BSD-3-Clause); ships typed ESM+CJS builds plus a real
|
||
worker-offload `./worker-client` export. Bundle cost measured: default
|
||
import +225.9 kB raw/+89.2 kB gzip, worker-client +99.4 kB raw/+25.1 kB
|
||
gzip in the main chunk plus a 165 kB worker asset fetched on demand — a
|
||
genuine trade-off left open for `omemo-worker.ts` time. Storage-interface
|
||
shape read from the library's shipped `.d.ts`: sessions serialize to plain
|
||
strings (not class instances) and its `address` carries no namespace —
|
||
simpler to adapt than this plan first assumed; see `OMEMO-PLAN.md`'s
|
||
"Audit findings" for the corrections. `es-check es2020 --module` (not
|
||
plain script mode) passes with the library present. Licensing-text
|
||
reviewed and applied to `LICENSE` and `README.md`'s license section
|
||
(combined-work GPLv3 note). Phase 0 is fully done.
|
||
- **2026-10-04, milestone 12 (OMEMO), Phase 1 (skeleton) done:** delegated
|
||
via herdr to OpenCode, independently verified by the coordinator. New
|
||
`src/crypto/omemo-db.ts`/`omemo-store.ts`/`omemo.ts` implement identity
|
||
bootstrap only (registration id, identity keypair, 1 signed prekey, 100
|
||
one-time prekeys via `KeyHelper`, idempotent across relogin) — no PEP, no
|
||
send/receive, no UI yet. `test/omemo.test.ts` (4 tests) added
|
||
`fake-indexeddb` as a new devDependency, this project's first Node-side
|
||
IndexedDB test coverage. `npm run check` 0 errors/warnings, `npm test`
|
||
32/32 (28 existing + 4 new, stable across repeated runs), `npm run build`
|
||
succeeds at an unchanged 202.26 kB/65.51 kB gzip (nothing imports
|
||
`src/crypto/` into the app yet). Implementing against the library's real
|
||
`dist/index.d.ts` corrected several of Phase 0's audit assumptions
|
||
(`saveIdentity()`'s return direction, the generic store methods being
|
||
sync-only/unused by the library, two Node-compat deviations from
|
||
`ChatDB`'s exact style) — see `OMEMO-PLAN.md`'s "Phase 1 skeleton
|
||
findings" for the full list.
|
||
- **2026-10-04, milestone 12 (OMEMO), Phase 2 (device lists & bundles)
|
||
done:** delegated via herdr to OpenCode (using its non-interactive `run`
|
||
mode after the interactive TUI proved to crash reproducibly on this
|
||
host — see `docs/agentic-opencode.md` in blunix), independently
|
||
verified by the coordinator. New `src/crypto/omemo-wire.ts` (legacy
|
||
`<bundle>`/`<list>` build/parse, 6 unit tests incl. exact-wire-string
|
||
assertions) plus `publishBundle`/`publishDeviceList`/`fetchDeviceList`/
|
||
`fetchBundle`/`replenishPreKeys`/`syncOmemo` in `omemo.ts`, hooked into
|
||
`session.svelte.ts`'s login handler, fail-soft throughout. `caps.ts`/
|
||
`parse.ts` gained the devicelist feature/namespace. `npm run check` 0
|
||
errors/warnings, `npm test` 38/38 (32 existing + 6 new, stable across
|
||
repeated runs), `npm run build` succeeds at 412.21 kB/151.07 kB gzip
|
||
(libomemo.js's cost now actually paid, since this is the first phase
|
||
that wires it into the app). Noted deviations from the plan (publish-
|
||
options minimalism vs. the bookmark precedent, two simplified function
|
||
signatures, a documented safe import cycle) — see `OMEMO-PLAN.md`'s
|
||
"Phase 2 findings". Still outstanding, unchanged: the live two-scratch-
|
||
account/real-Monal interop test, which needs the operator's credentials.
|
||
- **2026-10-04, milestone 12 (OMEMO), Phase 3 (send-path encryption) done:**
|
||
implemented directly (not delegated — real encryption, not scaffolding).
|
||
Probed the real `libomemo.js` locally first (no network): confirmed
|
||
`SessionCipher.encrypt()`'s output is a binary string, not base64;
|
||
confirmed `registrationId: 0` is accepted for legacy bundles (which never
|
||
publish one); confirmed legacy OMEMO uses **AES-128-GCM** with the tag
|
||
concatenated into the per-device wrapped key, not the plan's originally-
|
||
written AES-256. `session.svelte.ts`'s 1:1 `send()` now encrypts for
|
||
every capable contact, fail-closed on any error. New `encryptFor`/
|
||
`decrypt` in `omemo.ts` (the latter written for test coverage now, not
|
||
yet wired into the receive path — Phase 4's job) round-tripped real
|
||
plaintext through the actual Double Ratchet + AES-GCM locally, two
|
||
simulated devices including a third "own other device," no network
|
||
(`test/omemo.test.ts`). `omemo-wire.ts` gained the `<encrypted>` envelope
|
||
(10 new tests). Found and closed two gaps the plan never addressed:
|
||
private messages to room occupants (a full JID; OMEMO needs a bare one)
|
||
now explicitly skip OMEMO rather than mis-querying or hard-failing; an
|
||
encrypted-capable contact's file attachment now fails closed instead of
|
||
leaking the URL in the clear. `npm run check` 0 errors/warnings, `npm
|
||
test` 44/44 (32 + 6 Phase-2 wire tests + 6 new), `npm run build` succeeds
|
||
at 431.00 kB/155.77 kB gzip. See `OMEMO-PLAN.md`'s "Phase 3 findings" for
|
||
the full detail. Still outstanding, unchanged from the plan's own
|
||
wording: the live two-scratch-account Prosody-MAM-ciphertext acceptance
|
||
test, which needs the operator's server access.
|
||
- **2026-10-04, milestone 12 (OMEMO), Phase 4 (receive-path decryption)
|
||
done:** implemented directly, same reasoning as Phase 3. `Sync.#store()`
|
||
now skips OMEMO for groupchat, checks `ChatDB.hasMessage()` before
|
||
attempting decrypt (never after — a duplicate must never advance a
|
||
sender device's ratchet twice), calls the new never-throwing
|
||
`decryptIncoming` and stores the real plaintext with `encrypted: true`
|
||
on success, or a distinct placeholder with `undecryptable: true` on
|
||
failure — a new `firstEver` flag threaded through `#query`/`#initial`
|
||
tells "sent before this device existed" (expected forward secrecy) apart
|
||
from a generic decrypt failure, exactly as specified. `npm run check` 0
|
||
errors/warnings, `npm test` 44/44 (unchanged — see below), `npm run
|
||
build` succeeds at 432.86 kB/156.37 kB gzip. **Deliberately not
|
||
unit-tested**: `Sync`'s constructor uses TS parameter properties, which
|
||
Node's `--test` can't load — `Sync` has no tests at all for this reason,
|
||
matching the existing untested-network-code convention (`muc.ts`
|
||
bookmarks, Phase 2's PEP calls); the crypto this new code calls is
|
||
already thoroughly tested from Phase 3. Full detail in `OMEMO-PLAN.md`'s
|
||
"Phase 4 findings". Still outstanding: the plan's own two live tests
|
||
(new-device backlog placeholder; same-profile relogin still decrypts)
|
||
plus the Phase 2/3 live tests already pending — all need the operator.
|
||
- **2026-10-04, milestone 12 (OMEMO), Phase 5 done:** found and fixed a
|
||
real bug while designing the lock icon's "key changed" state, before any
|
||
UI was built on it: `isTrustedIdentity()` returned `false` on a changed
|
||
key, making the library throw and *block* further send/receive —
|
||
contradicting this plan's own "warn, never block" trust model. Now
|
||
always returns `true`; `saveIdentity()` alone detects a change and
|
||
persists it as a status (`tofu`/`changed`/`verified`) on a richer
|
||
`TrustRecord`. New `getTrustStatus`/`markVerified`/`fingerprintFor`/
|
||
`keyFingerprintHex` exports in `omemo.ts`, plus five thin UI-facing
|
||
methods on `Session` so the UI never touches `#xmpp`/`OmemoDB` directly.
|
||
The UI itself (lock icon in `Chat.svelte` — none/encrypted/changed, new
|
||
`Fingerprint.svelte` sheet, Settings' Encryption section, device-list
|
||
prefetch on conversation open) was delegated to OpenCode via herdr
|
||
(`opencode run`) and reviewed cold — its own self-report couldn't finish
|
||
because the `opencode-go` provider's usage limit hit right after its
|
||
checkpoint commit landed. `npm run check` 0 errors/warnings, `npm test`
|
||
47/47 unchanged (no Svelte-component test precedent in this project),
|
||
`npm run build` succeeds at 440.55 kB/158.53 kB gzip; the dev server was
|
||
started and curled (HTTP 200) as a minimal runtime check. See
|
||
`OMEMO-PLAN.md`'s "Phase 5 findings" for the full detail. **Not
|
||
verified**: real-browser rendering and the live Monal fingerprint
|
||
comparison — both the operator's job, alongside the Phase 2-4 live tests
|
||
already pending.
|
||
- **2026-10-04, milestone 12 (OMEMO), Phase 6 (deploy) prepared, cutover
|
||
pending:** found a real, previously-unaddressed gap while preparing
|
||
this phase: `chat.0ff.space`'s *current* CSP already blocks
|
||
`libomemo.js`'s WASM entirely, and nothing degraded gracefully from
|
||
that — `send()` would have thrown an uncaught, cryptic error on the
|
||
first message to a capable contact instead of falling back to
|
||
plaintext. Fixed first: `omemoSupported()` (feature-detects WASM via
|
||
the smallest valid module, memoized) gates `cachedDeviceList` — the one
|
||
choke point every OMEMO entry point already goes through — so a
|
||
CSP-blocked browser now behaves exactly as if no contact were ever
|
||
OMEMO-capable; Settings shows a one-line notice. Also found and fixed:
|
||
`package.nix`'s `npmDepsHash` has been stale (Nix build broken) since
|
||
Phase 1 added `libomemo.js`/`fake-indexeddb` via npm directly. `npm run
|
||
check` 0 errors/warnings, `npm test` 48/48 (1 new test, run twice),
|
||
`npm run build` and `nix-build` both succeed. blunix's
|
||
`capabilities/xmpp/chat-client.nix` CSP now carries
|
||
`'wasm-unsafe-eval'`, its `chat-client` flake input bumped to this
|
||
repo's `main`, `docs/intent/omemo-encryption.md` written — all
|
||
committed and pushed, but **`nixos-rebuild switch` was deliberately not
|
||
run**: no cutover until the operator's live interop tests (still
|
||
pending from Phases 2-5) confirm this actually works against real
|
||
Monal. See `OMEMO-PLAN.md`'s updated Phase 6 rollout entry.
|
||
- **2026-10-05, milestone 12 (OMEMO), Phase 5's real-browser gap closed:**
|
||
two disposable LLDAP test accounts (`testcal-a`, `testcal-b`) in two
|
||
Playwright-driven Chromium contexts against `npm run dev`'s proxy to
|
||
production `chat.0ff.space` (no separate staging server exists).
|
||
Confirmed by screenshot: lock icon renders, a message round-trips
|
||
encrypted and decrypts correctly, the `Fingerprint.svelte` sheet lists
|
||
devices with matching fingerprints and a working "Mark verified", and
|
||
Settings' Encryption section shows the own fingerprint. Script at
|
||
`.playwright-cli/omemo-e2e.mjs` (gitignored, not committed). See
|
||
`OMEMO-PLAN.md`'s "Phase 5 findings" for the full write-up and the
|
||
NixOS/Playwright browser-binary setup note. **Still outstanding,
|
||
unchanged**: the live Monal fingerprint comparison and the Phase 2-4
|
||
live interop tests — all the operator's job, not scriptable.
|
||
- **2026-10-07, milestone 12 (OMEMO) live on production, plus two defects
|
||
found by testing it:** the CSP/chat-client deploy was switched (blunix
|
||
generation 542), and the whole send/receive path was then exercised by
|
||
Playwright against the *deployed* build (not the dev proxy), two
|
||
accounts, both directions. Both messages round-tripped and decrypted.
|
||
**Phase 3's acceptance test is now passed**: Prosody's MAM archive on
|
||
disk holds `<encrypted>` stanzas (per-device `<key rid>`, `<iv>`,
|
||
`<payload>`, `<store>` hint) whose `<body>` carries only the fixed
|
||
fallback string, and a `grep` of all of `/var/lib/prosody/` finds no
|
||
trace of either test message's plaintext. The two defects the run
|
||
exposed:
|
||
- `libomemo.js` fetches its WASM core from a `data:` URL, which
|
||
`connect-src` governs — `'wasm-unsafe-eval'` only allows compiling the
|
||
module. The fetch was blocked and the library fell back to an
|
||
undocumented inline-compile path, so encryption worked but rested on
|
||
that fallback. Fixed in blunix's `chat-client.nix` (`data:` added to
|
||
`connect-src`); re-tested against production, console now clean.
|
||
- The lock icon was driven by `omemoDevices`, which lists only devices a
|
||
session was already built with — so the *first* message to a peer was
|
||
encrypted while showing no lock, and the fingerprint sheet behind the
|
||
lock was unreachable. Fixed: presence now follows `omemoCapable()`
|
||
(the peer publishes a device), `changed` still comes from trust rows.
|
||
Verified in a fresh browser context: lock and sheet both present
|
||
before any session exists.
|
||
- **2026-10-07, group invitations: the receiving half was missing and is
|
||
now built.** An invitation arrived as a conversation whose subtitle read
|
||
"Not joined" with no way to act on it — the composer is disabled when a
|
||
room isn't joined, so the only route in was typing the room address into
|
||
"New chat" by hand. The handoff's long-pending "confirm the invitee can
|
||
join" check was therefore never passable through the UI as built.
|
||
`Chat.svelte`'s header now shows a "Join" button while a room is not
|
||
joined (disabled while a join is under way, left enabled on error as a
|
||
retry). End-to-end verified with Playwright: a creates a room and
|
||
invites b, b's list shows the invitation, b joins from the header
|
||
(subtitle goes "Not joined" → "2 participants", button disappears,
|
||
composer enables) and b's message into the room arrives at a.
|
||
- **2026-10-07, OMEMO interop confirmed against real Monal, both
|
||
directions.** Monal fetched the web client's PEP device list and
|
||
encrypted to every device on it (plus its own, for self-sync), with a
|
||
payload-less KeyTransportElement first for session setup — so our publish
|
||
path is validated by a foreign implementation. In the other direction, a
|
||
message typed in Monal ("Hallo wie geht's") decrypted correctly in the
|
||
web client, read from a persistent browser profile whose OMEMO identity
|
||
survives across runs. Monal writes `prekey="1"` where this client writes
|
||
`"true"`; `omemo-wire.ts` already accepts both. Monal also sends a
|
||
`urn:xmpp:eme:0` hint, which this client does not — no interop effect
|
||
observed. Phase 4's new-device backlog placeholder was confirmed on the
|
||
way: a real Monal message from before the profile's device existed
|
||
rendered as "Encrypted message sent before this device was added."
|
||
Still open, and only the operator can do it: the out-of-band fingerprint
|
||
comparison against their Monal device.
|
||
- **2026-10-07, dead OMEMO devices can now be retracted.** See
|
||
`OMEMO-PLAN.md`'s "Dead-device cleanup" section for the full write-up.
|
||
Settings' Encryption section lists the account's own published devices and
|
||
removes the stale ones; verified live, the test account went from 9
|
||
devices to 1 with the orphaned bundle nodes deleted.
|
||
- **2026-10-07, fingerprint display corrected and confirmed against
|
||
Monal.** Two defects, both of which defeated out-of-band comparison: the
|
||
hex renderer included the `0x05` key-type byte (66 characters, 8 groups
|
||
plus a stray 2, every later character a byte out of step), and the
|
||
per-device rows showed a libsignal *safety number* — order-independent by
|
||
design, hence identical under "You" and "Them", and absent from Monal and
|
||
Conversations entirely. Both fixed; the sheet now shows each device's
|
||
identity-key fingerprint in the shared convention. Verified in a browser:
|
||
the value shown for the operator's Monal device matches what Monal
|
||
displays character for character. See `OMEMO-PLAN.md`'s "Fingerprint
|
||
display corrected". **The OMEMO plan is now fully verified end to end,
|
||
including against a foreign client; nothing on it is outstanding.**
|
||
- **2026-10-07, milestone 11 (pinix) removed from this project.** The
|
||
operator's decision: replacing `pinix:xmpp/web.nix`'s xmpp-web happens in
|
||
a separate project, and only once `chat.0ff.space` has proven itself in
|
||
daily use. The milestone number stays vacant so every "milestone 12"
|
||
above still means OMEMO. With it gone, no milestone of this plan is
|
||
outstanding.
|
||
- **2026-10-08, encrypted attachments: the compatibility format is
|
||
decided, nothing is implemented yet.** `ux-implementation-plan.md`'s
|
||
package 9 blocked on this. Read from Monal's `develop` source: the only
|
||
representation Monal recognises is XEP-0454's `aesgcm://` URL as the sole
|
||
content of the decrypted body, with no `jabber:x:oob` child and no
|
||
thumbnail line; it has no XEP-0447 support, so SFS is rejected. Verified
|
||
live on blunix that `upload.0ff.space`'s file route already answers with
|
||
`access-control-allow-origin: https://chat.0ff.space`, and that the chat
|
||
CSP already permits that fetch and `blob:` media — so no server change is
|
||
needed. Full write-up, including the three receive fragment layouts, the
|
||
10 MiB limit's 16-byte tag overhead, the federated-host limitation and
|
||
the failure matrix: `OMEMO-PLAN.md`, "Encrypted attachments: format
|
||
decided".
|
||
- **2026-10-08, encrypted attachments are implemented; acceptance is the
|
||
remaining half.** Image, file and voice messages to an OMEMO-capable
|
||
contact are now encrypted with AES-256-GCM before the upload and shared
|
||
as an `aesgcm://` URL inside the envelope, so the composer no longer
|
||
refuses them. New `src/crypto/aesgcm.ts` plus changes in `sendFile`,
|
||
`mediaCapability`, `media.ts`, `db.ts` and `Chat.svelte`; `npm run check`,
|
||
86 tests and `npm run build` pass. Two defects were found on the way — the
|
||
WHATWG URL scheme swap silently failing, and a cleartext key leak if a
|
||
peer's capability changed mid-upload — both closed and written up in
|
||
`OMEMO-PLAN.md`, "Encrypted attachments implemented". Still open: a live
|
||
browser run, the round trip against real Monal, iPhone recording and
|
||
playback, and the release itself.
|
||
- **2026-10-08, encrypted attachments are live as generation 557.** Client
|
||
`031766c`, pinned by blunix `d23cb82`. The deployed bundle is byte-identical
|
||
to the local build, and the new `ux-media-runner.cjs` passes all ten
|
||
encrypted-attachment checks against it with zero page errors: no `oob`
|
||
child and no cleartext `aesgcm` on the wire, independent decryption in a
|
||
second profile, uploads exactly plaintext + 16 bytes, the group path
|
||
untouched, and distinct messages for a 404 and an integrity failure. See
|
||
`UX-MEDIA-RESULTS.md`. What remains is device-bound: the real Monal round
|
||
trip and iPhone recording/playback.
|