feat(backend): MLS group state recovery and new-device join - #531
Open
Olorunfemi20 wants to merge 1 commit into
Open
feat(backend): MLS group state recovery and new-device join#531Olorunfemi20 wants to merge 1 commit into
Olorunfemi20 wants to merge 1 commit into
Conversation
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.
@
|
@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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mls_groupsmls_group_membersmls_commitsmls_welcomesPlus
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_membersstoresjoined_at_epochand a nullableremoved_at_epoch, so a device reads epochewhenjoined_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 NULLkeeps at most one live leaf per device.Endpoints
Mounted on
/conversationsin their own router soconversations.ts(already 800+ lines) does not keep growing. Express falls through fromconversationsRouter, and/:idcannot match a multi-segment/:id/mls/...path, so the two coexist cleanly.POST /conversations/:id/mls/groupGET /conversations/:id/mls/grouphistoryAvailableFromEpochGET /conversations/:id/mls/pending-devicesPOST /conversations/:id/mls/commitsGET /conversations/:id/mls/commitsGET /conversations/:id/mls/welcomeThree 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.recordCommitlocks the group row withSELECT ... FOR UPDATE, re-checks the epoch, and applies the commit, membership changes, Welcomes and the epoch bump in one transaction. The loser gets a409carryingcurrentEpochandexpectedEpochso 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 tojoinedAtEpochif 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/messagesand the socketmessage_historyevent both apply the same rule via a pure helper insrc/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:
GET /conversations/:id/mls/groupalso returnshistoryAvailableFromEpoch, 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:validateMessagePayloaddoes not apply — a non-emptyciphertextis required insteadenvelopesalongsidemlsEpochis rejected, because mixing the two key-distribution models on one message leaves recipients with no unambiguous rule for which ciphertext to decryptfileIdis still required for file-type messages, and clients still cannot sendsystemmessages.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 notmlsGroups.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 epochmls.routes.test.ts— 26 cases over authorisation, validation, the epoch-conflict response and the socket events a commit fans outmls.history.test.ts— the acceptance criterion end to end on the history endpoint, including interleaved non-MLS messages and the removed-device casevalidateMessagePayload.test.ts— MLS cases appended to the existing suiteSuite goes from 378 to 446 passing. The three files that fail (
file.messages,presence.typing,gateway.integration) already fail onmainbecauseweb-push,socket.io-clientandioredis-mockare not installed in the local workspace; the failure set is unchanged.eslint srcreports no errors.Docs
apps/backend/docs/mls-group-membership.mdcovers 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 a0001_*migration on its own branch. Whichever merges second needs its migration renumbered to0002and its_journal.jsonentry re-indexed — the SQL itself is independent and does not overlap.