Chat encryption design โ Olm / Megolm via matrix-sdk-crypto-wasm โ
Status: Phases 1โ3 + 5 implemented 2026-05-30 on the new library (device identity, 1:1 sessions + encrypt/decrypt, backup + restore, edge-case tests) on the Megolm-for-everything model (see ยง5.1, decision log 2026-05-30). This completes the v1 scope. Phase 4 (bonfire foundation: Megolm group sessions + sessionEpoch rotation) is deferred until bonfires ship. Tracking: Privacy hardening master tracker #523. Action plan row: P3 / HIGH / "Encrypt chat messages client-side." Companion docs:PRIVACY_HARDENING_ROADMAP.md (strategic), SEALED_IDENTITY.md (sealed-identity architecture, prerequisite context), ../superpowers/plans/2026-05-11-privacy-hardening-action-plan.md (live action tracker). Author: privacy workstream. Opened: 2026-05-26. Re-baselined: 2026-05-30 (library pivot โ see ยง3).
1. Why this doc exists โ
The 2026-05-11 audit found that messageService.sendMessage() writes plaintext message bodies to Firestore today. The roadmap ยง3 had claimed client-side encryption; that claim was corrected to reality in P1.
This doc lays out the architecture for actually delivering that encryption. The model is Olm + Megolm โ the protocol family pioneered by Signal (Double Ratchet) and adapted by Matrix for group messaging โ accessed through the audited @matrix-org/matrix-sdk-crypto-wasm library. Rationale: it survives the addition of bonfires (group chats) without re-architecture, provides forward secrecy + post-compromise security, and โ critically โ ships as an audited implementation we are not maintaining ourselves.
This is a design doc, not an implementation guide. It records decisions, schema, and phasing so the implementation work has a single reference point.
Naming note. This file was previously
SIGNAL_PROTOCOL.md. The protocol primitives are the same lineage (Double Ratchet), but we are not using libsignal โ so the Signal name was misleading and the file was renamed. "Olm" is the Matrix project's Double-Ratchet implementation, named after the olm, a blind cave salamander. "Megolm" is "Olm, but bigger" โ the group-messaging variant. The Rust core both are built on is vodozemac.
2. Threat model โ
What we're protecting:
| Threat | Without encryption (today) | With this design |
|---|---|---|
| Firestore breach / compromise | Reads all message content | Reads opaque ciphertext only |
| Subpoena targeting Lantern | We can produce message content | We cannot โ keys live only on user devices |
| Insider threat (Lantern staff, Firebase support staff) | Visible | Sealed against PASSIVE access (ciphertext only). An ACTIVE key-injection attack (adding a ghost device to a peer's directory) is now detectable via safety-number verification + new-device alerts (H-E2EE-1), not silent; under warn-but-allow we surface it rather than block. |
| Long-term log retention exposing old conversations | All readable | Forward secrecy: keys ratchet per message, old ciphertext stays sealed even if current keys leak |
| Single-message key compromise | Affects all messages | Affects only that message (Double Ratchet ratchets forward) |
| Group member is removed but has saved old messages | Sees future messages too | Megolm session rotated on membership change โ server-driven (see ยง5.3) |
What remains visible after this design:
| Surface | Visibility |
|---|---|
| Conversation existence (Alice and Bob have a connection) | Yes โ connections doc with participants array |
| Message timestamps | Yes |
| Message volume / frequency | Yes |
| Sender pseudonyms (UIDs) within a conversation | Yes |
| Bonfire membership | Yes โ participants array |
| Message content | No โ sealed by Olm/Megolm |
The sealed-identity work covers the identity layer (no PII pinned to UIDs). This design covers the content layer.
3. Library choice โ
Decision (2026-05-30): @matrix-org/matrix-sdk-crypto-wasm (the OlmMachine), Apache-2.0, v18.x.
What changed and why โ
The original design (2026-05-26) chose @privacyresearch/libsignal-protocol-typescript. During implementation we discovered that package is GPL-3.0, which is incompatible with a closed-source SaaS PWA. That forced a re-evaluation.
The two real browser-capable options that survived:
ts-mls(MIT, npm) โ a modern RFC 9420 MLS implementation. Better protocol on paper: member removal is protocol-enforced (a cryptographic "External Remove" proposal locks the removed member out in seconds). But the author explicitly states it is unaudited. For a privacy product, shipping unaudited crypto is the one risk we won't take โ crypto bugs are silent.@matrix-org/matrix-sdk-crypto-wasm(Apache-2.0, npm) โ theOlmMachine, built on vodozemac, a Rust reimplementation of Olm/Megolm. Audited (Least Authority, 2022) and battle-tested at Element/Matrix scale. Older protocol with one notable rough edge (member removal, see below), but proven.
We chose the audited library. A proven implementation of a slightly-older protocol beats an unproven implementation of a better one, when the failure mode is "silently leaks plaintext."
The Megolm tradeoff we are accepting (and mitigating) โ
In Megolm (group messaging), all members share an outbound group session. To stop a removed/banned member from reading future messages, the surviving members must discard that session and start a fresh one ("rotation"). In Megolm this rotation is client-policy-enforced, not protocol-enforced โ historically a source of bugs (Element shipped leaks where rotation didn't fire, with worst-case windows of ~1 week / ~100 messages).
Mitigation (load-bearing): server-driven rotation. We do not trust the client to remember to rotate. A Cloud Run service owns membership and, on any membership change (add/remove/ban), invalidates the current bonfire group session so the next message forces a fresh one. See ยง5.3. This is the single most important invariant in this design โ any code touching bonfire membership must go through it.
Cost accepted โ
WASM bundle is ~1.5โ3 MB (lazy-loaded, not on the critical path). More key material to ship and store than libsignal. Both acceptable.
Future improvement โ
Revisit MLS (ts-mls or a future audited MLS lib) when an audited browser MLS implementation exists. Migration is a re-key event, not a rewrite of the app surface, because our message-envelope and transport seams (ยง6) are protocol-agnostic.
4. How the library is shaped (and why our integration looks unusual) โ
matrix-sdk-crypto-wasm is extracted from a Matrix client. Its mental model is the Matrix client-server API, not a generic crypto library. That has two consequences for us:
The
OlmMachineowns its own storage. You hand it a store name + passphrase and it persists all key material in its own encrypted IndexedDB database. We do not hand-roll key persistence anymore (the oldstore.jsis gone). The passphrase we feed it is derived from the same per-account entropy that backs profile encryption (getLastDerivedEntropy()inencryption.js), so the OlmMachine's store is encrypted at rest under the same PIN gate as everything else.It communicates via "outgoing requests," not direct calls. Instead of
machine.publishKeys(), you callmachine.outgoingRequests()and get back a list of request objects (KeysUploadRequest,KeysQueryRequest,KeysClaimRequest, โฆ) shaped like Matrix HTTP requests. You're expected to send each to a homeserver and feed the response back viamachine.markRequestAsSent(id, type, responseJson).We have no homeserver. So our
firestoreTransport.jsis a shim that pretends to be one: it drainsoutgoingRequests(), fulfills each against Firestore (aKeysUploadRequestbecomes a Firestore write touserKeys/{uid}/devices/{deviceId}), synthesizes the JSON response a Matrix homeserver would have returned, and callsmarkRequestAsSent. This is the canonical pattern for embedding the crypto machine outside a Matrix server.
OlmMachine โโoutgoingRequests()โโโถ firestoreTransport
โ (KeysUpload โ write userKeys/.../devices/{deviceId})
โ (synthesize homeserver-shaped response)
OlmMachine โโโmarkRequestAsSent()โโโโโโโ5. Architecture overview โ
5.1 Olm โ the key-distribution layer (Double Ratchet) โ
Purpose: establish a pairwise encrypted channel between two devices, used as the secure courier that hand-delivers room keys (ยง5.2) to each recipient device. Olm itself gives that courier channel forward secrecy + post-compromise security.
Mechanism (handled by the OlmMachine, we don't implement it): each device publishes long-lived device keys (a Curve25519 identity key + an Ed25519 signing key) plus a pool of one-time keys and a fallback key. To start talking to Bob, Alice "claims" one of Bob's one-time keys, the machine runs the handshake, and a Double-Ratchet Olm session is born. This is the Olm analogue of Signal's X3DH.
Lantern mapping: Firestore is the key directory. userKeys/{uid}/devices/{deviceId} holds the public device keys; one-time keys are published and claimed there too.
Megolm for everything โ decided 2026-05-30 (see decision log).
matrix-sdk-crypto-wasm's high-level API only encrypts stored conversation messages with Megolm (encryptRoomEvent/decryptRoomEvent); Olm is exclusively the to-device transport for distributing Megolm room keys (shareRoomKeyโToDeviceRequest), never the cipher for stored content. So a 1:1 pair is modeled as a 2-member Megolm "room", identical machinery to a bonfire. We do not hand-roll raw-Olm message storage โ that would mean leaving the audited high-level API, which defeats the library choice (ยง3). The forward-secrecy gap between Megolm and a pure Olm Double Ratchet on 1:1 is closed by session rotation (rotationPeriodMessages/rotationPeriodonEncryptionSettings), exactly as Matrix ships 1:1 E2EE in production.
5.2 Megolm โ the bonfire layer (group ratchet) โ
Purpose: group messaging without O(Nยฒ) pairwise encryption per message.
Mechanism: the sender creates an outbound group session and distributes its key to each member over the pairwise Olm channels (ยง5.1). Group messages are then encrypted once with the group session, which ratchets forward per message.
Lantern mapping: bonfires. The pairwise Olm sessions are the substrate that distributes Megolm keys โ so a 1:1 chat and a bonfire between the same people share the same underlying Olm session. "We vibed in the bonfire, let's DM" is free.
5.3 Server-driven session rotation (the ban invariant) โ
Because Megolm member-removal is client-policy-enforced (ยง3), Lantern adds a server authority:
- Bonfire membership lives in
connections/{bonfireId}.participants, mutated only by a Cloud Run endpoint (not by clients directly). - On any add/remove/ban, that service bumps a
sessionEpochcounter on the bonfire doc. - Clients treat a higher
sessionEpochthan their current outbound group session as a hard signal to discard and re-create the group session before sending again. - A removed member is dropped from
participantsand loses read access via Firestore rules, so they cannot receive the new session key.
This converts Megolm's "hope the client rotates" into "the server forces a rotation epoch." Worst-case leak window collapses from ~days to "messages already sent before removal," which is the irreducible minimum (you can't unsend).
Cloud Run, not Cloud Functions โ per project preference, this authority is a Cloud Run service alongside the other APIs, not a Firebase Cloud Function.
6. Data model โ
6.1 Firestore โ device-key directory โ
The OlmMachine is device-centric (a user may eventually have several devices). v1 is single-device, but the schema is device-keyed from day one so multi-device is a data migration, not a redesign.
userKeys/{userId}
primaryDeviceId: <string> # which device is active (v1: only one)
updatedAt: <timestamp>
devices/{deviceId} # subcollection, one per device
deviceKeys: <json> # OlmMachine KeysUpload body: device_keys
oneTimeKeys: <json> # published one-time keys (claimed + deleted per handshake)
fallbackKey: <json> # last-resort key if OTKs exhausted
algorithms: [<string>, ...]
updatedAt: <timestamp>The deviceKeys / oneTimeKeys / fallbackKey values are written verbatim from the KeysUploadRequest.body the OlmMachine produces โ we do not reshape them, so we never have to re-implement Matrix's signing/canonicalization.
Security rules: any authenticated user may read any userKeys/{uid} (these are public keys by design). Only the owner may write their own userKeys/{uid}/**. One-time keys are deleted by the claimer during a handshake.
Replenishment: the OlmMachine itself decides when one-time keys run low. We feed it the current published count via receiveSyncChanges(..., oneTimeKeysCounts, ...); if low, it emits a fresh KeysUploadRequest on the next outgoingRequests() drain, which our transport publishes.
6.2 Firestore โ message envelope โ
The existing connections/{cid}/messages/* schema changes from:
messages/{msgId}
text: "hello world" # PLAINTEXT โ what we're removing
senderId: <uid>
createdAt: <timestamp>to:
messages/{msgId}
ciphertext: <json> # opaque encrypted room event (encryptRoomEvent output)
algorithm: 'm.megolm.v1' # always Megolm โ pairs are 2-member rooms (ยง5.1)
senderUid: <uid> # present in both 1:1 and bonfire
senderDeviceId: <string> # needed to select the right session
conversationType: 'pair' | 'bonfire'
sessionEpoch: <number> # bonfire only; ties msg to a Megolm session generation
createdAt: <timestamp>Why senderUid/senderDeviceId in 1:1 too: keeps the envelope identical between 1:1 and bonfire, so one query/render/cascade path serves both.
ciphertext is the full encryptRoomEvent output (a JSON-encoded m.room.encrypted event, stored as a string/map), not a bare base64 blob โ decryptRoomEvent consumes it verbatim. algorithm is always m.megolm.v1 in this model; the field stays in the envelope as a forward-compat discriminator (e.g. a future MLS migration) rather than a per-message branch.
6.3 Firestore โ connection / bonfire doc โ
connections/{cid}
type: 'pair' | 'bonfire'
participants: [<uid>, ...] # 2 for pair, 2+ for bonfire
sessionEpoch: <number> # bonfire only; bumped by Cloud Run on membership change (ยง5.3)
createdAt: <timestamp>
lastActivityAt: <timestamp> # for 30-day TTL
...existing fields...participants and sessionEpoch for bonfires are mutated only by the Cloud Run membership service, never client-side.
6.4 Local storage โ the OlmMachine's own store โ
We no longer hand-roll an IndexedDB schema for key material. The OlmMachine persists everything (identity, sessions, group sessions, tracked devices) in its own encrypted IndexedDB database:
store name: `lantern-olm-{userId}` # one DB per account
store passphrase: derived from getLastDerivedEntropy() # same PIN gate as profile encryptionThe only thing we persist outside the machine is a stable device id (so the same machine store is reused across sessions), kept in localStorage keyed by uid. It is a random opaque id, not PII.
6.5 Firestore โ encrypted backup (recovery) โ
secureBackup/{userId} # owner-only access
encryptedBundle: <base64> # PIN-encrypted export of the OlmMachine store
recoveryKeyCheck: <base64> # lets a recovery-phrase unlock verify before decrypting
updatedAt: <timestamp>
version: <number>See ยง8 for the recovery model.
6.6 Firestore โ to-device mailbox โ
Megolm room keys are delivered device-to-device over Olm. With no homeserver, Firestore is the mailbox: when a sender's ToDeviceRequest targets a recipient device, the transport writes one doc per recipient into:
toDevice/{recipientUid}/messages/{autoId}
senderUid: <uid> # becomes the to-device event `sender`
eventType: <string> # e.g. 'm.room.encrypted'
content: <json> # the per-device payload from ToDeviceRequest.body.messages
createdAt: <timestamp>The recipient drains its mailbox, reshapes each doc into a Matrix to-device event { sender, type, content }, feeds the array to receiveSyncChanges(...), then deletes the consumed docs (one-shot delivery). Owner-only read; any authenticated user may create a doc in another user's mailbox (you must be able to deliver a key to someone you're messaging), but only the owner may read/delete.
7. Flows โ
7.1 Account setup (post-PIN) โ
- Derive/persist a stable
deviceIdfor this account. OlmMachine.initialize(userId, deviceId, "lantern-olm-{uid}", passphrase)โ passphrase from entropy.- Drain
outgoingRequests(); the transport publishes theKeysUploadRequest(device keys + one-time keys + fallback key) touserKeys/{uid}/devices/{deviceId}. - Write the initial
secureBackup/{uid}blob.
7.2 First message Alice โ Bob (pair = 2-member Megolm room) โ
The pair's connectionId maps to a synthetic RoomId (!{connectionId}:lantern.local). Alice's send path:
- Track + discover devices.
updateTrackedUsers([bob]), then drainoutgoingRequests(); the transport fulfills the emittedKeysQueryRequestfromuserKeys/{bob}/devices/*so the machine learns Bob's device keys. - Establish Olm sessions.
getMissingSessions([bob])โKeysClaimRequestโ the transport claims (and deletes) a one-time key from Bob's device doc โmarkRequestAsSent. The machine now has a pairwise Olm session with Bob's device. - Share the room key.
shareRoomKey(roomId, [bob], settings)โToDeviceRequest[]; the transport writes each to-device message into Bob's mailboxtoDevice/{bob}/messages/*(ยง6.6), then acks. - Encrypt + store.
encryptRoomEvent(roomId, 'm.room.message', json)โ Alice writes{ ciphertext, algorithm:'m.megolm.v1', senderUid, senderDeviceId, conversationType:'pair' }toconnections/{cid}/messages/*.
Bob's receive path: drain his toDevice/{bob}/messages/* mailbox โ feed them to receiveSyncChanges(...) (imports the inbound Megolm session) โ delete the consumed mailbox docs โ decryptRoomEvent(ciphertext, roomId, settings).
7.3 Subsequent 1:1 messages โ
After steps 1โ3 run once, they become no-ops (the machine reports no missing sessions and won't re-share the room key) until device membership changes or the session hits its rotation threshold. Steady state is just encryptRoomEvent on send / decryptRoomEvent on receive; the Megolm session ratchets forward per message.
7.4 Bonfire creation โ
- Creator's machine creates a Megolm outbound group session for
bonfireId. - The session key is distributed to each member over the pairwise Olm sessions (claiming/establishing them as needed).
connections/{bonfireId}created withtype:'bonfire',participants,sessionEpoch: 0.- Messages encrypted with the group session, tagged with
algorithm:'m.megolm.v1'and the currentsessionEpoch.
7.5 Adding a member โ
- Cloud Run adds the uid to
participantsand bumpssessionEpoch(ยง5.3). - Clients observe the new epoch, rotate to a fresh group session, distribute its key to all members including the new one.
- New member decrypts subsequent messages only โ not history (Megolm doesn't share past ratchet state forward).
7.6 Removing / banning a member โ
- Cloud Run removes the uid from
participants, revoking their read access via rules, and bumpssessionEpoch. - Surviving clients rotate to a fresh group session and distribute the new key to remaining members only.
- The removed member can still read messages they already received (irreducible โ can't unsend), but nothing sent after the epoch bump.
7.7 New-device / reinstall sign-in โ
See ยง8 โ restore the OlmMachine store from secureBackup/{uid}.
7.8 Account deletion (cascade) โ
Sprint D.1's cascade already deletes users/{uid}, connections/{cid}/messages/*, Storage, Auth, and pseudonymizes BQ events. Add:
userKeys/{uid}and itsdevices/**subcollection (so others can't initiate new sessions against a deleted user).secureBackup/{uid}.- For each bonfire they're in: Cloud Run removes them from
participantsand bumpssessionEpoch.
8. Backup & recovery โ Option 2 + Option 3 โ
Decision (2026-05-30): single-device v1 with two recovery factors:
- Option 2 โ PIN-encrypted backup. The OlmMachine store is exported, encrypted with the PIN-derived key, and written to
secureBackup/{uid}(debounced after session changes). On a fresh install, signing in with phone + PIN re-derives the key, pulls the backup, decrypts, and re-imports the store. All chats restored. - Option 3 โ recovery phrase as a secondary factor. The existing recovery-phrase machinery (
unlockEncryptionWithRecoveryPhrase,hashRecoveryPhraseinencryption.js) gates a second path to the same backup, so a user who has their phrase but not their PIN (or vice-versa, depending on flow) can still recover.recoveryKeyChecklets the client verify a phrase before attempting decryption.
Threat-model fit: Lantern is zero-knowledge โ losing your key already means losing your account. There is no realistic attacker who captures the encrypted backup and the PIN/phrase but isn't already the user. So backing up full session state (rather than identity-only) costs no meaningful forward secrecy, and buys "your history survives a lost phone." That's the trade we explicitly chose.
Rejected: identity-only backup (history lost on device switch). The protected-against attacker isn't real for us, and the UX cost is large.
Caveat โ changing your PIN does NOT re-key the backup. A PIN change re-wraps the seed but keeps the same entropy-derived data key (reWrapSeed in encryption.js), so secureBackup/{uid} stays encrypted under the same key. Changing your PIN is therefore not a post-compromise remediation for chat history: anyone who already captured the old backup blob and derived the old key keeps the ability to decrypt it. Consistent with the zero-knowledge model above (that attacker isn't in our threat model), but worth stating so "change PIN" isn't mistaken for key rotation.
9. Migration plan โ
9.1 Existing plaintext messages โ
Decision: leave them. They expire via Sprint B.1's 30-day TTL. After ~30 days post-launch, all messages are encrypted. (Not production yet, so there is no real history to protect.)
9.2 Mixed-mode rendering โ
During the ~30-day overlap:
if (msg.ciphertext) {
return <Bubble text={await decryptMessage(msg)} />
} else if (msg.text) {
return <Bubble text={msg.text} /> // legacy plaintext
} else {
return <Bubble text="(message unavailable)" />
}9.3 First-time setup for existing users โ
On next sign-in: if userKeys/{uid} is missing, bootstrap the machine, publish device keys, write the initial backup. No conversation impact โ they just become "ready to receive encrypted messages."
10. Phasing โ
| Phase | Effort | Scope |
|---|---|---|
| Phase 1: Device-identity layer | ~2 days | OlmMachine bootstrap, store passphrase derivation, stable deviceId, Firestore transport for KeysUpload, key publishing + replenishment, Firestore rules |
| Phase 2: Olm sessions | ~3 days | KeysClaim/KeysQuery transport, session establishment, 1:1 encrypt/decrypt wired into messageService |
| Phase 3: Backup + restore | ~2 days | PIN-encrypted store export to secureBackup, restore on fresh sign-in, recovery-phrase second factor |
| Phase 4: Bonfire foundation (deferred until bonfires ship) | ~3 days | Megolm group sessions, key distribution over Olm, Cloud Run membership service + sessionEpoch rotation (ยง5.3) |
| Phase 5: Tests + edge cases | ~2โ3 days | Reinstall/restore, OTK exhaustion, out-of-order, epoch rotation, removed-member-can't-read |
v1 scope: Phases 1โ3 + 5. Bonfire foundation (Phase 4) lands with the bonfires feature, but its schema seams (type, sessionEpoch, message envelope) are present from Phase 1.
11. Open questions / decisions to revisit โ
| Question | Default for v1 | When to revisit |
|---|---|---|
| MLS instead of Olm/Megolm | Olm/Megolm (audited) | When an audited browser MLS lib exists; migration is a re-key, not a rewrite |
| Multi-device (concurrent) | Single active device; restore-from-backup only | If product wants concurrent multi-device โ schema is already device-keyed |
| Cross-signing / device verification | Manual verify UI removed (2026-06-17, see ยง12): disproportionate for a meet-in-person, single-device app. E2EE + the silent deviceTrust change-detection stay; the safety-number / verified-state code remains but is no longer surfaced. Cross-signing was never built (it is multi-device machinery, and v1 is single-device). | Account-level device management (prune stale devices on phone switch) + an optional soft change-notice if multi-device lands; cross-signing only if concurrent multi-device ships |
| Disappearing messages | 30-day TTL only | If product wants per-conversation timer |
| Sealed Sender (hide sender UID from server) | Not implemented | When senderUid visibility becomes a concern |
| Read receipts / typing | Out of scope (typing leaks metadata) | If implemented, design as content-blind signals |
12. Decision log (append, don't overwrite) โ
2026-05-26 โ design conversation. Library = libsignal TS port. Foundation for bonfires from day one. Existing plaintext = leave to expire. Backup = restore-everything under PIN. Design doc first.
2026-05-26 โ Phase 1 (identity layer) implemented against
@privacyresearch/libsignal-protocol-typescript.2026-05-30 โ Library pivot. Discovered the libsignal TS port is GPL-3.0 (incompatible with closed-source SaaS). Evaluated
ts-mls(MIT, modern MLS, unaudited) vs@matrix-org/matrix-sdk-crypto-wasm(Apache-2.0, Olm/Megolm via audited vodozemac). Chose matrix-sdk-crypto-wasm โ audited beats newer-but-unproven for a privacy product. Accepted the Megolm client-enforced-removal tradeoff, mitigated by server-drivensessionEpochrotation (ยง5.3). Recovery model finalized as Option 2 + Option 3 (PIN-encrypted backup + recovery phrase), single-device v1. Doc renamedSIGNAL_PROTOCOL.mdโCHAT_ENCRYPTION.md.2026-05-30 โ Megolm for everything (1:1 included). Confirmed against the bundled
.d.tsthatmatrix-sdk-crypto-wasm's high-level API encrypts stored messages only with Megolm (encryptRoomEvent/decryptRoomEvent); Olm is exclusively the to-device transport for room-key distribution (shareRoomKeyโToDeviceRequest). This contradicted the original ยง6.2 plan ofm.olm.v1for pairs. Considered hand-rolling raw-Olm message storage to keep stronger 1:1 break-in recovery, but that leaves the audited high-level API and means owning session persistence โ rejected (defeats ยง3 library choice). Decision: model a 1:1 pair as a 2-member Megolm room, identical machinery to bonfires; close the forward-secrecy gap with Megolm session rotation. Updated ยง5.1, ยง6.2, ยง7.2โ7.3; added to-device mailbox ยง6.6. Envelopealgorithmis now alwaysm.megolm.v1.2026-05-30 โ Phase 1 reimplemented on the new library. Modules:
apps/web/src/lib/signal/โolmMachine.js(machine lifecycle + store passphrase + deviceId),firestoreTransport.js(outgoing-request โ Firestore shim),schema.js(Firestore device-key shapes),keyManager.js(initializeSignalKeys,maintainPrekeys),index.js. Oldstore.jsdeleted (OlmMachine owns persistence). Wired intoApp.jsx, fire-and-forget after encryption restore. Firestore rules updated foruserKeys/{uid}/devices/**. Operator note: runnpm installinapps/webafter pulling โ new dep@matrix-org/matrix-sdk-crypto-wasm.2026-05-30 โ Phase 2 implemented (sessions + 1:1 encrypt/decrypt). Added
roomCrypto.js(ensureSession,encryptMessage,decryptMessage,drainToDeviceMailboxโ the Megolm-room orchestration over the to-device transport).getMissingSessionsreturns a one-offKeysClaimthat does not flow throughoutgoingRequests(), sofirestoreTransport.jswas refactored to exposefulfillRequest(handle, uid, request)(callable directly for the claim) withfulfillKeysQuery/fulfillKeysClaim/fulfillToDevice+ mailbox read/clear. OTK claim is served by a new Cloud Run endpointPOST /auth/key-directory/claim-otk(services/api/auth/src/routes/keyDirectory.js) doing an atomic transactional read-and-delete of the one-time key.messageService.sendMessagenow stores anencenvelope ({algorithm:'m.megolm.v1', ciphertext}) andformatMessageDoc/getMessagesdecrypt transparently, plaintext fallback on failure. To-device mailboxtoDevice/{uid}/messagesadded to Firestore rules (owner read/delete, sender-only create).2026-05-30 โ Phase 5 implemented (tests + edge cases), completing the v1 scope. Added unit suites for the edge cases that don't need a live homeserver:
olmMachine.test.js(synthetic-id round-trip + foreign/malformed-id rejection โ the security boundary that decides whose key docs a claim can touch โ plus per-account device-id stability across reloads, the reinstall invariant),keyManager.test.js(thebootstrappedflag that gates restore-on-fresh-device, and OTK-pool replenishment feeding the server-side count intoreceiveSyncChanges), and extendedmessageService.crypto.test.jswith the out-of-order path: a ciphertext that arrives before its Megolm room key renders the[unable to decrypt message]placeholder and never blanks its siblings. The atomic OTK claim+delete transaction and full WASM crypto round-trips remain integration-tested against dev (they need a live Firestore / the realOlmMachine), consistent with the repo's route-test convention (firebaseUidFromSyntheticunit-tested, transaction integration-tested). Phase 4 edge cases (epoch rotation, removed-member-can't-read) are deferred with the bonfire feature. Full web suite: 699 passing.2026-05-30 โ Phase 3 implemented (backup + restore). Added
backup.js:exportEncryptedBackup(machine.exportRoomKeysโencryptDataunder the PIN-derived key โsecureBackup/{uid}with arecoveryKeyCheckcanary +version),restoreFromBackup(verify canary before importing, thenimportRoomKeys), and a 5s-debouncedscheduleBackup. Only room keys are backed up โ device identity is regenerated and republished on a fresh device, never exported.roomCryptocallsscheduleBackupafter key material changes (post-shareRoomKey, post-mailbox-drain). Restore is wired intoApp.jsxafterinitializeSignalKeys, gated onbootstrapped(only on a freshly bootstrapped machine). Backup is skipped when the account is locked (no encryption key). Recovery phrase (Option 3) re-derives the same PIN seed, so it transparently unlocks the same backup โ no separate backup blob.2026-06-15 - H-E2EE-1 device verification implemented. Blind trust-on-first-use is replaced with a user-facing trust layer. New
deviceTrust.js: a comparable SAFETY NUMBER derived from both parties' Ed25519 identity keys (computeSafetyNumber), a per-(local,peer) VERIFIED-state store, and per-peer device-set CHANGE DETECTION with a pub/sub the chat UI subscribes to.firestoreTransport.fulfillKeysQueryrecords the peer device set after each directory read;roomCrypto.getConnectionSafetyInfoexposes the number + state.Chat.jsxadds a verify modal + a "safety number changed" warning banner (warn-but-allow: a changed/unverified device is surfaced, never silently blocked, mirroring Signal's default).firestore.rulesmakes a device'sdeviceKeysidentity WRITE-ONCE (blocks a stolen-token identity swap; OTK/fallback still update; adding a new device id is still allowed and is what verification covers). Tests: deviceTrust 16, rules +3 (118 total), a real-WASM roundtrip ghost-device case. Cross-signing remains out of scope; a fail-closed "block on unverified device" mode is the documented next step if the threat model tightens.2026-06-17 - Manual verification UI removed from chat (product decision). The safety-number "verify this contact" screen + the verified-state badge/banner were pulled from
Chat.jsx. Rationale: it is disproportionate for a meet-in-person app. A manual safety number only adds protection at FIRST contact (the automatic change-detection already covers later device changes), and Lantern resolves first-contact identity in person, where the two people ARE the out-of-band channel the number was invented to be. It was also a poor fit for v1's single-device model: the only multi-device pain (mismatched numbers, device pile-up) surfaced in testing because incognito sessions mint throwaway devices that are never pruned (see the device-accumulation gap below). What STAYS: E2EE itself, thefirestore.ruleshardening (write-once device key, server-onlyuserKeysdelete, server-routed to-device), and the silentdeviceTrustchange-DETECTION (recordPeerDevicesstill records + warns todevLog). ThedeviceTrust/roomCryptosafety-number + verified-state functions remain in the tree but are no longer surfaced (unused by UI; full removal can follow). NEXT (the real fix the operator asked for): account-level device management, i.e. a "your devices" view + prune-stale-devices on a phone switch (userKeys/{uid}/devices/*accumulates today because nothing deletes the old device doc on reinstall). That is also where any re-surfaced "their device changed" notice belongs, at the user level rather than inside the chat. Cross-signing stays deferred (multi-device machinery; v1 is single-device).
13. How to use this document โ
Source of truth for the chat-encryption work. When implementation starts:
- Open a PR per phase; cite the phase number.
- When a decision changes, update ยง11 and append to ยง12 โ never silently rewrite the body.
- Schema changes go to ยง6 first, then implementation.
- New threat-model entries go to ยง2 โ the table grows as we find new edges.