Skip to content

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:

ThreatWithout encryption (today)With this design
Firestore breach / compromiseReads all message contentReads opaque ciphertext only
Subpoena targeting LanternWe can produce message contentWe cannot โ€” keys live only on user devices
Insider threat (Lantern staff, Firebase support staff)VisibleSealed 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 conversationsAll readableForward secrecy: keys ratchet per message, old ciphertext stays sealed even if current keys leak
Single-message key compromiseAffects all messagesAffects only that message (Double Ratchet ratchets forward)
Group member is removed but has saved old messagesSees future messages tooMegolm session rotated on membership change โ€” server-driven (see ยง5.3)

What remains visible after this design:

SurfaceVisibility
Conversation existence (Alice and Bob have a connection)Yes โ€” connections doc with participants array
Message timestampsYes
Message volume / frequencyYes
Sender pseudonyms (UIDs) within a conversationYes
Bonfire membershipYes โ€” participants array
Message contentNo โ€” 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) โ€” the OlmMachine, 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:

  1. The OlmMachine owns 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 old store.js is gone). The passphrase we feed it is derived from the same per-account entropy that backs profile encryption (getLastDerivedEntropy() in encryption.js), so the OlmMachine's store is encrypted at rest under the same PIN gate as everything else.

  2. It communicates via "outgoing requests," not direct calls. Instead of machine.publishKeys(), you call machine.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 via machine.markRequestAsSent(id, type, responseJson).

    We have no homeserver. So our firestoreTransport.js is a shim that pretends to be one: it drains outgoingRequests(), fulfills each against Firestore (a KeysUploadRequest becomes a Firestore write to userKeys/{uid}/devices/{deviceId}), synthesizes the JSON response a Matrix homeserver would have returned, and calls markRequestAsSent. 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 / rotationPeriod on EncryptionSettings), 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:

  1. Bonfire membership lives in connections/{bonfireId}.participants, mutated only by a Cloud Run endpoint (not by clients directly).
  2. On any add/remove/ban, that service bumps a sessionEpoch counter on the bonfire doc.
  3. Clients treat a higher sessionEpoch than their current outbound group session as a hard signal to discard and re-create the group session before sending again.
  4. A removed member is dropped from participants and 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 encryption

The 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) โ€‹

  1. Derive/persist a stable deviceId for this account.
  2. OlmMachine.initialize(userId, deviceId, "lantern-olm-{uid}", passphrase) โ€” passphrase from entropy.
  3. Drain outgoingRequests(); the transport publishes the KeysUploadRequest (device keys + one-time keys + fallback key) to userKeys/{uid}/devices/{deviceId}.
  4. 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:

  1. Track + discover devices. updateTrackedUsers([bob]), then drain outgoingRequests(); the transport fulfills the emitted KeysQueryRequest from userKeys/{bob}/devices/* so the machine learns Bob's device keys.
  2. 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.
  3. Share the room key. shareRoomKey(roomId, [bob], settings) โ†’ ToDeviceRequest[]; the transport writes each to-device message into Bob's mailbox toDevice/{bob}/messages/* (ยง6.6), then acks.
  4. Encrypt + store. encryptRoomEvent(roomId, 'm.room.message', json) โ†’ Alice writes { ciphertext, algorithm:'m.megolm.v1', senderUid, senderDeviceId, conversationType:'pair' } to connections/{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 โ€‹

  1. Creator's machine creates a Megolm outbound group session for bonfireId.
  2. The session key is distributed to each member over the pairwise Olm sessions (claiming/establishing them as needed).
  3. connections/{bonfireId} created with type:'bonfire', participants, sessionEpoch: 0.
  4. Messages encrypted with the group session, tagged with algorithm:'m.megolm.v1' and the current sessionEpoch.

7.5 Adding a member โ€‹

  1. Cloud Run adds the uid to participants and bumps sessionEpoch (ยง5.3).
  2. Clients observe the new epoch, rotate to a fresh group session, distribute its key to all members including the new one.
  3. New member decrypts subsequent messages only โ€” not history (Megolm doesn't share past ratchet state forward).

7.6 Removing / banning a member โ€‹

  1. Cloud Run removes the uid from participants, revoking their read access via rules, and bumps sessionEpoch.
  2. Surviving clients rotate to a fresh group session and distribute the new key to remaining members only.
  3. 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 its devices/** 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 participants and bumps sessionEpoch.

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, hashRecoveryPhrase in encryption.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. recoveryKeyCheck lets 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:

javascript
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 โ€‹

PhaseEffortScope
Phase 1: Device-identity layer~2 daysOlmMachine bootstrap, store passphrase derivation, stable deviceId, Firestore transport for KeysUpload, key publishing + replenishment, Firestore rules
Phase 2: Olm sessions~3 daysKeysClaim/KeysQuery transport, session establishment, 1:1 encrypt/decrypt wired into messageService
Phase 3: Backup + restore~2 daysPIN-encrypted store export to secureBackup, restore on fresh sign-in, recovery-phrase second factor
Phase 4: Bonfire foundation (deferred until bonfires ship)~3 daysMegolm group sessions, key distribution over Olm, Cloud Run membership service + sessionEpoch rotation (ยง5.3)
Phase 5: Tests + edge cases~2โ€“3 daysReinstall/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 โ€‹

QuestionDefault for v1When to revisit
MLS instead of Olm/MegolmOlm/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 onlyIf product wants concurrent multi-device โ€” schema is already device-keyed
Cross-signing / device verificationManual 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 messages30-day TTL onlyIf product wants per-conversation timer
Sealed Sender (hide sender UID from server)Not implementedWhen senderUid visibility becomes a concern
Read receipts / typingOut 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-driven sessionEpoch rotation (ยง5.3). Recovery model finalized as Option 2 + Option 3 (PIN-encrypted backup + recovery phrase), single-device v1. Doc renamed SIGNAL_PROTOCOL.md โ†’ CHAT_ENCRYPTION.md.

  • 2026-05-30 โ€” Megolm for everything (1:1 included). Confirmed against the bundled .d.ts that matrix-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 of m.olm.v1 for 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. Envelope algorithm is now always m.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. Old store.js deleted (OlmMachine owns persistence). Wired into App.jsx, fire-and-forget after encryption restore. Firestore rules updated for userKeys/{uid}/devices/**. Operator note: run npm install in apps/web after 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). getMissingSessions returns a one-off KeysClaim that does not flow through outgoingRequests(), so firestoreTransport.js was refactored to expose fulfillRequest(handle, uid, request) (callable directly for the claim) with fulfillKeysQuery/fulfillKeysClaim/fulfillToDevice + mailbox read/clear. OTK claim is served by a new Cloud Run endpoint POST /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.sendMessage now stores an enc envelope ({algorithm:'m.megolm.v1', ciphertext}) and formatMessageDoc/getMessages decrypt transparently, plaintext fallback on failure. To-device mailbox toDevice/{uid}/messages added 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 (the bootstrapped flag that gates restore-on-fresh-device, and OTK-pool replenishment feeding the server-side count into receiveSyncChanges), and extended messageService.crypto.test.js with 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 real OlmMachine), consistent with the repo's route-test convention (firebaseUidFromSynthetic unit-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 โ†’ encryptData under the PIN-derived key โ†’ secureBackup/{uid} with a recoveryKeyCheck canary + version), restoreFromBackup (verify canary before importing, then importRoomKeys), and a 5s-debounced scheduleBackup. Only room keys are backed up โ€” device identity is regenerated and republished on a fresh device, never exported. roomCrypto calls scheduleBackup after key material changes (post-shareRoomKey, post-mailbox-drain). Restore is wired into App.jsx after initializeSignalKeys, gated on bootstrapped (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.fulfillKeysQuery records the peer device set after each directory read; roomCrypto.getConnectionSafetyInfo exposes the number + state. Chat.jsx adds 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.rules makes a device's deviceKeys identity 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, the firestore.rules hardening (write-once device key, server-only userKeys delete, server-routed to-device), and the silent deviceTrust change-DETECTION (recordPeerDevices still records + warns to devLog). The deviceTrust / roomCrypto safety-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.

Built with VitePress