Skip to main content
Messaging Layer Security (MLS) is an IETF standard protocol (RFC 9420) for end-to-end encrypted group messaging. Unlike pairwise schemes, MLS scales to large groups while preserving both forward secrecy (past messages remain secure after a key compromise) and post-compromise security (a compromised device is healed from the group after the next commit).

Key MLS concepts

Group / Epoch

An MLS group is a set of members sharing a common cryptographic state. Each time the group membership or keying material changes, the group advances to a new epoch. Every epoch has a fresh symmetric encryption secret derived via the MLS key schedule.

Key Package

A KeyPackage is a signed bundle containing a client’s identity credential and a short-term HPKE public key. It is published to a key-distribution service and consumed by a group member when adding the client to a group.

Proposal

A proposal is a signed statement of intent: add a member, remove a member, or update keying material. Proposals are queued and do not take effect until a commit is created and merged. The bundle returned by proposal operations is MlsProposalBundle.

Commit

A commit finalises one or more pending proposals and advances the epoch. The committer sends an MlsCommitBundle to the delivery service, which fans it out to all current members. New members receive a WelcomeBundle derived from the same commit.

CoreCrypto’s MLS implementation

CoreCrypto’s MLS layer wraps OpenMLS — specifically a Wire-maintained fork that tracks upstream closely while incorporating Wire-specific patches. The fork is pulled in via the openmls crate dependency and is not a hard fork in terms of protocol compatibility.

Entry point: Session

Session (in crypto/src/mls/session/mod.rs) is the per-device MLS context. It maps to the RFC 9420 concept of a Client but is named Session to avoid ambiguity with the word “client” in product contexts.
pub struct Session<D> {
    id: ClientId,
    pub(crate) crypto_provider: MlsCryptoProvider,
    pub(crate) transport: Arc<dyn MlsTransport + 'static>,
    database: D,
    pub(crate) epoch_observer: Arc<RwLock<Option<Arc<dyn EpochObserver>>>>,
    pub(crate) history_observer: Arc<RwLock<Option<Arc<dyn HistoryObserver>>>>,
}
One Session exists per user device. It is created lazily via TransactionContext::mls_init and stored inside CoreCrypto. A session can hold many MLS conversations.

MlsConversation

MlsConversation wraps an OpenMLS MlsGroup and is the object on which all group operations are performed. It holds:
  • id: ConversationId — an application-defined byte-slice identifier
  • parent_id: Option<ConversationId> — for sub-conversations
  • group: MlsGroup — the underlying OpenMLS group state
  • configuration: MlsConversationConfiguration — ciphersuite, wire policy, custom extensions
The allowed operations on a conversation depend on its pending-proposal and pending-commit state:
StateEncryptHandshakeMergeDecrypt
0 pending proposals, 0 pending commits
1+ pending proposals, 0 pending commits
0 pending proposals, 1 pending commit
1+ pending proposals, 1 pending commit

MlsCommitBundle

Every commit operation returns an MlsCommitBundle:
pub struct MlsCommitBundle {
    /// Present when the commit includes pending Add proposals
    pub welcome: Option<MlsMessageOut>,
    /// The commit message itself (TLS-serialised)
    pub commit: MlsMessageOut,
    /// Updated GroupInfo for external joins
    pub group_info: MlsGroupInfoBundle,
    /// Encrypted fan-out message for the new epoch (if applicable)
    pub encrypted_message: Option<Vec<u8>>,
}
The application is responsible for routing each field to the correct delivery-service endpoint — typically commit and welcome go to separate endpoints.

MlsTransport — the application callback

CoreCrypto does not send messages over the network itself. Instead, the application must implement the MlsTransport trait and pass it to mls_init:
#[async_trait]
pub trait MlsTransport: Debug + Send + Sync {
    async fn send_commit_bundle(
        &self,
        commit_bundle: MlsCommitBundle,
    ) -> Result<MlsTransportResponse>;

    async fn send_message(&self, mls_message: Vec<u8>) -> Result<MlsTransportResponse>;

    async fn prepare_for_transport(
        &self,
        secret: &HistorySecret,
    ) -> Result<MlsTransportData>;
}
The delivery service can respond with:
  • MlsTransportResponse::Success — message accepted
  • MlsTransportResponse::Retry — consume pending incoming messages and retry
  • MlsTransportResponse::Abort { reason } — unrecoverable rejection
On Retry, CoreCrypto automatically processes the queued messages and re-sends the commit, so the application does not need to implement retry logic manually.

Group creation flow

1

Open a database and create CoreCrypto

let key = DatabaseKey::generate();
let db = Database::open(ConnectionType::Persistent("./cc.db"), &key).await?;
let cc = CoreCrypto::new(db);
2

Begin a transaction and initialise the MLS session

let transport = Arc::new(MyTransportImpl::new());
let ctx = cc.new_transaction().await?;
ctx.mls_init(client_id, transport).await?;
This creates a Session, which loads or creates the client’s identity keypair and restores any previously persisted conversations.
3

Add a credential and generate key packages

let credential = Credential::from_identifier(&identifier, ciphersuite)?;
let credential_ref = ctx.add_credential(credential).await?;
let kp = ctx.generate_keypackage(&credential_ref, None).await?;
Key packages are published to a key-distribution service so other users can add this device to groups.
4

Create a new conversation

ctx.new_conversation(&conversation_id, &credential_ref, config).await?;
The creator is the sole member at epoch 0.
5

Invite other members

// Fetch remote key packages from the delivery service, then:
let guard = ctx.conversation(&conversation_id).await?;
guard.add_members(key_packages).await?;
This creates a commit with embedded Welcome messages. CoreCrypto calls MlsTransport::send_commit_bundle automatically.
6

Commit the transaction

ctx.finish().await?;
All keystore writes are flushed atomically. The context becomes invalid after this call.

Supported ciphersuites

CoreCrypto delegates ciphersuite support entirely to OpenMLS. The default ciphersuite is:
MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519
Other suites supported by OpenMLS (P-256, P-384, X25519Kyber768Draft00 for PQ hybrid) can be specified in MlsConversationConfiguration. Hash algorithm selection (SHA-256, SHA-384, SHA-512) is determined automatically by the chosen ciphersuite.

Security guarantees

Forward secrecy is achieved through the MLS key schedule: each epoch derives a fresh symmetric tree key from the previous one and immediately deletes the previous epoch secret. Messages encrypted in epoch N cannot be decrypted by an attacker who compromises epoch N+1 keying material. Post-compromise security (also called “healing”) is achieved via Update proposals and commits: after a device is believed to be compromised, the group creator (or any member) can issue an Update commit that rotates all leaf keying material, evicting the attacker from the forward secret tree.
Always call ctx.finish() — not just drop(ctx) — to ensure keystore writes are actually committed. Dropping a TransactionContext without finishing it is equivalent to an abort and discards all in-flight changes.

Build docs developers (and LLMs) love