Skip to content

feat(backend): MLS group state recovery and new-device join - #531

Open
Olorunfemi20 wants to merge 1 commit into
codebestia:mainfrom
Olorunfemi20:feat/mls-group-new-device-join
Open

feat(backend): MLS group state recovery and new-device join#531
Olorunfemi20 wants to merge 1 commit into
codebestia:mainfrom
Olorunfemi20:feat/mls-group-new-device-join

Conversation

@Olorunfemi20

Copy link
Copy Markdown
Contributor

closes #372

Summary

A newly-linked device now joins existing MLS groups through a proposal/commit and receives every epoch from its join onwards. Group messages sent before that join surface as unavailable placeholders rather than as errors, and the "no history for a new device" property is documented for users.

The property being implemented

MLS derives each message key from the group secrets at a particular epoch, and those secrets depend on the ratchet tree — which only contains a device's leaf from the commit that added it. A device added at epoch 7 therefore holds epoch 7 onwards and has no way to derive epoch 6 or earlier. There is no key on the server to hand it either: the server stores ciphertext and public group state only.

This is the same property that makes removal meaningful. If a joining device could reconstruct old epochs, so could a removed-and-re-added device, and post-removal forward secrecy would not hold. Backfilling history would require an existing device to re-encrypt past plaintext, which is the deferred recovery service, not this issue.

Schema

Migration drizzle/0001_mls_group_state.sql, generated by drizzle-kit with its meta snapshot.

Table Purpose
mls_groups Group id, cipher suite, current epoch. One row per group conversation.
mls_group_members A device leaf, as an epoch interval.
mls_commits Append-only log of the commit that produced each epoch.
mls_welcomes Welcome messages held for devices offline when they are added.

Plus messages.mls_epoch, recording which epoch encrypted a group message. Null for DMs, system events, and anything sent before the conversation adopted MLS.

Membership is an epoch interval, not a flag. mls_group_members stores joined_at_epoch and a nullable removed_at_epoch, so a device reads epoch e when joined_at_epoch <= e < (removed_at_epoch ?? infinity). A device removed and later re-added gets a second row with a later join epoch; the intervals are deliberately not merged, because it genuinely cannot read the epochs it was absent for. A partial unique index on (mls_group_id, device_id) WHERE removed_at_epoch IS NULL keeps at most one live leaf per device.

Endpoints

Mounted on /conversations in their own router so conversations.ts (already 800+ lines) does not keep growing. Express falls through from conversationsRouter, and /:id cannot match a multi-segment /:id/mls/... path, so the two coexist cleanly.

Route Purpose
POST /conversations/:id/mls/group Records group identifiers, seats the founder at epoch 0
GET /conversations/:id/mls/group Epoch, membership window, pending Welcome, historyAvailableFromEpoch
GET /conversations/:id/mls/pending-devices Member devices with no leaf yet — the set to Add
POST /conversations/:id/mls/commits Publishes a commit with its adds, removes and Welcomes
GET /conversations/:id/mls/commits Replay for a device catching up
GET /conversations/:id/mls/welcome Claims the pending Welcome

Three details worth calling out:

Epoch races are resolved in the database. Two members can commit at the same instant, and both will claim currentEpoch + 1. recordCommit locks the group row with SELECT ... FOR UPDATE, re-checks the epoch, and applies the commit, membership changes, Welcomes and the epoch bump in one transaction. The loser gets a 409 carrying currentEpoch and expectedEpoch so it can rebase and retry, instead of both commits applying and the members diverging.

Added devices are validated against conversation membership. Without that check a member could smuggle an arbitrary device into the group. The route rejects device ids that do not resolve to an active device of a conversation member, and rejects a commit that both adds and removes the same device.

Commit replay is clamped to the join epoch. ?sinceEpoch= is raised to joinedAtEpoch if a client asks for something earlier — those commits belong to a tree the device had no leaf in and cannot be applied to its state.

Pre-join messages surface as unavailable

GET /conversations/:id/messages and the socket message_history event both apply the same rule via a pure helper in src/lib/mlsVisibility.ts. A message outside the device's window comes back as:

{
  "id": "uuid",
  "senderId": "uuid",
  "createdAt": "2026-01-01T00:00:00.000Z",
  "mlsEpoch": 4,
  "ciphertext": null,
  "unavailable": true,
  "unavailableReason": "mls_no_key_before_join"
}

Reason codes: mls_no_key_before_join, mls_no_key_after_removal, mls_not_a_group_member.

Three deliberate choices:

  • Not an error. The server knows in advance the device cannot decrypt these. Returning ciphertext and letting decryption fail is indistinguishable on the client from tampering, and training users to dismiss decryption failures destroys a signal that is supposed to mean something.
  • Not omitted. Sender, timestamp and epoch are preserved so the client renders a placeholder in the right position. A gap is more alarming than a labelled placeholder and hides that a conversation happened.
  • Applied server-side, so a client cannot forget to check.

GET /conversations/:id/mls/group also returns historyAvailableFromEpoch, so a client can show one divider at the top of the readable range rather than repeating the notice on every message.

Conversations with no MLS group skip the lookup entirely, so nothing changes for DMs.

Sending group messages

The send path (REST and socket) accepts an optional mlsEpoch. When present, the message carries one ciphertext encrypted to the group's epoch secrets rather than one envelope per recipient device, so:

  • the per-device envelope requirement in validateMessagePayload does not apply — a non-empty ciphertext is required instead
  • sending envelopes alongside mlsEpoch is rejected, because mixing the two key-distribution models on one message leaves recipients with no unambiguous rule for which ciphertext to decrypt
  • the sibling-device coverage check (Multi-device self-sync (sender's own other devices) #188) is skipped, since a group ciphertext already reaches every device in the tree

fileId is still required for file-type messages, and clients still cannot send system messages.

Tests

  • mlsVisibility.test.ts — 15 pure cases pinning the epoch boundaries, including that the join epoch itself is readable and the removal epoch itself is not
  • mlsGroups.service.test.ts — commit transaction behaviour: correct epoch applies, skipped and already-applied epochs conflict, added windows open and removed windows close at the commit epoch
  • mls.routes.test.ts — 26 cases over authorisation, validation, the epoch-conflict response and the socket events a commit fans out
  • mls.history.test.ts — the acceptance criterion end to end on the history endpoint, including interleaved non-MLS messages and the removed-device case
  • validateMessagePayload.test.ts — MLS cases appended to the existing suite

Suite goes from 378 to 446 passing. The three files that fail (file.messages, presence.typing, gateway.integration) already fail on main because web-push, socket.io-client and ioredis-mock are not installed in the local workspace; the failure set is unchanged. eslint src reports no errors.

Docs

apps/backend/docs/mls-group-membership.md covers the join flow with a sequence diagram, every endpoint, the epoch-interval model, the reason codes with suggested user-facing copy, and the no-history property with the reasoning behind it.

Note on ordering

This branch adds migration 0001_mls_group_state.sql. My PR for #365 also adds a 0001_* migration on its own branch. Whichever merges second needs its migration renumbered to 0002 and its _journal.json entry re-indexed — the SQL itself is independent and does not overlap.

@
feat(backend): MLS group state recovery and new-device join

A newly-linked device now joins existing groups through a proposal/commit
and receives every epoch from its join onwards. Group messages from before
its join surface as unavailable placeholders rather than as errors.

Schema:
- mls_groups holds the public group identifiers and current epoch, one row
  per group conversation
- mls_group_members records a device leaf as an epoch interval
  [joinedAtEpoch, removedAtEpoch) rather than a boolean, so a device
  decryption window is derivable and a re-added device does not regain the
  epochs it was absent for
- mls_commits is the append-only epoch log a device replays to catch up
- mls_welcomes holds Welcome messages for devices that are offline when
  they are added
- messages.mls_epoch records which epoch encrypted a group message

Routes under /conversations/:id/mls:
- POST   /group           records group identifiers, seats the founder at 0
- GET    /group           epoch, membership window, pending Welcome and the
                          epoch this device history starts at
- GET    /pending-devices member devices with no leaf yet
- POST   /commits         publishes a commit plus its adds, removes and
                          Welcomes; epoch must be currentEpoch + 1 and the
                          group row is locked so a concurrent commit loses
                          with 409 instead of both applying
- GET    /commits         replay, clamped to the device join epoch
- GET    /welcome         claims the pending Welcome

Read paths:
- GET /conversations/:id/messages and the socket message_history event both
  blank the ciphertext of messages outside the device epoch window and tag
  them with unavailable plus a reason code, preserving sender and timestamp
  so the client renders a placeholder in position
- the send path accepts mlsEpoch; an MLS group message carries one group
  ciphertext instead of per-device envelopes, so the envelope requirement
  and the sibling-coverage check do not apply to it

Adds docs/mls-group-membership.md documenting the join flow, the endpoints
and the no-history-for-new-device property.
@
@drips-wave

drips-wave Bot commented Jul 30, 2026

Copy link
Copy Markdown

@Olorunfemi20 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MLS group state recovery + new-device join

1 participant