This document describes how XMTP implements Messaging Layer Security (MLS).
Foreign key constraints and indexes omitted for simplicity.
CREATE TABLE groups (
-- Random ID generated by group creator
"id" BLOB PRIMARY KEY NOT NULL,
-- Based on the timestamp of the welcome message
"created_at_ns" BIGINT NOT NULL,
-- Enum of GROUP_MEMBERSHIP_STATE
"membership_state" INT NOT NULL
);
-- Allow for efficient sorting of groups
CREATE INDEX groups_created_at_idx ON groups(created_at_ns);
CREATE INDEX groups_membership_state ON groups(membership_state);
-- Successfully processed messages meant to be returned to the user
CREATE TABLE group_messages (
"id" BLOB PRIMARY KEY NOT NULL,
-- Derived via SHA256(CONCAT(decrypted_message_bytes, conversation_id, timestamp))
"group_id" BLOB NOT NULL,
-- Message contents after decryption
"decrypted_message_bytes" BLOB NOT NULL,
-- Based on the timestamp of the message
"sent_at_ns" BIGINT NOT NULL,
-- Enum GROUP_MESSAGE_KIND
"kind" INT NOT NULL,
-- Could remove this if we added a table mapping installation_ids to wallet addresses
"sender_installation_id" BLOB NOT NULL,
"sender_account_address" TEXT NOT NULL,
-- Enum: 1 = 'published' or 2 = 'unpublished'
"delivery_status" INT NOT NULL,
FOREIGN KEY (group_id) REFERENCES groups(id)
);
CREATE INDEX group_messages_group_id_sort_idx ON group_messages(group_id, sent_at_ns);
-- Used to keep track of the last seen message timestamp in a topic
CREATE TABLE topic_refresh_state (
"topic" TEXT PRIMARY KEY NOT NULL,
"last_message_timestamp_ns" BIGINT NOT NULL
);
-- This table is required to retry messages that do not send successfully due to epoch conflicts
CREATE TABLE group_intents (
-- Serial ID auto-generated by the DB
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
-- Enum INTENT_KIND
"kind" INT NOT NULL,
"group_id" BLOB NOT NULL,
-- Some sort of serializable blob that can be used to re-try the message if the first attempt failed due to conflict
"publish_data" BLOB NOT NULL,
-- Data needed after applying a commit, such as welcome messages
"post_commit_data" BLOB NOT NULL,
-- INTENT_STATE,
"state" INT NOT NULL,
-- The hash of the encrypted, concrete, form of the message if it was published.
"message_hash" BLOB,
FOREIGN KEY (group_id) REFERENCES groups(id)
);
CREATE INDEX group_intents_group_id_id ON group_intents(group_id, id);
- ALLOWED // User has agreed to be a member of the group
- REJECTED // User has rejected an invite to the group or left
- PENDING // User has neither accepted or rejected whether they should join the group
- TO_SEND // Either has never been sent to the network or needs to be re-sent
- PUBLISHED // Sent to the network but has not been read back or committed
- COMMITTED // Committed messages could be deleted
- SEND_MESSAGE // An intent to send a message to the group
- ADD_MEMBERS // An intent to add members to the group
- REMOVE_MEMBERS // An intent to remove members from the group
- KEY_UPDATE // An intent to update your own group key
- PENDING // Needs to wait for commit to be applied before sending
- READY_TO_SEND
- SENT // Messages may be deleted at this point. We may decide to remove this state altogether.
- APPLICATION
- MEMBER_ADDED
- MEMBER_REMOVED
The following diagram illustrates some common flows in the state machine
For the first version of MLS in XMTP, all members commit their own proposals immediately, and immediately discard any proposals from other members upon receiving them. Future versions of XMTP will have more sophisticated logic, such as batching proposals, allowing members to commit proposals from other members, as well as more sophisticated validation logic for which proposals are permitted from which members.
- Key updates
- Processing incoming welcome messages
- Tracking group membership at the account/user level
- Permissioning for adding/removing accounts/users
- Mechanism for syncing installations under each account/user
Simplified high level flow for adding members to a group:
- Create a
group_intent
for adding the members - Fetch Key Packages for all new members
- Convert the intent into concrete commit and welcome messages for the current epoch
- Write the welcome messages to the
post_commit_data
field for later
- Write the welcome messages to the
- Publish commit message
- Sync the state of the group with the network
- If no conflicts: Publish welcome messages to new members.
If conflicts: Go back to step 2 and try again (reset the intent's state to
TO_SEND
and clear thepublish_data
andpost_commit_data
fields)
Simplified high level flow for removing members from a group:
- Create a
group_intent
for removing the members - Convert the intent into concrete commit for the current epoch
- Publish commit to the network
- Sync the state of the group with the network
- If no conflicts: Done.
If conflicts: Go back to step 2 and try again (reset the intent's state to
TO_SEND
and clear thepublish_data
andpost_commit_data
fields)
Simplified high level flow for sending a group message:
- Create a
group_intent
for sending the message - Convert the intent into a concrete message for the current epoch
- Publish message to the network
- Sync the state of the group with the network (can be debounced or otherwise only done periodically)
- If no conflicts: Mark the message as committed.
If conflicts: Go back to step 2 and try again (reset the intent's state to
TO_SEND
and clear thepublish_data
andpost_commit_data
fields)
The latest payloads on a group could be synced from the server in the following cases:
- Push notifications
- Application-triggered subscription
- Application-triggered pull
- Commit publishing flow
Any syncing strategy must be able to handle the following constraints:
- Payload syncing could be initiated concurrently from multiple locations
- Due to forward secrecy constraints, each payload may only be decrypted successfully once
These are the following possible strategies, each with their own limitations:
- Co-ordinated: Syncing can only happen in one location at a time via locks/queues
- Unco-ordinated: Allow syncing to happen in parallel
The latter is simpler to implement in the short-term, but raises the following potential challenges:
- How to handle concurrent decryption failures and return the latest data regardless
- How to handle updating the
last_message_timestamp_ns
on thetopic_refresh_state
table - How to know if a failure is due to the message having already been decrypted, or permanent failure
For the initial version, this simple strategy can be used to pull the latest payloads:
- Read the
last_message_timestamp_ns
from the database and pull all payloads from the server with timestamp greater than it - For each payload, attempt to decrypt it
- If it succeeds, process the payload. Write the result, update the cryptographic state, and update the
last_message_timestamp_ns
, together in a single transaction. Setlast_message_timestamp_ns
to the larger value out of the value in the database and the payload's timestamp. - If it fails, only attempt to update
last_message_timestamp_ns
in the database to the larger value out of the value in the database and the payload's timestamp.
- If it succeeds, process the payload. Write the result, update the cryptographic state, and update the
- To return the result of the sync, pull the latest data from the database rather than using the in-memory data from the syncing process
This strategy effectively means that the processing of each payload succeeds or fails atomically. In the event of failure due to concurrency, the actual result can be read from the database.
For now, we can put off the issue of detecting if a decryption failure is due to concurrency or permanent failure. If OpenMLS cryptographic state is entirely database-driven, we may be able to detect that a failure is due to concurrency by the fact that last_message_timestamp_ns
has already been updated. If OpenMLS cryptographic state is partially driven by in-memory data, we can record per-payload successes and failures in a separate table, with successes always overwriting failures.
- Read from the welcome topic for your
installation_id
, filtering for messages sincelast_message_timestamp
- For each message, create a group with a
GROUP_MEMBERSHIP_STATE
of pending