Introduction
OpenMLS is a Rust implementation of the Messaging Layer Security (MLS) protocol, as specified in RFC 9420. OpenMLS provides a high-level API to create and manage MLS groups. It supports basic ciphersuites and an interchangeable cryptographic provider, key store, and random number generator.
This book provides guidance on using OpenMLS and its MlsGroup API to perform basic group operations, illustrated with examples.
Supported ciphersuites
- MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 (MTI)
- MLS_128_DHKEMP256_AES128GCM_SHA256_P256
- MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519
Supported platforms
OpenMLS is built and tested on the Github CI for the following rust targets.
- x86_64-unknown-linux-gnu
- x86_64-pc-windows-msvc
- aarch64-apple-darwin
- aarch64-unknown-linux-gnu
Unsupported, but built on CI
The Github CI also builds (but doesn’t test) the following rust targets.
- i686-unknown-linux-gnu
- i686-pc-windows-msvc
- x86_64-apple-darwin
- aarch64-linux-android
- aarch64-apple-ios
- wasm32-unknown-unknown
- armv7-linux-androideabi
- x86_64-linux-android
- i686-linux-android
OpenMLS supports 32 bit platforms and above.
Cryptography Dependencies
OpenMLS does not implement its own cryptographic primitives. Instead, it relies on existing implementations of the cryptographic primitives used by MLS. There are two different cryptography providers implemented right now. But consumers can bring their own implementation. See traits for more details.
Features
OpenMLS provides the following features
- extensions-draft: enable features defined in MLS extensions draft
- fork-resolution: helper functionality for resolving forks.
- js: enable compilation to wasm
Developer features
- libcrux-provider: enable the libcrux crypto provider dependency
- openmls_rust_crypto: enable the rust crypto provider
- sqlite-provider: enable the sqlite provider
- backtrace: enable backtraces
- content-debug: allow printing sensitive content of messages for debugging
- crypto-debug: allow printing cryptographic key material for debugging
- test-util: test utilities
Working on OpenMLS
For more details when working on OpenMLS itself please see the Developer.md.
Maintenance & Support
OpenMLS is maintained and developed by Phoenix R&D and CE Labs.
Acknowledgements
Zulip graciously provides the OpenMLS community with a “Zulip Cloud Standard” tier Zulip instance.
User manual
The user manual describes how to use the different parts of the OpenMLS API.
Prerequisites
Most operations in OpenMLS require a provider object that provides all required cryptographic algorithms via the [OpenMlsProvider] trait.
Currently, there are two implementations available:
- one through the openmls_rust_crypto crate.
- one through the openmls_libcrux_crypto crate.
Thus, you can create the provider object for the following examples using …
let provider: OpenMlsRustCrypto = OpenMlsRustCrypto::default();
Credentials
MLS relies on credentials to encode the identity of clients in the context of a group.
There are different types of credentials, with the OpenMLS library currently only supporting the BasicCredential credential type (see below).
Credentials are used to authenticate messages by the owner in the context of a group.
Note that the link between the credential and its signature keys depends on the credential type.
For example, the link between the BasicCredential’s and its keys is not defined by MLS.
A credential is always embedded in a leaf node, which is ultimately used to represent a client in a group and signed by the private key corresponding to the signature public key of the leaf node. Clients can decide to use the same credential in multiple leaf nodes (and thus multiple groups) or to use distinct credentials per group.
The binding between a given credential and owning client’s identity is, in turn, authenticated by the Authentication Service, an abstract authentication layer defined by the MLS architecture document. Note that the implementation of the Authentication Service and, thus, the details of how the binding is authenticated are not specified by MLS.
Creating and using credentials
OpenMLS allows clients to create Credentials.
A BasicCredential, currently the only credential type supported by OpenMLS, consists only of the identity.
Thus, to create a fresh Credential, the following inputs are required:
identity: Vec<u8>: An octet string that uniquely identifies the client.credential_type: CredentialType: The type of the credential, in this caseCredentialType::Basic.
let credential = BasicCredential::new(identity);
After creating the credential bundle, clients should create keys for it.
OpenMLS provides a simple implementation of BasicCredential for tests and to demonstrate how to use credentials.
let signature_keys = SignatureKeyPair::new(signature_algorithm).unwrap();
signature_keys.store(provider.storage()).unwrap();
All functions and structs related to credentials can be found in the credentials module.
Key Packages
The MLS protocol is designed to be asynchronous: It allows members to be added to groups without said members being online.
The general flow is as follows:
- Client A generates key packages and registers them with the Delivery Service
- Client B decides to add Client A to a group, so it contacts the Delivery Service and downloads one of Client A’s key packages
- The delivery service marks the
KeyPackageas consumed (more on this below) - Client B uses the KeyPackage to generate a commit that adds Client A to the group
Each key package has corresponding private keys; The KeyPackageBundle structure encapsulates a key package and its private keys:
key_package(KeyPackage): Public key material and information required to add the client to a groupprivate_init_key(HpkePrivateKey): The private key required to decrypt theWelcomemessage that adds the client to a groupprivate_encryption_key(EncryptionPrivateKey): The private key used for group operations after the client was added
Key packages are meant to be used only once (unless it’s a last resort KeyPackage). The Delivery Service should ensure that each (non-last resort) KeyPackage is only downloaded once.
Clients should register multiple KeyPackages and one or more last-resort KeyPackages with the DS, and replenish them sufficiently frequently. This is to ensure that the DS’s KeyPackage supply is not exhausted (either through use or KeyPackage expiry) and that key material stays fresh.
Each key package has a lifetime attached to it (default is 3 * 28 days), so the client should keep that in mind when it comes to replenishing registered KeyPackages at the Delivery Service.
Finally, a key package is identified within the protocol by a “hash reference”.
Creating KeyPackageBundles
A few parameters need to be determined in order to generate a KeyPackageBundle:
ciphersuites: &[CiphersuiteName]: A list of ciphersuites supported by the client.extensions: Vec<Extensions>: A list of extensions supported by the client.
The client must specify at least one ciphersuite per KeyPackage and it must not advertise ciphersuites it does not support.
The client should advertise all extensions it supports. See the documentation of extensions for more details.
A KeyPackageBundle can be generated through the KeyPackage::builder():
#![allow(unused)]
fn main() {
// The following needs to be pre-defined:
// - CIPHERSUITE (`Ciphersuite`)
// - provider (`OpenMlsProvider`)
// - signature_key_pair (`SignatureKeyPair`)
// - credential (`BasicCredential`)
let key_package_bundle = KeyPackage::builder()
.build(
CIPHERSUITE,
&provider,
&signature_key_pair,
CredentialWithKey {
credential: credential.clone().into(),
signature_key: signature_key_pair.public().into(),
},
)?;
// This is the exact data another client would need to add this client to a group (upload this to the Delivery Service)
let key_package: Vec<u8> = key_package_bundle.key_package().tls_serialize_detached()?;
// This is the key package's identifier within the protocol
let hash_ref: Vec<u8> = key_package_bundle.key_package().hash_ref(provider.crypto())?.tls_serialize_detached()?
}
The private parts of the key package are automatically stored in the database.
Getting existing key packages
OpenMLS does not provide an API for this; While you’re implementing StorageProvider for your local database, you can implement a get_key_packages() function that queries the storage to get that for you.
Deleting a key package (identified by hash reference)
#![allow(unused)]
fn main() {
let hash_ref: Vec<u8> = ...;
provider.storage().delete_key_package(
&KeyPackageRef::tls_deserialize_exact_bytes(hash_ref.as_slice())?
)?;
}
Appendix: KeyPackage contents
A KeyPackage (the public part of KeyPackageBundle) consists of:
- A public HPKE encryption key to enable MLS’ basic group key distribution feature
- The lifetime throughout which the key package is valid
- Information about the client’s capabilities (i.e., which features of MLS it supports)
- Any extension that the client wants to include
- One of the client’s credentials, as well as a signature over the whole key package using the private key corresponding to the credential’s signature public key
Group configuration
Two very similar structs can help configure groups upon their creation: MlsGroupJoinConfig and MlsGroupCreateConfig.
MlsGroupJoinConfig contains the following runtime-relevant configuration options for an MlsGroup and can be set on a per-client basis when a group is joined.
| Name | Type | Explanation |
|---|---|---|
wire_format_policy | WireFormatPolicy | Defines the wire format policy for outgoing and incoming handshake messages. |
padding_size | usize | Size of padding in bytes. The default is 0. |
past_epoch_deletion_policy | PastEpochDeletionPolicy | Configures the number of past epochs for which application messages can be decrypted. The default is MaxEpochs(0). For more information, see Past epoch secret deletion. |
number_of_resumption_psks | usize | Number of resumption psks to keep. The default is 0. |
use_ratchet_tree_extension | bool | Flag indicating the Ratchet Tree Extension should be used. The default is false. |
sender_ratchet_configuration | SenderRatchetConfiguration | Sender ratchet configuration. |
MlsGroupCreateConfig contains an MlsGroupJoinConfig, as well as a few additional parameters that are part of the group state that is agreed-upon by all group members. It can be set at the time of a group’s creation and contains the following additional configuration options.
| Name | Type | Explanation |
|---|---|---|
group_context_extensions | Extensions | Optional group-level extensions, e.g. RequiredCapabilitiesExtension. |
capabilities . | Capabilities | Lists the capabilities of the group’s creator. |
leaf_extensions . | Extensions | Extensions to be included in the group creator’s leaf |
Both ways of group configurations can be specified by using the struct’s builder pattern, or choosing their default values. The default value contains safe values for all parameters and is suitable for scenarios without particular requirements.
Example join configuration:
let mls_group_config = MlsGroupJoinConfig::builder()
.padding_size(100)
.sender_ratchet_configuration(SenderRatchetConfiguration::new(
10, // out_of_order_tolerance
2000, // maximum_forward_distance
))
.use_ratchet_tree_extension(true)
.build();
Example create configuration:
let mls_group_create_config = MlsGroupCreateConfig::builder()
.padding_size(100)
.sender_ratchet_configuration(SenderRatchetConfiguration::new(
10, // out_of_order_tolerance
2000, // maximum_forward_distance
))
.with_group_context_extensions(
Extensions::single(Extension::ExternalSenders(vec![ExternalSender::new(
ds_credential_with_key.signature_key.clone(),
ds_credential_with_key.credential.clone(),
)]))
.expect("failed to create single-element extensions list"),
)
.ciphersuite(ciphersuite)
// we need to specify the non-default extension here
.capabilities(Capabilities::new(
None, // Defaults to the group's protocol version
None, // Defaults to the group's ciphersuite
Some(&[ExtensionType::Unknown(0xff00)]),
None, // Defaults to all basic extension types
Some(&[CredentialType::Basic]),
))
// Example leaf extension
.with_leaf_node_extensions(
Extensions::single(Extension::Unknown(
0xff00,
UnknownExtension(vec![0, 1, 2, 3]),
))
.expect("failed to create single-element extensions list"),
)
.expect("failed to configure leaf extensions")
.use_ratchet_tree_extension(true)
.build();
Unknown extensions
Some extensions carry data, but don’t alter the behaviour of the protocol (e.g. the application_id extension). OpenMLS allows the use of arbitrary such extensions in the group context, key packages and leaf nodes. Such extensions can be instantiated and retrieved through the use of the UnknownExtension struct and the ExtensionType::Unknown extension type. Such “unknown” extensions are handled transparently by OpenMLS, but can be used by the application, e.g. to have a group agree on pieces of data.
Creating groups
There are two ways to create a group: Either by building an MlsGroup directly, or by using an MlsGroupCreateConfig. The former is slightly simpler, while the latter allows the creating of multiple groups using the same configuration. See Group configuration for more details on group parameters.
In addition to the group configuration, the client should define all supported and required extensions for the group. The negotiation mechanism for extension in MLS consists in setting an initial list of extensions at group creation time and choosing key packages of subsequent new members accordingly.
In practice, the supported and required extensions are set by adding them to the initial KeyPackage of the creator:
// Create the key package
KeyPackage::builder()
.key_package_extensions(extensions)
.build(ciphersuite, provider, signer, credential_with_key)
.unwrap()
After that, the group can be created either using a config:
let mut alice_group = MlsGroup::new(
alice_provider,
&alice_signature_keys,
&mls_group_create_config,
alice_credential.clone(),
)
.expect("An unexpected error occurred.");
… or using the builder pattern:
let mut alice_group = MlsGroup::builder()
.padding_size(100)
.sender_ratchet_configuration(SenderRatchetConfiguration::new(
10, // out_of_order_tolerance
2000, // maximum_forward_distance
))
.ciphersuite(ciphersuite)
.use_ratchet_tree_extension(true)
.build(
alice_provider,
&alice_signature_keys,
alice_credential.clone(),
)
.expect("An unexpected error occurred.");
Note: Every group is assigned a random group ID during creation. The group ID cannot be changed and remains immutable throughout the group’s lifetime. Choosing it randomly makes sure that the group ID doesn’t collide with any other group ID in the same system.
If someone else already gave you a group ID, e.g., a provider server, you can also create a group using a specific group ID:
// Some specific group ID generated by someone else.
let group_id = GroupId::from_slice(b"123e4567e89b");
let mut alice_group = MlsGroup::new_with_group_id(
alice_provider,
&alice_signature_keys,
&mls_group_create_config,
group_id,
alice_credential.clone(),
)
.expect("An unexpected error occurred.");
The Builder provides methods for setting required capabilities and external senders.
The information passed into these lands in the group context, in the form of extensions.
Should the user want to add further extensions, they can use the with_group_context_extensions method:
// we are adding an external senders list as an example.
let extensions =
Extensions::from_vec(vec![Extension::ExternalSenders(external_senders_list)])
.expect("failed to create extensions list");
let mut alice_group = MlsGroup::builder()
.padding_size(100)
.sender_ratchet_configuration(SenderRatchetConfiguration::new(
10, // out_of_order_tolerance
2000, // maximum_forward_distance
))
.with_group_context_extensions(extensions) // NB: the builder method returns a Result
.use_ratchet_tree_extension(true)
.build(
alice_provider,
&alice_signature_keys,
alice_credential.clone(),
)
.expect("An unexpected error occurred.");
Join a group from a Welcome message
To join a group from a Welcome message, a new MlsGroup can be instantiated from
the MlsMessageIn message containing the Welcome and an MlsGroupJoinConfig
(see Group configuration for more details). This is a
two-step process: a StagedWelcome is constructed from the Welcome
and can then be turned into an MlsGroup. If the group configuration does not
use the ratchet tree extension, the ratchet tree needs to be provided.
let staged_join =
StagedWelcome::new_from_welcome(bob_provider, &mls_group_config, welcome, None)
.expect("Error constructing staged join");
let mut bob_group = staged_join
.into_group(bob_provider)
.expect("Error joining group from StagedWelcome");
The reason for this two-phase process is to allow the recipient of a Welcome
to inspect the message, e.g. to determine the identity of the sender.
Pay attention not to forward a Welcome message to a client before its associated commit has been accepted by the Delivery Service. Otherwise, you would end up with an invalid MLS group instance.
Examining a welcome message
When a client is invited to join a group, the application can allow the client to decide whether or not to join the group. In order to determine whether to join the group, the application can inspect information provided in the welcome message, such as who invited them, who else is in the group, what extensions are available, and more. If the application decides not to join the group, the welcome must be discarded to ensure that the local state is cleaned up.
After receiving a MlsMessageIn from the delivery service, the first step is to extract the MlsMessageBodyIn, and determine whether it is a welcome message.
let welcome = match welcome.extract() {
MlsMessageBodyIn::Welcome(welcome) => welcome,
_ => unimplemented!("Handle other message types"),
};
The next step is to process the Welcome. This removes the consumed KeyPackage from the StorageProvider, unless it is a last resort KeyPackage.
let join_config = MlsGroupJoinConfig::default();
// This deletes the keys used to decrypt the welcome, except if it is a last resort key
// package.
let processed_welcome = ProcessedWelcome::new_from_welcome(bob_provider, &join_config, welcome)
.expect("Error constructing processed welcome");
At this stage, there are some more pieces of information in the ProcessedWelcome that could be useful to the application. For example, it can be useful to check which extensions are available. However, the pieces of information that are retrieved from the ProcessedWelcome are unverified, and verified values are only available from the StagedWelcome that is produced in the next step.
// unverified pre-shared keys (`&[PreSharedKeyId]`)
let _unverified_psks = processed_welcome.psks();
// unverified group info (`VerifiableGroupInfo`)
let unverified_group_info = processed_welcome.unverified_group_info();
// From the unverified group info, the ciphersuite, group_id, and other information
// can be retrieved.
let _ciphersuite = unverified_group_info.ciphersuite();
let _group_id = unverified_group_info.group_id();
let _epoch = unverified_group_info.epoch();
// Can also retrieve any available extensions
let extensions = unverified_group_info.extensions();
// Retrieving the ratchet tree extension
let ratchet_tree_extension = extensions
.ratchet_tree()
.expect("No ratchet tree extension");
// The (unverified) ratchet tree itself can also be inspected
let _ratchet_tree = ratchet_tree_extension.ratchet_tree();
The next step is to stage the ProcessedWelcome.
let staged_welcome: StagedWelcome = processed_welcome
.into_staged_welcome(bob_provider, None)
.expect("Error constructing staged welcome");
Then, more information about the welcome message’s sender, such as the credential, signature public key, and encryption public key can also be individually inspected. The welcome message sender’s credential can be validated at this stage.
let welcome_sender: &LeafNode = staged_welcome
.welcome_sender()
.expect("Welcome sender could not be retrieved");
// Inspect sender's credential...
let _credential = welcome_sender.credential();
// Inspect sender's signature public key...
let _signature_key = welcome_sender.signature_key();
Additionally, some information about the other group members is made available, e.g. credentials and signature public keys for credential validation.
// Inspect the group members
for member in staged_welcome.members() {
// leaf node index
let _leaf_node_index = member.index;
// credential
let _credential = member.credential;
// encryption public key
let _encryption_key = member.encryption_key;
// signature public key
let _signature_key = member.signature_key;
}
Lastly, the GroupContext contains several other useful pieces of information, including the protocol version, the extensions enabled on the group, and the required extension, proposal, and credential types.
// Inspect group context...
let group_context = staged_welcome.group_context();
// inspect protocol version...
let _protocol_version = group_context.protocol_version();
// Inspect ciphersuite...
let _ciphersuite = group_context.ciphersuite();
// Inspect extensions...
let extensions: &Extensions<GroupContext> = group_context.extensions();
// Can check which extensions are enabled
let _has_ratchet_extension = extensions.ratchet_tree().is_some();
// Inspect required capabilities...
if let Some(capabilities) = group_context.required_capabilities() {
// Inspect required extension types...
let _extension_types: &[ExtensionType] = capabilities.extension_types();
// Inspect required proposal types...
let _proposal_types: &[ProposalType] = capabilities.proposal_types();
// Inspect required credential types...
let _credential_types: &[CredentialType] = capabilities.credential_types();
}
// Additional information from the `GroupContext`
let _group_id = group_context.group_id();
let _epoch = group_context.epoch();
let _tree_hash = group_context.tree_hash();
let _confirmed_transcript_hash = group_context.confirmed_transcript_hash();
Join a group with an external commit
To join a group with an external commit message, a new MlsGroup can be instantiated directly from the GroupInfo.
The GroupInfo/Ratchet Tree should be shared over a secure channel.
If the RatchetTree extension is not included in the GroupInfo as a GroupInfoExtension, then the ratchet tree needs to be provided.
The GroupInfo can be obtained either from a call to export_group_infofrom the MlsGroup:
let (mls_message_out, welcome, group_info) = alice_group
.add_members(
alice_provider,
&alice_signature_keys,
core::slice::from_ref(bob_key_package.key_package()),
)
.expect("Could not add members.");
Or from a call to a function that results in a staged commit:
let verifiable_group_info = alice_group
.export_group_info(alice_provider.crypto(), &alice_signature_keys, true)
.expect("Cannot export group info")
.into_verifiable_group_info()
.expect("Could not get group info");
External commits can be created using a builder pattern via MlsGroup::external_commit_builder(). The ExternalCommitBuilder provides more options than join_by_external in that it allows the inclusion of SelfRemove or PSK proposals. After its first stage, the ExternalCommitBuilder turns into a regular CommitBuilder. As external commits come with a few restrictions relative to regular commits, not all CommitBuilder capabilities are exposed for external commits. Also, instead of stage_commit this CommitBuilder requires a call to finalize before it returns the new MlsGroup, as well as a CommitMessageBundle containing the external commit, as well as a potential GroupInfo.
let (mut bob_group, commit_message_bundle) = MlsGroup::external_commit_builder()
.with_ratchet_tree(tree_option.into())
.with_config(join_group_config.clone())
.with_aad(AAD.to_vec())
.build_group(
bob_provider,
verifiable_group_info,
bob_credential_with_key.clone(),
)
.expect("error building group")
.leaf_node_parameters(leaf_node_parameters)
.load_psks(bob_provider.storage())
.expect("error loading psks")
.build(
bob_provider.rand(),
bob_provider.crypto(),
&bob_signer,
|_| true,
)
.expect("error building external commit")
.finalize(bob_provider)
.expect("error finalizing external commit");
The resulting external commit message needs to be fanned out to the Delivery Service and accepted by the other members before merging this external commit.
Adding members to a group
Immediate operation
Members can be added to the group atomically with the .add_members() function. The application needs to fetch the corresponding key packages from every new member from the Delivery Service first.
let (mls_message_out, welcome, group_info) = alice_group
.add_members(
alice_provider,
&alice_signature_keys,
core::slice::from_ref(bob_key_package.key_package()),
)
.expect("Could not add members.");
The function returns the tuple (MlsMessageOut, Welcome, Option<GroupInfo>). The MlsMessageOut contains a Commit message that needs to be fanned out to existing group members. The Welcome message must be sent to the newly added members, along the optional GroupInfo if it is available.
Users could also use the new CommitBuilder API, which would look like this:
let message_bundle = alice_group
.commit_builder()
.propose_adds(Some(bob_key_package.key_package().clone()))
.load_psks(alice_provider.storage())
.expect("error loading psks")
.build(
alice_provider.rand(),
alice_provider.crypto(),
&alice_signature_keys,
|_proposal| true,
)
.expect("error validating data and building commit")
.stage_commit(alice_provider)
.expect("error staging commit");
let (mls_message_out, welcome, group_info) = message_bundle.into_contents();
Some notes on the arguments to the builder stages:
- The reason that the
KeyPackageis wrapped in aSomeis thatOption<KeyPackage>implementsIntoIterator<Item = KeyPackage>, which is the type bounds of that function. This means that the function also works with any iterator overKeyPackageitems or aVec<KeyPackage>. - The closure is a predicate over
&QueuedProposaland represents the policy of which proposals are deemed acceptable in the application.
This function returns a CommitMessageBundle, from which the MlsMessageOut, Welcome and GroupInfo can be extracted.
Adding members without update
The .add_members_without_update() function functions the same as the .add_members() function, except that it will only include an update to the sender’s key material if the sender’s proposal store includes a proposal that requires a path. For a list of proposals and an indication whether they require a path (i.e. a key material update) see Section 17.4 of RFC 9420.
Not sending an update means that the sender will not achieve post-compromise security with this particular commit. However, not sending an update saves on performance both in terms of computation and bandwidth. Using .add_members_without_update() can thus be a useful option if the ciphersuite of the group features large public keys and/or expensive encryption operations.
Proposal
Members can also be added as a proposal (without the corresponding Commit message) by using the .propose_add_member() function:
let (mls_message_out, _proposal_ref) = alice_group
.propose_add_member(
alice_provider,
&alice_signature_keys,
bob_key_package.key_package(),
)
.expect("Could not create proposal to add Bob");
In this case, the function returns an MlsMessageOut that needs to be fanned out to existing group members.
External proposal
Parties outside the group can also make proposals to add themselves to the group with an external proposal. Since those proposals are crafted by outsiders, they are always plaintext messages.
let proposal =
JoinProposal::new::<<Provider as openmls_traits::OpenMlsProvider>::StorageProvider>(
bob_key_package.key_package().clone(),
alice_group.group_id().clone(),
alice_group.epoch(),
&bob_signature_keys,
)
.expect("Could not create external Add proposal");
It is then up to the group members to validate the proposal and commit it. Note that in this scenario it is up to the application to define a proper authorization policy to grant the sender.
let alice_processed_message = alice_group
.process_message(
alice_provider,
proposal
.into_protocol_message()
.expect("Unexpected message type."),
)
.expect("Could not process message.");
match alice_processed_message.into_content() {
ProcessedMessageContent::ExternalJoinProposalMessage(proposal) => {
alice_group
.store_pending_proposal(alice_provider.storage(), *proposal)
.unwrap();
let (_commit, welcome, _group_info) = alice_group
.commit_to_pending_proposals(alice_provider, &alice_signature_keys)
.expect("Could not commit");
assert_eq!(alice_group.members().count(), 1);
alice_group
.merge_pending_commit(alice_provider)
.expect("Could not merge commit");
assert_eq!(alice_group.members().count(), 2);
let welcome: MlsMessageIn = welcome.expect("Welcome was not returned").into();
let welcome = welcome
.into_welcome()
.expect("expected the message to be a welcome message");
let bob_group = StagedWelcome::new_from_welcome(
bob_provider,
mls_group_create_config.join_config(),
welcome,
None,
)
.expect("Bob could not stage the the group join")
.into_group(bob_provider)
.expect("Bob could not join the group");
assert_eq!(bob_group.members().count(), 2);
}
_ => unreachable!(),
}
Outside parties can also make proposals to add other members as long as they are registered as part of the ExternalSendersExtension extension.
Since those proposals are crafted by outsiders, they are always public messages.
let proposal = ExternalProposal::new_add::<Provider>(
bob_key_package.key_package().clone(),
alice_group.group_id().clone(),
alice_group.epoch(),
&ds_signature_keys,
SenderExtensionIndex::new(0),
)
.expect("Could not create external Add proposal");
It is then up to one of the group members to process the proposal and commit it.
let alice_processed_message = alice_group
.process_message(
alice_provider,
proposal
.into_protocol_message()
.expect("Unexpected message type."),
)
.expect("Could not process message.");
match alice_processed_message.into_content() {
ProcessedMessageContent::ProposalMessage(proposal) => {
alice_group
.store_pending_proposal(alice_provider.storage(), *proposal)
.unwrap();
assert_eq!(alice_group.members().count(), 2);
alice_group
.commit_to_pending_proposals(alice_provider, &alice_signature_keys)
.expect("Could not commit");
alice_group
.merge_pending_commit(alice_provider)
.expect("Could not merge commit");
assert_eq!(alice_group.members().count(), 1);
}
_ => unreachable!(),
}
Removing members from a group
Immediate operation
Members can be removed from the group atomically with the .remove_members() function, which takes the KeyPackageRef of group member as input. References to the KeyPackages of group members can be obtained using the .members() function, from which one can in turn compute the KeyPackageRef using their .hash_ref() function.
let (mls_message_out, welcome_option, _group_info) = charlie_group
.remove_members(
charlie_provider,
&charlie_signature_keys,
&[bob_member.index],
)
.expect("Could not remove Bob from group.");
The function returns the tuple (MlsMessageOut, Option<Welcome>). The MlsMessageOut contains a Commit message that needs to be fanned out to existing group members.
Even though members were removed in this operation, the Commit message could potentially also cover Add Proposals previously received in the epoch. Therefore the function can also optionally return a Welcome message. The Welcome message must be sent to the newly added members.
Proposal
Members can also be removed as a proposal (without the corresponding Commit message) by using the .propose_remove_member() function:
let (mls_message_out, _proposal_ref) = alice_group
.propose_remove_member(
alice_provider,
&alice_signature_keys,
charlie_group.own_leaf_index(),
)
.expect("Could not create proposal to remove Charlie.");
In this case, the function returns an MlsMessageOut that needs to be fanned out to existing group members.
Getting removed from a group
A member is removed from a group if another member commits to a remove proposal targeting the member’s leaf. Once the to-be-removed member merges that commit via merge_staged_commit(), all other proposals in that commit will still be applied, but the group will be marked as inactive afterward. The group remains usable, e.g., to examine the membership list after the final commit was processed, but it won’t be possible to create or process new messages.
if let ProcessedMessageContent::StagedCommitMessage(staged_commit) =
bob_processed_message.into_content()
{
let remove_proposal = staged_commit
.remove_proposals()
.next()
.expect("An unexpected error occurred.");
// We construct a RemoveOperation enum to help us interpret the remove operation
let remove_operation = RemoveOperation::new(remove_proposal, &bob_group)
.expect("An unexpected Error occurred.");
match remove_operation {
RemoveOperation::WeLeft => unreachable!(),
// We expect this variant, since Bob was removed by Charlie
RemoveOperation::WeWereRemovedBy(member) => {
assert!(matches!(member, Sender::Member(member) if member == charlies_leaf_index));
}
RemoveOperation::TheyLeft(_) => unreachable!(),
RemoveOperation::TheyWereRemovedBy(_) => unreachable!(),
RemoveOperation::WeRemovedThem(_) => unreachable!(),
}
// Merge staged Commit
bob_group
.merge_staged_commit(bob_provider, *staged_commit)
.expect("Error merging staged commit.");
} else {
unreachable!("Expected a StagedCommit.");
}
// Check we didn't receive a Welcome message
assert!(welcome_option.is_none());
// Check that Bob's group is no longer active
assert!(!bob_group.is_active());
let members = bob_group.members().collect::<Vec<Member>>();
assert_eq!(members.len(), 2);
let credential0 = members[0].credential.serialized_content();
let credential1 = members[1].credential.serialized_content();
assert_eq!(credential0, b"Alice");
assert_eq!(credential1, b"Charlie");
External Proposal
Parties outside the group can also make proposals to remove members as long as they are registered as part of the ExternalSendersExtension extension.
Since those proposals are crafted by outsiders, they are always public messages.
let proposal = ExternalProposal::new_remove::<Provider>(
bob_index,
alice_group.group_id().clone(),
alice_group.epoch(),
&ds_signature_keys,
SenderExtensionIndex::new(0),
)
.expect("Could not create external Remove proposal");
It is then up to one of the group members to process the proposal and commit it.
let alice_processed_message = alice_group
.process_message(
alice_provider,
proposal
.into_protocol_message()
.expect("Unexpected message type."),
)
.expect("Could not process message.");
match alice_processed_message.into_content() {
ProcessedMessageContent::ProposalMessage(proposal) => {
alice_group
.store_pending_proposal(alice_provider.storage(), *proposal)
.unwrap();
assert_eq!(alice_group.members().count(), 2);
alice_group
.commit_to_pending_proposals(alice_provider, &alice_signature_keys)
.expect("Could not commit");
alice_group
.merge_pending_commit(alice_provider)
.expect("Could not merge commit");
assert_eq!(alice_group.members().count(), 1);
}
_ => unreachable!(),
}
Updating own leaf node
Immediate operation
Members can update their own leaf node atomically with the .self_update() function.
By default, only the HPKE encryption key is updated. The application can however also provide more parameters like a new credential, capabilities and extensions using the LeafNodeParameters struct.
let (mls_message_out, welcome_option, _group_info) = bob_group
.self_update(
bob_provider,
&bob_signature_keys,
LeafNodeParameters::default(),
)
.expect("Could not update own key package.")
.into_contents();
The function returns a CommitMessageBundle, which consists of the Commit message that needs to be fanned out to existing group members.
Even though the member updates its own leaf node only in this operation, the Commit message could potentially also cover Add Proposals that were previously received in the epoch. Therefore the CommitMessagBundle can also contain a Welcome message. The Welcome message must be sent to the newly added members.
Members can use the .self_update_with_new_signer() function to also update the Signer used to sign future MLS messages.
let new_signer_bundle = NewSignerBundle {
signer: &alice_new_signature_keys,
credential_with_key: alice_new_credential,
};
let message_bundle = alice_group
.self_update_with_new_signer(
alice_provider,
&alice_old_signature_keys,
new_signer_bundle,
LeafNodeParameters::default(),
)
.unwrap();
let (mls_message_out, welcome, group_info) = message_bundle.into_contents();
When constructing the NewSignerBundle, the Signer must match the public key and credential in the CredentialWithKey. When using self_update_with_new_signer, LeafNodeParameters may not contain a CredentialWithKey.
Proposal
Members can also update their leaf node as a proposal (without the corresponding Commit message) by using the .propose_self_update() function. Just like with the .self_update() function, optional parameters can be set through LeafNodeParameters:
let (mls_message_out, _proposal_ref) = alice_group
.propose_self_update(
alice_provider,
&alice_signature_keys,
LeafNodeParameters::default(),
)
.expect("Could not create update proposal.");
In this case, the function returns an MlsMessageOut that needs to be fanned out to existing group members.
Using Additional Authenticated Data (AAD)
The Additional Authenticated Data (AAD) is a byte sequence that can be included in both private and public messages. By design, it is always authenticated (signed) but never encrypted. Its purpose is to contain data that can be inspected but not changed while a message is in transit.
Setting the AAD
Members can set the AAD by calling the .set_aad() function. The AAD will remain set until the next API call that successfully generates an MlsMessageOut. Until then, the AAD can be inspected with the .aad() function.
alice_group.set_aad(b"Additional Authenticated Data".to_vec());
assert_eq!(alice_group.aad(), b"Additional Authenticated Data");
Inspecting the AAD
Members can inspect the AAD of an incoming message once the message has been processed. The AAD can be accessed with the .aad() function of a ProcessedMessage.
let processed_message = bob_group
.process_message(bob_provider, protocol_message)
.expect("Could not process message.");
assert_eq!(processed_message.aad(), b"Additional Authenticated Data");
Leaving a group
Members can indicate to other group members that they wish to leave the group using the leave_group() function, which creates a remove proposal targeting the member’s own leaf. The member can’t create a Commit message that covers this proposal, as that would violate the Post-compromise Security guarantees of MLS because the member would know the epoch secrets of the next epoch.
let queued_message = bob_group
.leave_group(bob_provider, &bob_signature_keys)
.expect("Could not leave group");
After successfully sending the proposal to the DS for fanout, there is still the possibility that the remove proposal is not covered in the following commit. The member leaving the group thus has two options:
- tear down the local group state and ignore all subsequent messages for that group, or
- wait for the commit to come through and process it (see also Getting Removed).
For details on creating Remove Proposals, see Removing members from a group.
Custom proposals
OpenMLS allows the creation and use of application-defined proposals. To create such a proposal, the application needs to define a Proposal Type in such a way that its value doesn’t collide with any Proposal Types defined in Section 17.4. of RFC 9420. If the proposal is meant to be used only inside of a particular application, the value of the Proposal Type is recommended to be in the range between 0xF000 and 0xFFFF, as that range is reserved for private use.
Custom proposals can contain arbitrary octet-strings as defined by the application. Any policy decisions based on custom proposals will have to be made by the application, such as the decision to include a given custom proposal in a commit, or whether to accept a commit that includes one or more custom proposals. To decide the latter, applications can inspect the queued proposals in a ProcessedMessageContent::StagedCommitMessage(staged_commit).
Example on how to use custom proposals:
// Define a custom proposal type
let custom_proposal_type = 0xFFFF;
// Define capabilities supporting the custom proposal type
let capabilities = Capabilities::new(
None,
None,
None,
Some(&[ProposalType::Custom(custom_proposal_type)]),
None,
);
// Generate KeyPackage that signals support for the custom proposal type
let bob_key_package = KeyPackageBuilder::new()
.leaf_node_capabilities(capabilities.clone())
.build(
ciphersuite,
bob_provider,
&bob_signer,
bob_credential_with_key,
)
.unwrap();
// Create a group that supports the custom proposal type
let mut alice_group = MlsGroup::builder()
.with_capabilities(capabilities.clone())
.ciphersuite(ciphersuite)
.build(alice_provider, &alice_signer, alice_credential_with_key)
.unwrap();
// Create a custom proposal based on an example payload and the custom
// proposal type defined above
let custom_proposal_payload = vec![0, 1, 2, 3];
let custom_proposal =
CustomProposal::new(custom_proposal_type, custom_proposal_payload.clone());
let (custom_proposal_message, _proposal_ref) = alice_group
.propose_custom_proposal_by_reference(
alice_provider,
&alice_signer,
custom_proposal.clone(),
)
.unwrap();
// Have bob process the custom proposal.
let processed_message = bob_group
.process_message(
bob_provider,
custom_proposal_message.into_protocol_message().unwrap(),
)
.unwrap();
let ProcessedMessageContent::ProposalMessage(proposal) = processed_message.into_content()
else {
panic!("Unexpected message type");
};
bob_group
.store_pending_proposal(bob_provider.storage(), *proposal)
.unwrap();
// Commit to the proposal
let (commit, _, _) = alice_group
.commit_to_pending_proposals(alice_provider, &alice_signer)
.unwrap();
let processed_message = bob_group
.process_message(bob_provider, commit.into_protocol_message().unwrap())
.unwrap();
let staged_commit = match processed_message.into_content() {
ProcessedMessageContent::StagedCommitMessage(staged_commit) => staged_commit,
_ => panic!("Unexpected message type"),
};
// Check that the proposal is present in the staged commit
assert!(staged_commit.queued_proposals().any(|qp| {
let Proposal::Custom(custom_proposal) = qp.proposal() else {
return false;
};
custom_proposal.proposal_type() == custom_proposal_type
&& custom_proposal.payload() == custom_proposal_payload
}));
Creating application messages
Application messages are created from byte slices with the .create_message() function:
let message_alice = b"Hi, I'm Alice!";
let mls_message_out = alice_group
.create_message(alice_provider, &alice_signature_keys, message_alice)
.expect("Error creating application message.");
Note that the theoretical maximum length of application messages is 2^32 bytes. However, messages should be much shorter in practice unless the Delivery Service can cope with long messages.
The function returns an MlsMessageOut that needs to be sent to the Delivery Service for fanout to other group members. To guarantee the best possible Forward Secrecy, the key material used to encrypt messages is immediately discarded after encryption. This means that the message author cannot decrypt application messages. If access to the message’s content is required after creating the message, a copy of the plaintext message should be kept by the application.
Committing to pending proposals
During an epoch, members can create proposals that are not immediately committed. These proposals are called “pending proposals”. They will automatically be covered by any operation that creates a Commit message (like .add_members(), .remove_members(), etc.).
Some operations (like creating application messages) are not allowed as long as pending proposals exist for the current epoch. In that case, the application must first commit to the pending proposals by creating a Commit message that covers these proposals. This can be done with the commit_to_pending_proposals() function:
let (mls_message_out, welcome_option, _group_info) = alice_group
.commit_to_pending_proposals(alice_provider, &alice_signature_keys)
.expect("Could not commit to pending proposals.");
The function returns the tuple (MlsMessageOut, Option<Welcome>). The MlsMessageOut contains a Commit message that needs to be fanned out to existing group members.
If the Commit message also covers Add Proposals previously received in the epoch, a Welcome message is required to invite the new members. Therefore the function can also optionally return a Welcome message that must be sent to the newly added members.
Processing incoming messages
Processing of incoming messages happens in different phases:
Deserializing messages
Incoming messages can be deserialized from byte slices into an MlsMessageIn:
let mls_message =
MlsMessageIn::tls_deserialize_exact(bytes).expect("Could not deserialize message.");
If the message is malformed, the function will fail with an error.
Processing messages in groups
In the next step, the message needs to be processed in the context of the corresponding group.
MlsMessageIn can carry all MLS messages, but only PrivateMessageIn and
PublicMessageIn are processed in the context of a group. In OpenMLS these two
message types are combined into a ProtocolMessage enum. There are 3 ways to
extract the messages from an MlsMessageIn:
MlsMessageIn.try_into_protocol_message()returns aResult<ProtocolMessage, ProtocolMessageError>ProtocolMessage::try_from(m: MlsMessageIn)returns aResult<ProtocolMessage, ProtocolMessageError>MlsMessageIn.extract()returns anMlsMessageBodyInenumthat has two variants forPrivateMessageInandPublicMessageIn
MlsGroup.process_message() accepts either a ProtocolMessage, a
PrivateMessageIn, or a PublicMessageIn and processes the message.
ProtocolMessage.group_id() exposes the group ID that can help the application
find the right group.
If the message was encrypted (i.e. if it was a PrivateMessageIn), it will be
decrypted automatically. The processing performs all syntactic and semantic
validation checks and verifies the message’s signature. The function finally
returns a ProcessedMessage object if all checks are successful.
let protocol_message: ProtocolMessage = mls_message
.try_into_protocol_message()
.expect("Expected a PublicMessage or a PrivateMessage");
let processed_message = bob_group
.process_message(bob_provider, protocol_message)
.expect("Could not process message.");
Interpreting the processed message
In the last step, the message is ready for inspection. The ProcessedMessage
obtained in the previous step exposes header fields such as group ID, epoch,
sender, and authenticated data. It also exposes the message’s content. There are
3 different content types:
Application messages
Application messages simply return the original byte slice:
if let ProcessedMessageContent::ApplicationMessage(application_message) =
processed_message.into_content()
{
// Check the message
assert_eq!(application_message.into_bytes(), b"Hi, I'm Alice!");
}
Proposals
Standalone proposals are returned as a QueuedProposal, indicating that they are pending proposals. The proposal can be inspected through the .proposal() function. After inspection, applications should store the pending proposal in the proposal store of the group:
if let ProcessedMessageContent::ProposalMessage(staged_proposal) =
charlie_processed_message.into_content()
{
// In the case we received an Add Proposal
if let Proposal::Add(add_proposal) = staged_proposal.proposal() {
// Check that Bob was added
assert_eq!(
add_proposal.key_package().leaf_node().credential(),
&bob_credential.credential
);
} else {
panic!("Expected an AddProposal.");
}
// Check that Alice added Bob
assert!(matches!(
staged_proposal.sender(),
Sender::Member(member) if *member == alice_group.own_leaf_index()
));
// Store proposal
charlie_group
.store_pending_proposal(charlie_provider.storage(), *staged_proposal)
.unwrap();
}
Rolling back proposals
Operations that add a proposal to the proposal store, will return its reference. This reference can be used to remove a proposal from the proposal store. This can be useful for example to roll back in case of errors.
let (_mls_message_out, proposal_ref) = alice_group
.propose_add_member(
alice_provider,
&alice_signature_keys,
bob_key_package.key_package(),
)
.expect("Could not create proposal to add Bob");
alice_group
.remove_pending_proposal(alice_provider.storage(), &proposal_ref)
.expect("The proposal was not found");
Commit messages
Commit messages are returned as StagedCommit objects. The proposals they cover can be inspected through different functions, depending on the proposal type. After the application has inspected the StagedCommit and approved all the proposals it covers, the StagedCommit can be merged in the current group state by calling the .merge_staged_commit() function. For more details, see the StagedCommit documentation.
if let ProcessedMessageContent::StagedCommitMessage(staged_commit) =
alice_processed_message.into_content()
{
// We expect a remove proposal
let remove = staged_commit
.remove_proposals()
.next()
.expect("Expected a proposal.");
// Check that Bob was removed
assert_eq!(
remove.remove_proposal().removed(),
bob_group.own_leaf_index()
);
// Check that Charlie removed Bob
assert!(matches!(
remove.sender(),
Sender::Member(member) if *member == charlies_leaf_index
));
// Merge staged commit
alice_group
.merge_staged_commit(alice_provider, *staged_commit)
.expect("Error merging staged commit.");
}
Interpreting remove operations
Remove operations can have different meanings, such as:
- We left the group (by our own wish)
- We were removed from the group (by another member or a pre-configured sender)
- We removed another member from the group
- Another member left the group (by their own wish)
- Another member was removed from the group (by a member or a pre-configured sender, but not by us)
Since all remove operations only appear as a QueuedRemoveProposal, the RemoveOperation enum can be constructed from the remove proposal and the current group state to reflect the scenarios listed above.
if let ProcessedMessageContent::StagedCommitMessage(staged_commit) =
bob_processed_message.into_content()
{
let remove_proposal = staged_commit
.remove_proposals()
.next()
.expect("An unexpected error occurred.");
// We construct a RemoveOperation enum to help us interpret the remove operation
let remove_operation = RemoveOperation::new(remove_proposal, &bob_group)
.expect("An unexpected Error occurred.");
match remove_operation {
RemoveOperation::WeLeft => unreachable!(),
// We expect this variant, since Bob was removed by Charlie
RemoveOperation::WeWereRemovedBy(member) => {
assert!(matches!(member, Sender::Member(member) if member == charlies_leaf_index));
}
RemoveOperation::TheyLeft(_) => unreachable!(),
RemoveOperation::TheyWereRemovedBy(_) => unreachable!(),
RemoveOperation::WeRemovedThem(_) => unreachable!(),
}
// Merge staged Commit
bob_group
.merge_staged_commit(bob_provider, *staged_commit)
.expect("Error merging staged commit.");
} else {
unreachable!("Expected a StagedCommit.");
}
Persistence of Group Data
The state of a given MlsGroup instance is continuously written to the configured
StorageProvider. Later, the MlsGroup can be loaded from the provider using
the load constructor, which can be called with the respective storage provider
as well as the GroupId of the group to be loaded. For this to work, the group
must have been written to the provider previously.
Forward-Secrecy Considerations
OpenMLS uses the StorageProvider to store sensitive key material. To achieve forward-secrecy (i.e. to prevent an adversary from decrypting messages sent in the past if a client is compromised), OpenMLS frequently deletes previously used key material through calls to the StorageProvider. StorageProvider implementations must thus take care to ensure that values deleted through any of the delete_ functions of the trait are irrevocably deleted and that no copies are kept.
Integrity Considerations
OpenMLS treats the StorageProvider as trusted. Persisted group state
carries invariants that OpenMLS does not check again when it reads that state
back.
An adversary that can modify stored group state is therefore outside of OpenMLS’s threat model Applications that need to cover this case should protect the storage backend itself, for example with authenticated encryption or a platform keystore.
Migrating the storage provider
OpenMLS supports migrating the storage provider between versions through a serialization bridge:
a group, together with all the group-associated data it owns, is exported with the
previous version of the library, serialized into serde_json, and
then imported into the current version of the library, which writes it back out in the new storage format.
The storage migration is a re-encoding of a member’s own stored, local state: it doesn’t change anything on the wire, and doesn’t need coordination with other group members.
This migration approach requires the migration-export feature on the previous version’s openmls
crate and the migration-import feature on the current one.
When to use this
The main migration use case is switching to a self-describing serde storage format,
between openmls versions that introduce breaking changes when using non-self-describing formats (which have not been disallowed by OpenMLS so far).
The approach described here can be used to migrate data serialized using 0.7.4 or 0.8.1
into the format used by 0.9.0, which requires a self-describing format.
Note
As of this version, non-self-describing formats are no longer supported. When using non-self-describing formats, changes in struct layouts between
openmlsversions may silently shift the layout used for serialization, and corrupt existing data.
The migration target must use a self-describing format (such as JSON), even when the source did not: a current-version group cannot currently be stored in or loaded from a non-self-describing format like postcard. Migrating in place while staying on a non-self-describing format is therefore not supported — a self-describing format should be used for the current-version store.
Migration prerequisites
- Both the previous-version and the current-version OpenMLS crates should be in the
dependency tree (e.g.
openmls_0_8_1andopenmls), the previous version with itsmigration-exportfeature, and the current one withmigration-importenabled.openmls-v0.7.4-migration-helpersopenmls-v0.8.1-migration-helpers
- A storage provider for each version: one implementing the previous version’s storage traits (holding the existing data), and one implementing the current version’s storage traits (receiving the migrated data).
Migration requirements
The following requirements must be satisfied when migrating:
- Quiescence. The migration must run while the local MLS state is at rest: no message is mid-processing, and nothing else — no other thread or process — touches the store being migrated until the migration completes. Group state that is legitimately pending at rest is fine: queued (uncommitted) proposals and a pending, not yet merged commit are both migrated and preserved.
- Atomicity. Perform the migration (together with any cleanup of the old data, see below) within a single storage transaction, so that an interruption cannot leave a group partially migrated. Any such transaction has to come from the backing store (e.g. SQLite) — the OpenMLS storage traits have no transaction API — so it is only available if your backing store supports one. When it does not — and recommended in general — migrate into a fresh store, verify, then atomically swap it in and discard the old one.
- Interruption tolerance. Import is a replace, not an append, so it is idempotent: re-running the migration after a crash is safe as long as the old data is still intact.
Prefer a fresh store over migrating in place. A transaction only protects against interruption, not against a migration defect, and an in-place migration that overwrites the same keys destroys the source data as it writes: once the transaction commits there is nothing left to verify against and nothing to roll back to. In-place with changed keys keeps the old entries longer, but its cleanup is item-wise deletion of key material in a live store, where anything missed lingers silently. Migrating into a fresh store keeps the source intact for verification and rollback, tolerates interruption even without a transactional store, and makes cleanup a simple discard. If the store itself cannot be swapped (e.g. it shares a database with other application data), it is better to migrate into a fresh table set or namespace within it, and swap that instead of overwriting.
Performing the migration
For each group, the migration is performed by exporting it using the previous version’s API,
then bridging the bundle through serde_json, and storing it with the current version:
/// Migrate a single group from the previous OpenMLS version to the current one.
///
/// `old_provider` implements the *previous* version's storage traits and already
/// holds the group; `new_provider` implements the *current* version's storage
/// traits and receives the migrated group. Both refer to the same `group_id`.
///
/// This requires the `migration-export` feature on the previous version's
/// `openmls` crate and the `migration-import` feature on the current one.
///
/// **NOTE**: The `migration-export` feature is not available on the current
/// `openmls` version, since there are no supported migration paths that would
/// utilize this feature yet, although it may be added later, if needed.
fn migrate_group(
old_provider: &PostcardProvider<'_>,
new_provider: &SerdeJsonProvider<'_>,
group_id: &openmls_compat::prelude::GroupId,
) {
// 1. Export the group and all the group-associated data it owns, using the
// *previous* version's API.
let bundle = openmls_compat::prelude::MlsGroup::export_for_migration(old_provider, group_id)
.expect("error reading the old storage")
.expect("no group with this id in the old storage");
// 2. Bridge the bundle through `serde_json` into the *current* version's
// migration bundle. The intermediate buffer is zeroized on drop (see
// `serde_json_bridge`).
let bundle: openmls_current::storage::GroupMigrationBundle =
serde_json_bridge(&bundle).expect("error bridging the migration bundle through serde_json");
// 3. Write the group and all its data to storage in the current version's
// format.
bundle
.store(new_provider)
.expect("error storing the migrated group");
}
The target store need not be serde_json: the new_provider can be any current-version
storage provider that uses a self-describing format. Only its type changes, and the
migration body is identical. For example, to migrate into a CBOR-based (ciborium) store
instead:
fn migrate_group(
old_provider: &PostcardProvider<'_>,
new_provider: &CiboriumProvider<'_>,
group_id: &openmls_compat::prelude::GroupId,
) {
// ... migration ...
}
The bundle is bridged with the small helper below, which serializes to JSON and
deserializes into the current version’s type. Because the intermediate JSON buffer
holds the group’s private keys in plaintext, it is kept in a Zeroizing buffer
that is wiped when the helper returns (see Key material hygiene).
The same helper is reused for the application-managed material further down.
/// Bridge a value across the serde_json version boundary: serialize `source` (a
/// previous-version type) to JSON, then deserialize it as the current-version type
/// `T`.
///
/// The intermediate JSON buffer holds the value's secret key material in plaintext.
/// Unlike the typed `source` and returned `T` — whose secret fields are
/// `SecretVLBytes` and are wiped on their own drop — a plain `Vec<u8>` is not
/// scrubbed when freed, so it is held in a `Zeroizing` buffer that is wiped when
/// this function returns, on the error path as well as on success.
///
/// Keep the bridge on this byte-buffer path (`to_vec` / `from_slice`)
/// rather than routing it through `serde_json::Value`.
fn serde_json_bridge<S: serde::Serialize, T: serde::de::DeserializeOwned>(
source: &S,
) -> Result<T, serde_json::Error> {
let serialized = zeroize::Zeroizing::new(serde_json::to_vec(source)?);
serde_json::from_slice(&serialized)
}
This flow is performed once per group, observing the invariants in
Migration requirements; afterwards the group loads normally
with the current-version MlsGroup::load.
What is not migrated
The migration bundle carries the group and all group-associated data OpenMLS owns — group state, queued proposals, a pending commit if stored, the group’s encryption key pairs, and the group’s resumption PSK store (which covers resumption, reinit, and branch PSKs). Application-managed material that OpenMLS does not own — signature key pairs, external PSKs (and application-component PSKs), and key packages — is not group-scoped, and is not part of the migration bundle. If you keep this data in the same store, migrate it separately with the same read → bridge → write pattern over the ids that your application tracks.
Migrating data that is managed by the application
All three cases below use the existing public storage APIs. Each takes a value (or its id) from the previous version and produces the current-version equivalent in the new store.
Signature key pairs bridge directly through serde_json:
/// Migrate one application-managed signature key pair by bridging it through
/// `serde_json`: serialize the previous version's `SignatureKeyPair`, deserialize
/// it as the current version's type, and store it in the current provider.
fn migrate_signature_key_pair(
old_signer: &openmls_basic_credential_compat::SignatureKeyPair,
new_storage: &SerdeJsonProvider<'_>,
) -> openmls_basic_credential_current::SignatureKeyPair {
let signer: openmls_basic_credential_current::SignatureKeyPair =
serde_json_bridge(old_signer).expect("bridge signer into the current version");
signer
.store(new_storage)
.expect("store the migrated signer");
signer
}
Published key packages are read from the old store by the hash reference your application tracks, bridged, and written to the new store:
/// Migrate one key package. The application supplies the hash reference
/// it tracks (OpenMLS keys stored key packages by it). We read the stored
/// `KeyPackageBundle` with the previous version's storage API, bridge it through
/// `serde_json`, and write it to the current store under its current-version hash
/// reference. Returns that current-version hash reference so the application can
/// track it and load the migrated key package back from the new store.
///
/// The two `StorageProvider` traits (previous and current version) are brought
/// into scope in separate blocks: the backing `PostcardProvider` implements both,
/// so keeping only one in scope per call avoids an ambiguous method resolution.
fn migrate_key_package<NewProvider: openmls_traits::OpenMlsProvider>(
old_storage: &PostcardProvider<'_>,
old_hash_ref: &openmls_compat::prelude::KeyPackageRef,
new_provider: &NewProvider,
) -> openmls_current::prelude::KeyPackageRef {
// 1. Read the stored bundle (public key package + private init and encryption
// keys) with the previous version's storage API.
let old_bundle: KeyPackageBundle = {
use openmls_traits_compat::storage::StorageProvider as _;
old_storage
.key_package(old_hash_ref)
.expect("read the old key package")
.expect("no key package stored under this hash ref")
};
// 2. Bridge it through `serde_json` into the current version's type.
let bundle: openmls_current::prelude::KeyPackageBundle =
serde_json_bridge(&old_bundle).expect("bridge key package into the current version");
// 3. Write it to the current store, keyed by its current-version hash ref.
let new_hash_ref = bundle
.key_package()
.hash_ref(new_provider.crypto())
.expect("compute the current key package hash ref");
{
use openmls_traits::storage::StorageProvider as _;
new_provider
.storage()
.write_key_package(&new_hash_ref, &bundle)
.expect("write the migrated key package");
}
new_hash_ref
}
Pre-shared keys split by type. Resumption PSKs — including the reinit and branch
usages — are group-owned: OpenMLS keeps them in the group’s resumption PSK store and they
travel inside the group migration bundle, so they need no separate handling. Only
external PSKs (and, under extensions-draft, application-component PSKs) are
application-managed. Their secret is supplied by the application and written with
PreSharedKeyId::store; the stored bundle is not publicly readable, so — unlike the two
cases above — there is nothing to read and bridge. Migrating one re-stores the
application-held secret under a current-version PreSharedKeyId. The nonce is not
persisted (see PreSharedKeyId::store), so only the PSK id has to match:
/// Migrate one application-managed pre-shared key (an *external* PSK, or an
/// `extensions-draft` *application* component PSK).
///
/// These PSK secrets are *supplied by the application* and written with
/// `PreSharedKeyId::store`, and the stored bundle is not publicly readable — so there
/// is nothing to read back out of the old store. The application already holds the psk
/// id and the secret bytes it tracks; migrating one is simply re-storing that secret
/// into the new provider under a current-version `PreSharedKeyId`. (The nonce is not
/// persisted, so only the psk id identifies the stored secret.)
///
/// *Resumption* PSKs (including the reinit and branch usages) are **not** handled here:
/// they are group-owned, kept in the group's resumption psk store, and are carried
/// automatically by the group migration bundle.
fn migrate_psk<NewProvider: openmls_traits::OpenMlsProvider>(
psk_id: &openmls_current::schedule::PreSharedKeyId,
secret: &[u8],
new_provider: &NewProvider,
) {
psk_id
.store(new_provider, secret)
.expect("store the migrated PSK secret");
}
This assumes the application still holds the PSK secret. If an external PSK was stored only through OpenMLS and the secret was not retained elsewhere, there is currently no public API to read it back out of the old store, so there is no migration path for it.
Migration recommendations
Verifying the migration
Before relying on the migrated store, and before any cleanup, load each group
with the current-version MlsGroup::load and sanity-check what the application
expects (e.g., the epoch and the member list). If any group fails to export,
bridge, or load, then fail closed: abort the migration, keep the old data, and report
the error, rather than continuing with a partially migrated store.
Recommendation: Store a schema-version marker alongside the data so the migration runs exactly once and the application always knows which format the store holds.
Cleaning up the old data
Whether the old data needs to be removed depends on how you migrate:
- Into a fresh or separate store: simply discard the old store once every group has been migrated; there is nothing else to clean up.
- In-place, when the storage keys change: moving between a non-self-describing
and a self-describing format generally changes how storage keys are encoded, so
the new entries are written under new keys and the old entries remain behind.
Remove them by loading each group with the previous version’s API and calling
its
MlsGroup::deleteon the old storage provider. - In-place, when the storage keys are unchanged (e.g. toggling a feature flag, or a same-format update): the import overwrites the existing entries, so no separate cleanup is needed — and deleting would remove the freshly migrated data.
Note
Rolling back to retained old data is only safe before the first use after migration. Afterwards, once a migrated group sends or processes anything, the ratchet state in the old store becomes stale, and reverting to it forks the group and risks key reuse. The old store should not be kept as a rollback path after the migrated state is used.
Running the migration in an application
There are two strategies for when to migrate. An application chooses based on how much local state it holds and how much startup latency it can absorb:
- Migrate everything at startup — simplest, and lets you discard the old store promptly, but blocks startup for as long as the migration takes.
- Migrate lazily, one group at a time — no startup stall, at the cost of the old and new stores coexisting for the whole support window.
The following apply to both strategies (particularly for an application with local storage on end-user devices, e.g. a mobile app):
- Watch for other processes touching the store. For example, an iOS Notification Service Extension that decrypts MLS messages runs in a different process and can wake on a push mid-migration; hold a cross-process lock or gate it on the migration marker.
- Expect interruption. Mobile operating systems terminate apps freely, so the migration can be cut short at any point — the idempotent design above makes this safe. The migration should be run off of the main thread.
- Plan the release lifecycle. One “migration release” of the application ships both OpenMLS versions; keep the migration path for a defined support window (an enforced minimum client version, telemetry on remaining un-migrated installs, or a stated time period). The release that finally removes it must keep the marker check and ship a fallback — resetting the local MLS state and rejoining groups — because users can jump arbitrary version gaps when updating.
Option 1: migrate everything at startup
- Run at startup, before any MLS traffic. Application startup is a natural quiescence point: gate all message processing and outbound operations on the migration having completed, checked via a single, store-wide schema-version marker.
- Migrate into a fresh store, then swap. Following Migration requirements, migrate into a fresh store, verify every group, then atomically swap it in and discard the old one.
- Check disk space. Migrating into a fresh store temporarily roughly doubles the storage footprint.
This option is the simplest and cleans up after itself, but the startup delay is proportional to the total stored state — every group’s ratchet tree and retained epochs. For an install with many or large groups that stall may be unacceptable; use Option 2 instead.
Option 2: migrate lazily, one group at a time
Migrate each group the first time the application loads it, recording a per-group marker so each group is migrated exactly once and already-migrated groups load directly from the current store:
/// Load a group from the current-version store, migrating it from the previous
/// version *on first access* if it has not been migrated yet.
///
/// This is the building block of a **lazy, per-group** migration: instead of
/// migrating every group up front (a startup stall), each group is migrated the
/// first time the application needs it, exactly once. A per-group marker — a row in
/// the application's own store, here `CiboriumProvider::mark_group_migrated` —
/// records that a group has been migrated so it is never migrated twice, and so an
/// already-migrated group is loaded directly from the current store.
fn lazy_load_or_migrate(
old_provider: &PostcardProvider<'_>,
new_provider: &CiboriumProvider<'_>,
group_id_bytes: &[u8],
) -> openmls_current::prelude::MlsGroup {
let new_group_id = openmls_current::prelude::GroupId::from_slice(group_id_bytes);
// Migrate this group only if it has not been migrated into the new store yet.
// The marker is keyed by the (current-version) group id, checked through the
// same provider used to load it.
if !new_provider.is_group_migrated(&new_group_id) {
let old_group_id = openmls_compat::prelude::GroupId::from_slice(group_id_bytes);
let bundle =
openmls_compat::prelude::MlsGroup::export_for_migration(old_provider, &old_group_id)
.expect("error reading the old storage")
.expect("no group with this id in the old storage");
let bundle: openmls_current::storage::GroupMigrationBundle =
serde_json_bridge(&bundle).expect("error bridging the migration bundle");
bundle
.store(new_provider)
.expect("error storing the migrated group");
// Set the marker
new_provider.mark_group_migrated(&new_group_id);
}
openmls_current::prelude::MlsGroup::load(new_provider, &new_group_id)
.expect("error loading the migrated group")
.expect("no migrated group state persisted")
}
The helper above is deliberately minimal: it assumes every group still lives in the old store. A shipped version must also handle groups created or joined under the new version — those were never in the old store, so they should load directly from the current store rather than attempt an export that would find nothing. Setting the marker when the application creates or joins a group lets them skip the check.
Guidance specific to this strategy:
- The marker is per-group, not store-wide. Store it in the current store, keyed by group id the same way the store keys the group’s own data, and set it only after that group’s migrated state has been written. Because import is an idempotent replace, an interruption before the marker is set simply re-runs that group’s migration on the next access — no transaction required.
- Lock per group. A group’s first access can come from more than one place at the same time — e.g. the UI opening a conversation while a push handler processes a message for it, possibly in a different process. Take a lock keyed by the group id that spans the marker check, the migration, and setting the marker, so a given group is accessed by only one caller at a time. (A single global lock also works — e.g. relying on SQLite’s single-writer — trading some concurrency for simplicity.) A single, shared group-loading path can be a convenient place to enforce this together with the migrate-on-access check.
- Migrate application-managed material eagerly. Signature key pairs, key packages, and PSKs are not group-scoped (a signature key pair can back several groups), so they cannot be partitioned per group. They are also small, so migrate them up front: this keeps any residual startup cost tiny while the expensive per-group state migrates on demand, and guarantees a lazily-migrated group is immediately operable.
- Quiescence is per-group. Migrate a group before processing its traffic, and gate that group’s processing (including from other processes) on its marker.
- The two stores coexist for the whole support window. You cannot discard the
old store promptly or swap it out, because un-accessed groups still live there.
Clean up each group’s old data after it has been migrated (via the previous
version’s
MlsGroup::delete, see Cleaning up the old data) . Track how many groups remain un-migrated, so you know when it is safe to retire the old-version code. - Rollback is per-group. The “rollback only before first use” rule applies to each group independently: once a lazily-migrated group has been used, its old copy is stale and must not be reverted to.
- Binary size. Only code reachable from
export_for_migrationis linked from the previous version — storage reads and serde impls, no crypto backend, no protocol machinery — and the linker strips the rest. Measure (e.g. withcargo bloat) if you are concerned about binary size.
Key material hygiene
-
Never log or upload the migration bundle. The serialized
GroupMigrationBundlecontains private encryption keys in plaintext JSON. Error reports and diagnostics must not include the migration bundle, value diffs, or any deserialization error messages that embed the offending content. The bundle should also stay in memory, and never be written to a temp file for debugging. -
Create the fresh store with the same protections as the old. Same at-rest encryption (e.g. SQLCipher key), and on desktop, restrictive file permissions set before the data is written, not fixed up after.
-
Wipe the intermediate serialized buffer. The
serde_json_bridgehelper keeps the plaintext-JSON buffer in aZeroizingwrapper so it is scrubbed on drop. This approach is best-effort, sinceserde_jsonmay make intermediate copies during (de)serialization that cannot be reached and cleared. Keep the bridge on theto_vec/from_slicebyte path rather thanserde_json::Value.
Discarding commits
The delivery service may reject a commit sent by a client. In this case, the application needs to ensure that the local state remains the same as it was before the commit was staged.
Cleaning up local state after discarded commits
Generally, if a commit is discarded (e.g. due to being rejected by the Delivery Service), it can be cleaned up by the application in the following way:
// clear pending commit and reset state
alice_group
.clear_pending_commit(alice_provider.storage())
.unwrap();
In general, the application only needs to complete the cleanup above in order to fully restore the local state to the way it was before the commit was staged.
In several other cases, additional cleanup may need to be done.
ExternalJoin
If a staged commit containing an external join proposal must be discarded, the entire MlsGroup instance should be discarded by the application.
// delete the `MlsGroup`
bob_group
.delete(bob_provider.storage())
.expect("Could not delete the group");
PreSharedKey
In addition to clearing the staged commit, the application may also clear the pre-shared key from storage.
// clear the psk that was stored earlier, if necessary
alice_provider
.storage()
.delete_psk(&psk)
.expect("Could not delete stored psk");
// clear pending commit and reset state
alice_group
.clear_pending_commit(alice_provider.storage())
.expect("Could not clear pending commit");
Self Update
The storage provider may also be used by the application to store signature keypairs. For self updates that update a signature keypair for the client, if the application has stored a new keypair in the storage provider at this point, it can be deleted from the storage provider here.
Credential validation
Credential validation is a process that allows a member to verify the validity of the credentials of other members in the group. The process is described in detail in the MLS protocol specification.
In practice, the application should check the validity of the credentials of other members in two instances:
- When joining a new group (by looking at the ratchet tree)
- When processing messages (by looking at a add & update proposals of a StagedCommit)
Working with AppData
Important
Currently this functionality is behind the
extensions-draftfeature.
So far, applications could store group state that all members should agree on in custom
extensions.
The MLS Extensions draft specifies a new mechanism to encode application data in the
group state via the AppDataDictionary extension.
When using custom extensions for this purpose, every update message contains the full new state,
for example in a GroupContextExtensionProposal.
The AppDataUpdate proposal allows sending only a diff, which the application interprets to produce
the new state in the AppDataDictionary.
This is very flexible and allows implementing a wide range of diff-style approaches. However, it puts more burden on the application, since it needs to validate and process the updates itself to produce the new state.
Note
The extensions draft specifies ComponentIDs to be 32 bit, but after publishing this was reduced to 16 bit. We are using 16 bit ComponentIDs. More context in issue mls-extensions#69
To demonstrate the API, we need a custom component that we keep in the group.
Setting up a custom Component
Each application component needs:
- A unique
ComponentId(we’ll use0xf042, which is in the private range0x8000..0xffff) - A data format for the stored state
- A data format for updates (the “diff”)
- Application logic to process updates and compute new state
For this example, we’ll build a simple counter where:
- The stored state is the counter value as a big-endian
u32 - Updates are a single byte:
0x01= increment,0x02= decrement - Incrementing a counter that hasn’t been set yet initializes it to 1
- Decrementing below zero is invalid and will cause the commit to be rejected
/// Our counter component ID (in the private range 0x8000..0xffff)
const COUNTER_COMPONENT_ID: ComponentId = 0xf042;
/// The operations that can be performed on the counter
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CounterOperation {
Increment = 0x01,
Decrement = 0x02,
}
impl CounterOperation {
fn from_byte(byte: u8) -> Option<Self> {
match byte {
0x01 => Some(CounterOperation::Increment),
0x02 => Some(CounterOperation::Decrement),
_ => None,
}
}
fn to_bytes(self) -> Vec<u8> {
vec![self as u8]
}
}
/// Error type for counter operations
#[derive(Debug, Clone, PartialEq, Eq)]
enum CounterError {
/// Attempted to decrement below zero
Underflow,
/// Invalid operation byte
InvalidOperation,
}
/// Process a list of counter updates, returning the new counter value.
///
/// - `current_value`: The current counter value (None if not yet set)
/// - `updates`: Iterator of update payloads (each is a single byte)
///
/// Returns the new counter value, or an error if the updates are invalid.
fn process_counter_updates<'a>(
current_value: Option<&[u8]>,
updates: impl Iterator<Item = &'a [u8]>,
) -> Result<Vec<u8>, CounterError> {
// Parse current value as big-endian u32, defaulting to 0
let mut counter: u32 = current_value
.map(|bytes| {
let arr: [u8; 4] = bytes.try_into().unwrap_or([0; 4]);
u32::from_be_bytes(arr)
})
.unwrap_or(0);
// Apply each update
for update in updates {
let op_byte = update.first().ok_or(CounterError::InvalidOperation)?;
let op = CounterOperation::from_byte(*op_byte).ok_or(CounterError::InvalidOperation)?;
match op {
CounterOperation::Increment => {
counter = counter.saturating_add(1);
}
CounterOperation::Decrement => {
counter = counter.checked_sub(1).ok_or(CounterError::Underflow)?;
}
}
}
Ok(counter.to_be_bytes().to_vec())
}
Next, we crate the group.
Group Setup
Both the group and its members must advertise support for AppDataUpdate proposals and the AppDataDictionary extension. This is done through capabilities and required capabilities.
/// Set up a group with AppDataUpdate support.
///
/// This creates Alice and Bob with the required capabilities and creates
/// a group where AppDataUpdate proposals are supported.
fn setup_group_with_app_data_support<'a, Provider: OpenMlsProvider>(
alice_party: &'a CorePartyState<Provider>,
bob_party: &'a CorePartyState<Provider>,
ciphersuite: Ciphersuite,
) -> GroupState<'a, Provider> {
// Define capabilities that include AppDataDictionary extension
// and AppDataUpdate proposal support
let capabilities = Capabilities::new(
None, // protocol versions (default)
None, // ciphersuites (default)
Some(&[ExtensionType::AppDataDictionary]),
Some(&[ProposalType::AppDataUpdate]),
None, // credentials (default)
);
// The group context must require these capabilities so that
// all members are guaranteed to support them
let required_capabilities_extension =
Extension::RequiredCapabilities(RequiredCapabilitiesExtension::new(
&[ExtensionType::AppDataDictionary], // required extensions
&[ProposalType::AppDataUpdate], // required proposals
&[], // required credentials
));
// Create pre-group states with the capabilities
let alice_pre_group = alice_party
.pre_group_builder(ciphersuite)
.with_leaf_node_capabilities(capabilities.clone())
.build();
let bob_pre_group = bob_party
.pre_group_builder(ciphersuite)
.with_leaf_node_capabilities(capabilities.clone())
.build();
// Configure the group with required capabilities
let create_config = MlsGroupCreateConfig::builder()
.ciphersuite(ciphersuite)
.capabilities(capabilities)
.use_ratchet_tree_extension(true)
.with_group_context_extensions(
Extensions::single(required_capabilities_extension).expect("valid extensions"),
)
.build();
let join_config = create_config.join_config().clone();
// Alice creates the group
let mut group_state = GroupState::new_from_party(
GroupId::from_slice(b"CounterGroup"),
alice_pre_group,
create_config,
)
.expect("failed to create group");
// Alice adds Bob
group_state
.add_member(AddMemberConfig {
adder: "alice",
addees: vec![bob_pre_group],
join_config,
tree: None,
})
.expect("failed to add Bob");
group_state
}
Sending and receiving proposals
This part doesn’t really change.
Alice sends a proposal to increment the counter:
// Alice sends a standalone proposal to increment the counter.
// This proposal will be included in a later commit by reference.
let (proposal_message, _proposal_ref) = alice
.group
.propose_app_data_update(
&alice_party.provider,
&alice.party.signer,
COUNTER_COMPONENT_ID,
AppDataUpdateOperation::Update(CounterOperation::Increment.to_bytes().into()),
)
.expect("failed to create proposal");
Bob receives and stores the proposal:
// Bob receives and stores the proposal
let processed_proposal = bob
.group
.process_message(
&bob_party.provider,
proposal_message
.into_protocol_message()
.expect("failed to convert Proposal MlsMessageOut to ProtocolMessage"),
)
.expect("failed to process proposal");
// Verify it's a proposal and store it
match processed_proposal.into_content() {
ProcessedMessageContent::ProposalMessage(proposal) => {
bob.group
.store_pending_proposal(bob_party.provider.storage(), *proposal)
.expect("failed to store proposal");
}
_ => panic!("expected a proposal message"),
}
Sending Commits
Now, Alice creates a commit that includes:
- The previously sent proposal (by reference, from her proposal store)
- One additional increment proposal (inline)
An important change is that Alice must compute the resulting state herself before building the commit:
// Alice creates a commit that includes:
// - The previously sent proposal (by reference, from her proposal store)
// - Two additional increment proposals (inline)
let mut commit_stage = alice
.group
.commit_builder()
.add_proposals(vec![
// Two more increments as inline proposals
Proposal::AppDataUpdate(Box::new(AppDataUpdateProposal::update(
COUNTER_COMPONENT_ID,
CounterOperation::Increment.to_bytes(),
))),
])
.load_psks(alice_party.provider.storage())
.expect("failed to load PSKs");
// Alice must compute the resulting state before building the commit.
// She iterates over all AppDataUpdate proposals (both from the proposal
// store and inline proposals).
let mut alice_updater = commit_stage.app_data_dictionary_updater();
process_app_data_proposals(&mut alice_updater, commit_stage.app_data_update_proposals())
.expect("failed to process proposals");
// Provide the computed changes to the commit builder
commit_stage.with_app_data_dictionary_updates(alice_updater.changes());
// Build and stage the commit
let commit_bundle = commit_stage
.build(
alice_party.provider.rand(),
alice_party.provider.crypto(),
&alice.party.signer,
|_proposal| true, // accept all proposals
)
.expect("failed to build commit")
.stage_commit(&alice_party.provider)
.expect("failed to stage commit");
let (commit_message, _welcome, _group_info) = commit_bundle.into_contents();
Receiving Commits
Bob receives the commit and must independently compute the same new state.
When a commit covers AppDataUpdate proposals, process_message returns it as
ProcessedMessageContent::UnresolvedAppDataCommit instead of a staged commit:
the message signature has already been verified, but staging is paused until
the application has interpreted the proposals. Bob iterates over the covered
proposals (references are already resolved from his proposal store), computes
the new state and resumes staging with stage_app_data_commit:
// Bob receives the commit and must independently compute the same new state.
// Process the message. Since the commit covers AppDataUpdate proposals,
// it comes back as an UnresolvedAppDataCommit: the signature has been
// verified, but staging is paused until Bob supplies the computed updates.
let commit_in: MlsMessageIn = commit_message.into();
let processed_message = bob
.group
.process_message(
&bob_party.provider,
commit_in
.into_protocol_message()
.expect("not a protocol message"),
)
.expect("failed to process message");
let unresolved_commit = match processed_message.into_content() {
ProcessedMessageContent::UnresolvedAppDataCommit(unresolved_commit) => unresolved_commit,
_ => panic!("expected an unresolved app data commit"),
};
// Create an updater for Bob and compute the new state. The proposals are
// already verified, resolved from the proposal store and sorted by
// component ID.
let mut bob_updater = bob.group.app_data_dictionary_updater();
process_app_data_proposals(
&mut bob_updater,
unresolved_commit.app_data_update_proposals(),
)
.expect("failed to process proposals");
let updates = bob_updater.changes();
// Resume staging with the computed updates
let staged_commit = bob
.group
.stage_app_data_commit(&bob_party.provider, *unresolved_commit, updates)
.expect("failed to stage commit");
bob.group
.merge_staged_commit(&bob_party.provider, staged_commit)
.expect("failed to merge commit");
After both parties merge, they should have identical state:
// Both parties should now have identical state
assert_eq!(
alice.group.extensions().app_data_dictionary(),
bob.group.extensions().app_data_dictionary(),
"dictionaries should match"
);
// Verify the counter value is 3 (three increments)
let alice_dict = alice
.group
.extensions()
.app_data_dictionary()
.expect("dictionary should exist");
let counter_bytes = alice_dict
.dictionary()
.get(&COUNTER_COMPONENT_ID)
.expect("counter should exist");
let counter_value = u32::from_be_bytes(counter_bytes.try_into().expect("invalid length"));
assert_eq!(counter_value, 2, "counter should be 2 after two increments");
Error Handling: Invalid Updates
If an update would result in invalid state (e.g., decrementing below zero), the application should reject the commit. Here’s what happens when Alice tries to decrement an unset counter:
// Alice tries to decrement an unset counter, which should fail.
let commit_stage = alice
.group
.commit_builder()
.add_proposals(vec![Proposal::AppDataUpdate(Box::new(
AppDataUpdateProposal::update(
COUNTER_COMPONENT_ID,
CounterOperation::Decrement.to_bytes(),
),
))])
.load_psks(alice_party.provider.storage())
.expect("failed to load PSKs");
let mut alice_updater = commit_stage.app_data_dictionary_updater();
let proposals: Vec<_> = commit_stage.app_data_update_proposals().collect();
// This should fail because we can't decrement below zero
let result = process_app_data_proposals(&mut alice_updater, proposals.into_iter());
assert_eq!(
result,
Err(CounterError::Underflow),
"decrementing unset counter should fail"
);
// Alice should not proceed with the commit since the state is invalid.
// In a real application, you would handle this error appropriately,
// perhaps by notifying the user or choosing different proposals.
The application detects the invalid state during proposal processing and can choose not to proceed with the commit (on the sender side) or reject the message (on the receiver side).
Verifying Consistency
GREASE Support
GREASE (Generate Random Extensions And Sustain Extensibility) is a mechanism defined in RFC 9420 Section 13.5 to ensure that implementations properly handle unknown values and maintain protocol extensibility.
What is GREASE?
GREASE values are special reserved values that follow a specific pattern (0x0A0A, 0x1A1A, 0x2A2A, ..., 0xEAEA) and are used to:
- Test extensibility: Ensure implementations don’t reject messages containing unknown values
- Prevent ossification: Help maintain forward compatibility by exercising unknown value handling paths
- Identify bugs: Catch implementations that incorrectly assume all possible values are known
RFC-defined vs. Custom GREASE Values
The 15 values defined in RFC 9420 are the “official” GREASE values. When OpenMLS generates GREASE values (e.g., via with_grease()), it uses these RFC-defined values. The is_grease() method only returns true for these specific values.
However, any unknown value can serve a similar purpose. The difference is that for non-RFC values, we cannot distinguish whether they are intentionally injected “GREASE-like” values or genuinely unknown identifiers from future protocol extensions.
The Purpose of GREASE
Important: The entire point of GREASE is that implementations should not check for these values. GREASE values exist to ensure that your unknown-value handling code paths are exercised. Applications can inject GREASE values into their capabilities to discourage other implementations from:
- Failing on unknown values
- Hard-coding assumptions about which values exist
- Breaking when the protocol is extended
See Important Notes for details on how OpenMLS handles GREASE during validation.
GREASE in OpenMLS
OpenMLS supports GREASE values for the following types:
- Ciphersuites (
VerifiableCiphersuite) - Extensions (
ExtensionType::Grease) - Proposals (
ProposalType::Grease) - Credentials (
CredentialType::Grease)
GREASE Handling
OpenMLS:
- Recognizes GREASE values during deserialization
- Filters GREASE values during validation to prevent false negatives (GREASE values are treated the same as unknown values)
- Preserves GREASE values when present in capabilities
- Provides convenience methods to inject random GREASE values into capabilities
Note: GREASE values are NOT automatically injected. Library users who wish to include GREASE values in their capabilities should use the with_grease() method described below.
Using GREASE Values
In Capabilities
You can include GREASE values in your KeyPackage capabilities to test interoperability:
#![allow(unused)]
fn main() {
use openmls::prelude::*;
let capabilities = Capabilities::builder()
.proposals(vec![
ProposalType::Add,
ProposalType::Update,
ProposalType::Remove,
ProposalType::Grease(0x0A0A), // Add a GREASE proposal type
])
.extensions(vec![
ExtensionType::ApplicationId,
ExtensionType::Grease(0x1A1A), // Add a GREASE extension type
])
.credentials(vec![
CredentialType::Basic,
CredentialType::Grease(0x2A2A), // Add a GREASE credential type
])
.build();
}
Injecting Random GREASE Values
The easiest way to add GREASE values is using the with_grease() method on Capabilities or CapabilitiesBuilder:
#![allow(unused)]
fn main() {
use openmls::prelude::*;
use openmls_rust_crypto::OpenMlsRustCrypto;
let provider = OpenMlsRustCrypto::default();
// Using CapabilitiesBuilder
let capabilities = Capabilities::builder()
.with_grease(provider.rand())
.build();
// Or on an existing Capabilities instance
let capabilities = Capabilities::default()
.with_grease(provider.rand());
}
This will add one random GREASE value to each capability list (ciphersuites, extensions, proposals, and credentials) if no GREASE value is already present.
Generating Individual Random GREASE Values
OpenMLS also provides a helper function to generate individual random GREASE values:
#![allow(unused)]
fn main() {
use openmls::grease::random_grease_value;
use openmls_rust_crypto::OpenMlsRustCrypto;
let crypto = OpenMlsRustCrypto::default();
let grease_value = random_grease_value(&crypto);
// Use in capabilities
let grease_proposal = ProposalType::Grease(grease_value);
}
Checking for GREASE Values
All GREASE-capable types provide an is_grease() method. Note that this method only identifies the RFC-defined GREASE values—it cannot detect custom unknown values that serve a similar purpose.
Caution: As explained in The Purpose of GREASE, applications should generally not use is_grease() for filtering or decision-making. The method exists primarily for OpenMLS’s internal validation logic and for testing/debugging purposes.
#![allow(unused)]
fn main() {
use openmls::prelude::*;
let proposal = ProposalType::Grease(0x0A0A);
assert!(proposal.is_grease());
let extension = ExtensionType::Grease(0x1A1A);
assert!(extension.is_grease());
let credential = CredentialType::Grease(0x2A2A);
assert!(credential.is_grease());
use openmls_traits::types::VerifiableCiphersuite;
let ciphersuite = VerifiableCiphersuite::new(0x3A3A);
assert!(ciphersuite.is_grease());
}
GREASE Values
The following 15 values are defined as GREASE values in RFC 9420:
0x0A0A0x1A1A0x2A2A0x3A3A0x4A4A0x5A5A0x6A6A0x7A7A0x8A8A0x9A9A0xAAAA0xBABA0xCACA0xDADA0xEAEA
Important Notes
GREASE Values Cannot Be Used for Operations
GREASE ciphersuites, in particular, cannot be used for actual cryptographic operations. They exist only to test capability negotiation and should never be selected as the active ciphersuite for a group.
Validation Automatically Filters GREASE
When OpenMLS validates capabilities, it automatically filters out GREASE values. This means:
- Two members with different GREASE values in their capabilities can still interoperate
- GREASE values don’t affect capability intersection or matching
- Required capabilities never include GREASE values
Interoperability Testing
Including GREASE values in your capabilities is recommended for testing interoperability with other MLS implementations. It helps ensure that:
- Other implementations correctly handle unknown values
- Your implementation correctly filters GREASE during validation
- Protocol extensibility is maintained
Example: Full Usage
Here’s a complete example showing GREASE usage with the recommended with_grease() method:
#![allow(unused)]
fn main() {
use openmls::prelude::*;
use openmls_basic_credential::SignatureKeyPair;
use openmls_rust_crypto::OpenMlsRustCrypto;
use openmls_traits::types::Ciphersuite;
let provider = OpenMlsRustCrypto::default();
// Create a credential
let credential = BasicCredential::new(b"Alice".to_vec());
let signature_keys = SignatureKeyPair::new(
Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519.signature_algorithm()
).unwrap();
// Create capabilities with automatic random GREASE values
let capabilities = Capabilities::builder()
.with_grease(provider.rand())
.build();
// Create a KeyPackage with GREASE values
let key_package = KeyPackage::builder()
.leaf_node_capabilities(capabilities)
.build(
Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519,
&provider,
&signature_keys,
CredentialWithKey {
credential: credential.into(),
signature_key: signature_keys.public().into(),
},
)
.unwrap();
// The KeyPackage can be used normally - GREASE values are handled during validation
}
Example: Manually Specifying GREASE Values
If you need to specify particular GREASE values (e.g., for testing or interoperability):
#![allow(unused)]
fn main() {
use openmls::prelude::*;
use openmls_basic_credential::SignatureKeyPair;
use openmls_rust_crypto::OpenMlsRustCrypto;
use openmls_traits::types::Ciphersuite;
let provider = OpenMlsRustCrypto::default();
// Create a credential
let credential = BasicCredential::new(b"Alice".to_vec());
let signature_keys = SignatureKeyPair::new(
Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519.signature_algorithm()
).unwrap();
// Create capabilities with specific GREASE values
let capabilities = Capabilities::builder()
.proposals(vec![
ProposalType::Add,
ProposalType::Update,
ProposalType::Remove,
ProposalType::Grease(0x0A0A),
ProposalType::Grease(0x1A1A),
])
.extensions(vec![
ExtensionType::ApplicationId,
ExtensionType::Grease(0x2A2A),
])
.credentials(vec![
CredentialType::Basic,
CredentialType::Grease(0x3A3A),
])
.build();
// Create a KeyPackage with these capabilities
let key_package = KeyPackage::builder()
.leaf_node_capabilities(capabilities)
.build(
Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519,
&provider,
&signature_keys,
CredentialWithKey {
credential: credential.into(),
signature_key: signature_keys.public().into(),
},
)
.unwrap();
}
Further Reading
- RFC 9420 Section 13.5: GREASE
- RFC 8701: Applying GREASE to TLS Extensibility - The original GREASE specification for TLS
WebAssembly
OpenMLS can be built for WebAssembly. However, it does require two features that WebAssembly itself does not provide: access to secure randomness and the current time. Currently, this means that it can only run in a runtime that provides common JavaScript APIs (e.g. in the browser or node.js), accessed through the web_sys crate.
You can enable the js feature on the openmls crate to signal that the APIs are available.
Fork Resolution
If members of a group merge different commits, the group state is called forked.
At this point, the group members have different keys and will not be able to decrypt
each others’ messages. While this should not happen in normal operation, it may
still occur due to bugs. When enabling the fork-resolution-helpers feature,
OpenMLS comes with helpers to get a working group again. There are two helpers,
and they use different mechanisms.
The readd helper removes and then re-adds members that are forked. This requires
that the caller knows the set of members that are forked. It is relatively
efficient, especially if only a small number of members forked.
The reboot helper creates a new group and helps with migrating the entire group
state over. This includes extensions in the group context, as well as re-inviting
all the members.
We provide examples for how to use both, and in the end provide some guidance on detecting forks.
readd Example
First, let’s create a forked group. In this example, Alice creates a group and adds Bob. Then, they both merge different commits to add Charlie.
// Alice creates a group
let mut alice_group = MlsGroup::new(
alice_provider,
&alice_signature_keys,
&mls_group_create_config,
alice_credential.clone(),
)
.unwrap();
// Alice adds Bob and merges the commit
let add_bob_messages = alice_group
.commit_builder()
.propose_adds(vec![bob_kpb.key_package().clone()])
.load_psks(alice_provider.storage())
.unwrap()
.build(
alice_provider.rand(),
alice_provider.crypto(),
&alice_signature_keys,
|_| true,
)
.unwrap()
.stage_commit(alice_provider)
.unwrap();
alice_group.merge_pending_commit(alice_provider).unwrap();
// Bob joins from the welcome
let welcome = add_bob_messages.into_welcome().unwrap();
let mut bob_group =
StagedWelcome::new_from_welcome(bob_provider, mls_group_config, welcome.clone(), None)
.unwrap()
.into_group(bob_provider)
.unwrap();
// Now Alice and Bob both add Charlie and merge their own commit.
// This forks the group.
let charlie_kpb = generate_key_package(
ciphersuite,
charlie_credential,
Extensions::empty(),
charlie_provider,
&charlie_signature_keys,
);
let add_charlie_messages = alice_group
.commit_builder()
.propose_adds(vec![charlie_kpb.key_package().clone()])
.load_psks(alice_provider.storage())
.unwrap()
.build(
alice_provider.rand(),
alice_provider.crypto(),
&alice_signature_keys,
|_| true,
)
.unwrap()
.stage_commit(alice_provider)
.unwrap();
bob_group
.commit_builder()
.propose_adds(vec![charlie_kpb.key_package().clone()])
.load_psks(bob_provider.storage())
.unwrap()
.build(
bob_provider.rand(),
bob_provider.crypto(),
&bob_signature_keys,
|_| true,
)
.unwrap()
.stage_commit(bob_provider)
.unwrap();
alice_group.merge_pending_commit(alice_provider).unwrap();
bob_group.merge_pending_commit(bob_provider).unwrap();
// Charlie joins using Alice's invite
let welcome = add_charlie_messages.into_welcome().unwrap();
let mut charlie_group =
StagedWelcome::new_from_welcome(charlie_provider, mls_group_config, welcome, None)
.unwrap()
.into_group(charlie_provider)
.unwrap();
// We should be forked now, double-check
// Alice and Charlie are on the same state
assert_eq!(
alice_group.confirmation_tag(),
charlie_group.confirmation_tag()
);
// But Bob is different from the other two
assert_ne!(bob_group.confirmation_tag(), alice_group.confirmation_tag());
assert_ne!(
bob_group.confirmation_tag(),
charlie_group.confirmation_tag()
);
Then, Alice removes and re-adds Bob using the helper. We assume here that Alice knows that only Bob merged the wrong commit. This information needs to be transferred somehow, see Fork Detection. Notice how Alice needs to provide a new key package for Bob.
// Let Alice re-add the members of the other partition (i.e. Bob)
let bob_new_kpb = generate_key_package(
ciphersuite,
bob_credential,
Extensions::empty(),
bob_provider,
&bob_signature_keys,
);
// Alice and Charlie are in the same partition
let our_partition = &[alice_group.own_leaf_index(), charlie_group.own_leaf_index()];
let builder = alice_group.recover_fork_by_readding(our_partition).unwrap();
// Here we iterate over the members of the complement partition to get their key packages.
// In this example this is trivial, but the pattern extends to more realistic scenarios.
let readded_key_packages = builder
.complement_partition()
.iter()
.map(|member| {
let basic_credential = BasicCredential::try_from(member.credential.clone()).unwrap();
match basic_credential.identity() {
b"Bob" => bob_new_kpb.key_package().clone(),
other => panic!(
"we only expect bob to be re-added, but found {:?}",
String::from_utf8(other.to_vec()).unwrap()
),
}
})
.collect();
// Specify the key packages to be re-added and create the commit
let readd_messages = builder
.provide_key_packages(readded_key_packages)
.load_psks(alice_provider.storage())
.unwrap()
.build(
alice_provider.rand(),
alice_provider.crypto(),
&alice_signature_keys,
|_| true,
)
.unwrap()
.stage_commit(alice_provider)
.unwrap();
// Make Bob re-join the group and Alice and Charlie merge the commit that adds Bob.
let (commit, welcome, _) = readd_messages.into_contents();
let welcome = welcome.unwrap();
let processed_welcome =
ProcessedWelcome::new_from_welcome(bob_provider, mls_group_config, welcome).unwrap();
let bob_group = JoinBuilder::new(bob_provider, processed_welcome)
.replace_old_group()
.build()
.unwrap()
.into_group(bob_provider)
.unwrap();
alice_group.merge_pending_commit(alice_provider).unwrap();
if let ProcessedMessageContent::StagedCommitMessage(staged_commit) = charlie_group
.process_message(charlie_provider, commit.into_protocol_message().unwrap())
.unwrap()
.into_content()
{
charlie_group
.merge_staged_commit(charlie_provider, *staged_commit)
.unwrap()
} else {
panic!("expected a commit")
}
// The fork should be fixed now, double-check
assert_eq!(alice_group.confirmation_tag(), bob_group.confirmation_tag());
assert_eq!(
alice_group.confirmation_tag(),
charlie_group.confirmation_tag()
);
assert_eq!(
charlie_group.confirmation_tag(),
bob_group.confirmation_tag()
);
In the end, they all can communicate again.
reboot Example
Again, let’s create a forked group. In this example, Alice creates a group and adds Bob. Then, they both merge different commits to add Charlie.
// Alice creates a group
let mut alice_group = MlsGroup::new(
alice_provider,
&alice_signature_keys,
&mls_group_create_config,
alice_credential.clone(),
)
.unwrap();
// Alice adds Bob and merges the commit
let add_bob_messages = alice_group
.commit_builder()
.propose_adds(vec![bob_kpb.key_package().clone()])
.load_psks(alice_provider.storage())
.unwrap()
.build(
alice_provider.rand(),
alice_provider.crypto(),
&alice_signature_keys,
|_| true,
)
.unwrap()
.stage_commit(alice_provider)
.unwrap();
alice_group.merge_pending_commit(alice_provider).unwrap();
// Bob joins from the welcome
let welcome = add_bob_messages.into_welcome().unwrap();
let mut bob_group =
StagedWelcome::new_from_welcome(bob_provider, mls_group_config, welcome, None)
.unwrap()
.into_group(bob_provider)
.unwrap();
// Now Alice and Bob both add Charlie and merge their own commit.
// This forks the group.
let charlie_kpb = generate_key_package(
ciphersuite,
charlie_credential.clone(),
Extensions::empty(),
charlie_provider,
&charlie_signature_keys,
);
let add_charlie_messages = alice_group
.commit_builder()
.propose_adds(vec![charlie_kpb.key_package().clone()])
.load_psks(alice_provider.storage())
.unwrap()
.build(
alice_provider.rand(),
alice_provider.crypto(),
&alice_signature_keys,
|_| true,
)
.unwrap()
.stage_commit(alice_provider)
.unwrap();
bob_group
.commit_builder()
.propose_adds(vec![charlie_kpb.key_package().clone()])
.load_psks(bob_provider.storage())
.unwrap()
.build(
bob_provider.rand(),
bob_provider.crypto(),
&bob_signature_keys,
|_| true,
)
.unwrap()
.stage_commit(bob_provider)
.unwrap();
alice_group.merge_pending_commit(alice_provider).unwrap();
bob_group.merge_pending_commit(bob_provider).unwrap();
// Charlie joins using Alice's invite
let welcome = add_charlie_messages.into_welcome().unwrap();
let charlie_group =
StagedWelcome::new_from_welcome(charlie_provider, mls_group_config, welcome, None)
.unwrap()
.into_group(charlie_provider)
.unwrap();
// We shoulkd be forked now, double-check
// Alice and Charlie are on the same state
assert_eq!(
alice_group.confirmation_tag(),
charlie_group.confirmation_tag()
);
// But Bob is different from the other two
assert_ne!(bob_group.confirmation_tag(), alice_group.confirmation_tag());
assert_ne!(
bob_group.confirmation_tag(),
charlie_group.confirmation_tag()
);
Then, Alice sets up a new group and adds everyone from the old group. In this approach, she not only needs to provide key packages for all members, but also set a new group id and migrate the group context extensions, because these might be contain e.g. the old group id. This is the responsibility of the application, so the API just exposes the old extensions and expects the new ones.
// Let Alice reboot the group. For that she needs new key packages for Bob and Charlie, a;s
// well as a new group ID.
let bob_new_kpb = generate_key_package(
ciphersuite,
bob_credential,
Extensions::empty(),
bob_provider,
&bob_signature_keys,
);
let charlie_new_kpb = generate_key_package(
ciphersuite,
charlie_credential,
Extensions::empty(),
charlie_provider,
&charlie_signature_keys,
);
let new_group_id: GroupId = GroupId::from_slice(
alice_group
.group_id()
.as_slice()
.iter()
.copied()
.chain(b"-new".iter().copied())
.collect::<Vec<_>>()
.as_slice(),
);
let (mut alice_group, reboot_messages) = alice_group
.reboot(new_group_id)
.finish(
Extensions::empty(),
vec![
bob_new_kpb.key_package().clone(),
charlie_new_kpb.key_package().clone(),
],
// We can use this closure to add more proposals to the commit builder that is used to
// create the commit that readds all the other members, but in this case we will leave
// it as-is.
|builder| builder,
alice_provider,
&alice_signature_keys,
alice_credential,
)
.unwrap();
alice_group.merge_pending_commit(alice_provider).unwrap();
// Bob and Charlie join the new group
let welcome = reboot_messages.into_welcome().unwrap();
let bob_group =
StagedWelcome::new_from_welcome(bob_provider, mls_group_config, welcome.clone(), None)
.unwrap()
.into_group(bob_provider)
.unwrap();
assert_eq!(bob_group.own_leaf_index(), LeafNodeIndex::new(1));
let charlie_group =
StagedWelcome::new_from_welcome(charlie_provider, mls_group_config, welcome, None)
.unwrap()
.into_group(charlie_provider)
.unwrap();
assert_eq!(charlie_group.own_leaf_index(), LeafNodeIndex::new(2));
// The fork should be fixed now, double-check
assert_eq!(alice_group.confirmation_tag(), bob_group.confirmation_tag());
assert_eq!(
alice_group.confirmation_tag(),
charlie_group.confirmation_tag()
);
assert_eq!(
bob_group.confirmation_tag(),
charlie_group.confirmation_tag()
);
In the end, they all can communicate again.
Fork Detection
Before initiating fork resolution, we first need to detect that a fork happened.
In addition, for using the readd mechanism, we also need to know the members
that forked.
One simple technique that may work, depending on how the delivery service works, is to consider all incoming non-decryptable messages as a sign that there is a fork. However, this may lead to false positives and is not enough to know the membership.
One way to learn about this that every member send a message when they merges a
commit, encrypted for the old epoch, that contains the hash of the commit they are
merging. This way, all group members know which commits are merged, and the readd
strategy can be used to resolve possible forks.
Traits & External Types
OpenMLS defines several traits that have to be implemented to use OpenMLS. The main goal is to allow OpenMLS to use different implementations for its cryptographic primitives, persistence, and random number generation. This should make it possible to plug in anything from WebCrypto to secure enclaves.
Using storage
The store is probably one of the most interesting traits because applications that use OpenMLS will interact with it. See the StorageProvider trait description for details.
In the following examples, we have a ciphersuite and a provider (OpenMlsProvider).
// First we generate a credential and key package for our user.
let credential = BasicCredential::new(b"User ID".to_vec());
let signature_keys = SignatureKeyPair::new(ciphersuite.into()).unwrap();
// This key package includes the private init and encryption key as well.
// See [`KeyPackageBundle`].
let key_package = KeyPackage::builder()
.build(
ciphersuite,
provider,
&signature_keys,
CredentialWithKey {
credential: credential.into(),
signature_key: signature_keys.to_public_vec().into(),
},
)
.unwrap();
Retrieving a value from the store is as simple as calling read.
The retrieved key package bundles the private keys for the init and encryption keys
as well.
// Read the key package
let read_key_package: Option<KeyPackageBundle> = provider
.storage()
.key_package(&hash_ref)
.expect("Error reading key package");
assert_eq!(
read_key_package.unwrap().key_package(),
key_package.key_package()
);
The delete is called with the identifier to delete a value.
// Delete the key package
let hash_ref = key_package
.key_package()
.hash_ref(provider.crypto())
.unwrap();
provider
.storage()
.delete_key_package(&hash_ref)
.expect("Error deleting key package");
OpenMLS Traits
⚠️ These traits are responsible for all cryptographic operations and randomness within OpenMLS. Please ensure you know what you’re doing when implementing your own versions.
Because implementing the OpenMLSCryptoProvider is challenging, requires
tremendous care, and is not what the average OpenMLS consumer wants to (or should)
do, we provide two implementations that can be used.
Rust Crypto Provider The go-to default at the moment is an implementation using commonly used, native Rust crypto implementations.
Supported ciphersuites
- MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519
- MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519
- MLS_128_DHKEMP256_AES128GCM_SHA256_P256
Libcrux Crypto Provider A crypto provider backed by the high-assurance cryptography library [libcrux].
Supported ciphersuites
- MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519
- MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_Ed25519
- MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519
The Traits
There are 4 different traits defined in the OpenMLS traits crate.
OpenMlsRand
This trait defines two functions to generate arrays and vectors, and is used by OpenMLS to generate randomness for key generation and random identifiers. While there is the commonly used rand crate, not all implementations use it. OpenMLS, therefore, defines its own randomness trait that needs to be implemented by an OpenMLS crypto provider. It simply needs to implement two functions to generate cryptographically secure randomness and store it in an array or vector.
pub trait OpenMlsRand {
type Error: std::error::Error + Debug;
/// Fill an array with random bytes.
fn random_array<const N: usize>(&self) -> Result<[u8; N], Self::Error>;
/// Fill a vector of length `len` with bytes.
fn random_vec(&self, len: usize) -> Result<Vec<u8>, Self::Error>;
}
OpenMlsCrypto
This trait defines all cryptographic functions required by OpenMLS. In particular:
- HKDF
- Hashing
- AEAD
- Signatures
- HPKE
StorageProvider
This trait defines an API for a storage backend that is used for all OpenMLS persistence.
The store provides functions for reading and updating stored values. Each sort of value has separate methods for accessing or mutating the state. In order to decouple the provider from the OpenMLS implementation, while still having legible types at the provider, there are traits that mirror all the types stored by OpenMLS. The provider methods use values constrained by these traits as as arguments.
/// Each trait in this module corresponds to a type. Some are used as keys, some as
/// entities, and some both. Therefore, the Key and/or Entity traits also need to be implemented.
pub mod traits {
use super::{Entity, Key};
// traits for keys, one per data type
pub trait GroupId<const VERSION: u16>: Key<VERSION> {}
pub trait SignaturePublicKey<const VERSION: u16>: Key<VERSION> {}
pub trait HashReference<const VERSION: u16>: Key<VERSION> {}
pub trait PskId<const VERSION: u16>: Key<VERSION> {}
pub trait EncryptionKey<const VERSION: u16>: Key<VERSION> {}
pub trait EpochKey<const VERSION: u16>: Key<VERSION> {}
// traits for entity, one per type
pub trait QueuedProposal<const VERSION: u16>: Entity<VERSION> {}
pub trait TreeSync<const VERSION: u16>: Entity<VERSION> {}
pub trait GroupContext<const VERSION: u16>: Entity<VERSION> {}
pub trait InterimTranscriptHash<const VERSION: u16>: Entity<VERSION> {}
pub trait ConfirmationTag<const VERSION: u16>: Entity<VERSION> {}
pub trait SignatureKeyPair<const VERSION: u16>: Entity<VERSION> {}
pub trait PskBundle<const VERSION: u16>: Entity<VERSION> {}
pub trait HpkeKeyPair<const VERSION: u16>: Entity<VERSION> {}
pub trait GroupState<const VERSION: u16>: Entity<VERSION> {}
pub trait GroupEpochSecrets<const VERSION: u16>: Entity<VERSION> {}
pub trait LeafNodeIndex<const VERSION: u16>: Entity<VERSION> {}
pub trait MessageSecrets<const VERSION: u16>: Entity<VERSION> {}
pub trait ResumptionPskStore<const VERSION: u16>: Entity<VERSION> {}
pub trait KeyPackage<const VERSION: u16>: Entity<VERSION> {}
pub trait MlsGroupJoinConfig<const VERSION: u16>: Entity<VERSION> {}
pub trait LeafNode<const VERSION: u16>: Entity<VERSION> {}
pub trait ApplicationExportTree<const VERSION: u16>: Entity<VERSION> {}
#[cfg(feature = "virtual-clients-draft")]
pub trait VcEpochId<const VERSION: u16>: Key<VERSION> {}
#[cfg(feature = "virtual-clients-draft")]
pub trait VcDerivationEpochState<const VERSION: u16>: Entity<VERSION> {}
#[cfg(feature = "virtual-clients-draft")]
pub trait VcEmulationBindings<const VERSION: u16>: Entity<VERSION> {}
#[cfg(feature = "virtual-clients-draft")]
pub trait RegisteredVcDerivationEpoch<const VERSION: u16>: Entity<VERSION> {}
#[cfg(feature = "virtual-clients-draft")]
pub trait VcOperationTree<const VERSION: u16>: Entity<VERSION> {}
#[cfg(feature = "virtual-clients-draft")]
pub trait RetainedKeyPackageMaterial<const VERSION: u16>: Entity<VERSION> {}
// traits for types that implement both
pub trait ProposalRef<const VERSION: u16>: Entity<VERSION> + Key<VERSION> {}
}
The traits are generic over a VERSION, which is used to ensure that the values
that are persisted can be upgraded when OpenMLS changes the stored structs.
The traits used as arguments to the storage methods are constrained to implement
the Key or Entity traits as well, depending on whether they are only used for
addressing (in which case they are a Key) or whether they represent a stored
value (in which case they are an Entity).
/// Key is a trait implemented by all types that serve as a key (in the database sense) to in the
/// storage. For example, a GroupId is a key to the stored entities for the group with that id.
/// The point of a key is not to be stored, it's to address something that is stored.
pub trait Key<const VERSION: u16>: Serialize {}
/// Entity is a trait implemented by the values being stored.
pub trait Entity<const VERSION: u16>: Serialize + DeserializeOwned {}
An implementation of the storage trait should ensure that it can address and efficiently handle values.
Example: Key packages
This is only an example, but it illustrates that the application may need to do more when it comes to implementing storage.
Key packages are only deleted by OpenMLS when they are used and not last resort key packages (which may be used multiple times). The application needs to implement some logic to manage last resort key packages.
fn write_key_package<
HashReference: traits::HashReference<VERSION>,
KeyPackage: traits::KeyPackage<VERSION>,
>(
&self,
hash_ref: &HashReference,
key_package: &KeyPackage,
) -> Result<(), Self::Error>;
The application may store the hash references in a separate list with a validity period.
fn write_key_package<
HashReference: traits::HashReference<VERSION>,
KeyPackage: traits::KeyPackage<VERSION>,
>(
&self,
hash_ref: &HashReference,
key_package: &KeyPackage,
) -> Result<(), Self::Error> {
// Get the validity from the application in some way.
let validity = self.get_validity(hash_ref);
// Store the reference and its validity period.
self.store_hash_ref(hash_ref, validity);
// Store the actual key package.
self.store_key_package(hash_ref, key_package);
}
This allows the application to iterate over the hash references and delete outdated key packages.
OpenMlsProvider
Additionally, there’s a wrapper trait defined that is expected to be passed into the public OpenMLS API. Some OpenMLS APIs require only one of the sub-traits, though.
pub trait OpenMlsProvider {
type CryptoProvider: crypto::OpenMlsCrypto;
type RandProvider: random::OpenMlsRand;
type StorageProvider: storage::StorageProvider<{ storage::CURRENT_VERSION }>;
// Get the storage provider.
fn storage(&self) -> &Self::StorageProvider;
/// Get the crypto provider.
fn crypto(&self) -> &Self::CryptoProvider;
/// Get the randomness provider.
fn rand(&self) -> &Self::RandProvider;
}
Implementation Notes
It is not necessary to implement all sub-traits if one functionality is missing.
Suppose you want to use a persisting storage provider. In that case, it is
sufficient to do a new implementation of the StorageProvider trait and
combine it with one of the provided crypto and randomness trait implementations.
External Types
For interoperability, this crate also defines several types and algorithm identifiers.
AEADs
The following AEADs are defined.
#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
#[repr(u16)]
/// AEAD types
pub enum AeadType {
/// AES GCM 128
Aes128Gcm = 0x0001,
/// AES GCM 256
Aes256Gcm = 0x0002,
An AEAD provides the following functions to get the according values for each algorithm.
tag_sizekey_sizenonce_size
Hashing
The following hash algorithms are defined.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
#[repr(u8)]
#[allow(non_camel_case_types)]
/// Hash types
pub enum HashType {
A hash algorithm provides the following functions to get the according values for each algorithm.
size
Signatures
The following signature schemes are defined.
TlsSerializeBytes,
TlsDeserialize,
TlsDeserializeBytes,
TlsSize,
)]
#[repr(u16)]
pub enum SignatureScheme {
/// ECDSA_SECP256R1_SHA256
ECDSA_SECP256R1_SHA256 = 0x0403,
/// ECDSA_SECP384R1_SHA384
ECDSA_SECP384R1_SHA384 = 0x0503,
/// ECDSA_SECP521R1_SHA512
HPKE Types
The HPKE implementation is part of the crypto provider as well. The crate, therefore, defines the necessary types too.
The HPKE algorithms are defined as follows.
SigningError,
InvalidPublicKey,
}
impl std::fmt::Display for CryptoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
}
}
impl std::error::Error for CryptoError {}
// === HPKE === //
/// Convenience tuple struct for an HPKE configuration.
#[derive(Debug)]
#[repr(u16)]
pub enum HpkeKemType {
/// DH KEM on P256
DhKemP256 = 0x0010,
/// DH KEM on P384
DhKemP384 = 0x0011,
/// DH KEM on P521
DhKemP521 = 0x0012,
/// DH KEM on x448
DhKem448 = 0x0021,
/// ML-KEM-768
#[cfg(feature = "draft-ietf-mls-pq-ciphersuites")]
MlKem768 = 0x0041,
/// ML-KEM-1024
#[cfg(feature = "draft-ietf-mls-pq-ciphersuites")]
MlKem1024 = 0x0042,
/// XWing combiner for ML-KEM and X25519
#[cfg(feature = "draft-ietf-mls-pq-ciphersuites")]
In addition, helper structs for HpkeCiphertext and HpkeKeyPair are defined.
/// HKDF SHA 512
HkdfSha512 = 0x0003,
}
#[repr(u16)]
pub enum HpkeAeadType {
/// AES GCM 128
AesGcm128 = 0x0001,
Message Validation
OpenMLS implements a variety of syntactical and semantical checks, both when parsing and processing incoming commits and when creating own commits.
Validation steps
Validation is enforced using Rust’s type system. The chain of functions used to process incoming messages is described in the chapter on Processing incoming messages, where each function takes a distinct type as input and produces a distinct type as output, thus ensuring that the individual steps can’t be skipped. We now detail which step performs which validation checks.
Syntax validation
Incoming messages in the shape of a byte string can only be deserialized into a MlsMessageIn struct. Deserialization ensures that the message is a syntactically correct MLS message, i.e., either a PublicMessage or a PrivateMessage.
Further syntax checks are applied for the latter case once the message is decrypted.
Semantic validation
Every function in the processing chain performs several semantic validation steps. For a list of these steps, see below. In the following, we will give a brief overview of which function performs which category of checks.
Wire format policy and basic message consistency validation
MlsMessageIn struct instances can be passed into the .parse_message() function of the MlsGroup API, which validates that the message conforms to the group’s wire format policy. The function also performs several basic semantic validation steps, such as consistency of Group id, Epoch, and Sender data between message and group (ValSem002-ValSem007). It also checks if the sender type (e.g., Member, NewMember, etc.) matches the type of the message (ValSem112), as well as the presence of a path in case of an External Commit (ValSem246).
.parse_message() then returns an UnverifiedMessage struct instance, which can in turn be used as input for .process_unverified_message().
Message-specific semantic validation
.process_unverified_message() performs all other semantic validation steps. In particular, it ensures that …
- the message is correctly authenticated by a signature (
ValSem010), membership tag (ValSem008), and confirmation tag (ValSem205), - proposals are valid relative to one another and the current group state, e.g., no redundant adds or removes targeting non-members (
ValSem101-ValSem112), - commits are valid relative to the group state and the proposals it covers (
ValSem200-ValSem205) and - external commits are valid according to the spec (
ValSem240-ValSem245,ValSem247is checked as part ofValSem010).
After performing these steps, messages are returned as ProcessedMessages that the application can either use immediately (application messages) or inspect and decide if they find them valid according to the application’s policy (proposals and commits). Proposals can then be stored in the proposal queue via .store_pending_proposal(), while commits can be merged into the group state via .merge_staged_commit().
Detailed list of validation steps
The following is a list of the individual semantic validation steps performed by OpenMLS, including the location of the tests.
Semantic validation of message framing
| ValidationStep | Description | Implemented | Tested | Test File |
|---|---|---|---|---|
ValSem002 | Group id | ✅ | ✅ | openmls/src/group/tests/test_framing_validation.rs |
ValSem003 | Epoch | ✅ | ✅ | openmls/src/group/tests/test_framing_validation.rs |
ValSem004 | Sender: Member: check the sender points to a non-blank leaf | ✅ | ✅ | openmls/src/group/tests/test_framing_validation.rs |
ValSem005 | Application messages must use ciphertext | ✅ | ✅ | openmls/src/group/tests/test_framing_validation.rs |
ValSem006 | Ciphertext: decryption needs to work | ✅ | ✅ | openmls/src/group/tests/test_framing_validation.rs |
ValSem007 | Membership tag presence | ✅ | ✅ | openmls/src/group/tests/test_framing_validation.rs |
ValSem008 | Membership tag verification | ✅ | ✅ | openmls/src/group/tests/test_framing_validation.rs |
ValSem009 | Confirmation tag presence | ✅ | ✅ | openmls/src/group/tests/test_framing_validation.rs |
ValSem010 | Signature verification | ✅ | ✅ | openmls/src/group/tests/test_framing_validation.rs |
ValSem011 | PrivateMessageContent padding must be all-zero | ✅ | ✅ | openmls/src/group/tests/test_framing.rs |
Semantic validation of proposals covered by a Commit
| ValidationStep | Description | Implemented | Tested | Test File |
|---|---|---|---|---|
ValSem101 | Add Proposal: Signature public key in proposals must be unique among proposals & members | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem102 | Add Proposal: Init key in proposals must be unique among proposals | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem103 | Add Proposal: Encryption key in proposals must be unique among proposals & members | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem104 | Add Proposal: Init key and encryption key must be different | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem105 | Add Proposal: Ciphersuite & protocol version must match the group | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem106 | Add Proposal: required capabilities | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem107 | Remove Proposal: Removed member must be unique among proposals | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem108 | Remove Proposal: Removed member must be an existing group member | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem109 | Update Proposal: required capabilities | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem110 | Update Proposal: Encryption key must be unique among proposals & members | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem111 | Update Proposal: The sender of a full Commit must not include own update proposals | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem112 | Update Proposal: The sender of a standalone update proposal must be of type member | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem113 | All Proposals: The proposal type must be supported by all members of the group | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
Commit message validation
| ValidationStep | Description | Implemented | Tested | Test File |
|---|---|---|---|---|
ValSem200 | Commit must not cover inline self Remove proposal | ✅ | ✅ | openmls/src/group/tests/test_commit_validation.rs |
ValSem201 | Path must be present, if at least one proposal requires a path | ✅ | ✅ | openmls/src/group/tests/test_commit_validation.rs |
ValSem202 | Path must be the right length | ✅ | ✅ | openmls/src/group/tests/test_commit_validation.rs |
ValSem203 | Path secrets must decrypt correctly | ✅ | ✅ | openmls/src/group/tests/test_commit_validation.rs |
ValSem204 | Public keys from Path must be verified and match the private keys from the direct path | ✅ | ✅ | openmls/src/group/tests/test_commit_validation.rs |
ValSem205 | Confirmation tag must be successfully verified | ✅ | ✅ | openmls/src/group/tests/test_commit_validation.rs |
ValSem206 | Path leaf node encryption key must be unique among proposals & members | ✅ | ✅ | openmls/src/group/tests/test_commit_validation.rs |
ValSem207 | Path encryption keys must be unique among proposals & members | ✅ | ✅ | openmls/src/group/tests/test_commit_validation.rs |
ValSem208 | Only one GroupContextExtensions proposal in a commit | ✅ | ||
ValSem209 | GroupContextExtensions proposals may only contain extensions support by all members | ✅ |
External Commit message validation
| ValidationStep | Description | Implemented | Tested | Test File |
|---|---|---|---|---|
ValSem240 | External Commit must cover at least one inline ExternalInit proposal | ✅ | ✅ | openmls/src/group/tests/test_external_commit_validation.rs |
ValSem241 | External Commit must cover at most one inline ExternalInit proposal | ✅ | ✅ | openmls/src/group/tests/test_external_commit_validation.rs |
ValSem242 | External Commit must only cover inline proposal in allowlist (ExternalInit, Remove, PreSharedKey) | ✅ | ✅ | openmls/src/group/tests/test_external_commit_validation.rs |
ValSem244 | External Commit must not include any proposals by reference | ✅ | ✅ | openmls/src/group/tests/test_external_commit_validation.rs |
ValSem245 | External Commit must contain a path | ✅ | ✅ | openmls/src/group/tests/test_external_commit_validation.rs |
ValSem246 | External Commit signature must be verified using the credential in the path KeyPackage | ✅ | ✅ | openmls/src/group/tests/test_external_commit_validation.rs |
Ratchet tree validation
| ValidationStep | Description | Implemented | Tested | Test File |
|---|---|---|---|---|
ValSem300 | Exported ratchet trees must not have trailing blank nodes. | Yes | Yes | openmls/src/treesync/mod.rs |
PSK Validation
| ValidationStep | Description | Implemented | Tested | Test File |
|---|---|---|---|---|
ValSem400 | The application SHOULD specify an upper limit on the number of past epochs for which the resumption_psk may be stored. | ❌ | ❌ | https://github.com/openmls/openmls/issues/1122 |
ValSem401 | The nonce of a PreSharedKeyID must have length KDF.Nh. | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem402 | PSK in proposal must be of type Resumption (with usage Application) or External. | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem403 | Proposal list must not contain multiple PreSharedKey proposals that reference the same PreSharedKeyID. | ✅ | ❌ | https://github.com/openmls/openmls/issues/1335 |
App Validation
NOTE: This chapter described the validation steps an application, using OpenMLS, has to perform for safe operation of the MLS protocol.
⚠️ This chapter is work in progress (see #1504).
Credential Validation
Acceptable Presented Identifiers
The application using MLS is responsible for specifying which identifiers it finds acceptable for each member in a group. In other words, following the model that RFC6125 describes for TLS, the application maintains a list of “reference identifiers” for the members of a group, and the credentials provide “presented identifiers”. A member of a group is authenticated by first validating that the member’s credential legitimately represents some presented identifiers, and then ensuring that the reference identifiers for the member are authenticated by those presented identifiers
Validity of Updated Presented Identifiers
In cases where a member’s credential is being replaced, such as the Update and Commit cases above, the AS MUST also verify that the set of presented identifiers in the new credential is valid as a successor to the set of presented identifiers in the old credential, according to the application’s policy.
Application ID is Not Authenticated by AS
However, applications MUST NOT rely on the data in an application_id extension as if it were authenticated by the Authentication Service, and SHOULD gracefully handle cases where the identifier presented is not unique.
LeafNode Validation
Specifying the Maximum Total Acceptable Lifetime
Applications MUST define a maximum total lifetime that is acceptable for a LeafNode, and reject any LeafNode where the total lifetime is longer than this duration. In order to avoid disagreements about whether a LeafNode has a valid lifetime, the clients in a group SHOULD maintain time synchronization (e.g., using the Network Time Protocol RFC5905).
PrivateMessage Validation
Structure of AAD is Application-Defined
It is up to the application to decide what authenticated_data to provide and how much padding to add to a given message (if any). The overall size of the AAD and ciphertext MUST fit within the limits established for the group’s AEAD algorithm in CFRG-AEAD-LIMITS.
Therefore, the application must also validate whether the AAD adheres to the prescribed format.
Proposal Validation
When processing a commit, the application has to ensure that the application specific semantic checks for the validity of the committed proposals are performed.
This should be done on the StagedCommit. Also see the Message Processing
chapter
if let ProcessedMessageContent::StagedCommitMessage(staged_commit) =
alice_processed_message.into_content()
{
// We expect a remove proposal
let remove = staged_commit
.remove_proposals()
.next()
.expect("Expected a proposal.");
// Check that Bob was removed
assert_eq!(
remove.remove_proposal().removed(),
bob_group.own_leaf_index()
);
// Check that Charlie removed Bob
assert!(matches!(
remove.sender(),
Sender::Member(member) if *member == charlies_leaf_index
));
// Merge staged commit
alice_group
.merge_staged_commit(alice_provider, *staged_commit)
.expect("Error merging staged commit.");
}
External Commits
The RFC requires the following check
At most one Remove proposal, with which the joiner removes an old version of themselves. If a Remove proposal is present, then the LeafNode in the path field of the external Commit MUST meet the same criteria as would the LeafNode in an Update for the removed leaf (see Section 12.1.2). In particular, the credential in the LeafNode MUST present a set of identifiers that is acceptable to the application for the removed participant.
Since OpenMLS does not know the relevant policies, the application MUST ensure that the credentials are checked according to the policy.
Performance
How does OpenMLS (and MLS in general) perform in different settings?
Performance measurements are implemented here and can be run with cargo bench --bench group.
Check which scenarios and group sizes are enabled in the code.
OpenMLS Performance Spreadsheet
Real World Scenarios
Stable group
Many private groups follow this model.
- Group is created by user P1
- P1 invites a set of N other users
- The group is used for messaging between the N+1 members
- Every X messages, one user in the group sends an update
Somewhat stable group
This can model a company or team-wide group where regularly but infrequently, users are added, and users leave.
- Group is created by user P1
- P1 invites a set of N other users
- The group is used for messaging between the members
- Every X messages, one user in the group sends an update
- Every Y messages, Q users are added
- Every Z messages, R users are removed
High fluctuation group
This models public groups where users frequently join and leave. Real-time scenarios such as gather.town are examples of high-fluctuation groups. It is the same scenario as the somewhat stable group but with a very small Y and Z.
Extreme Scenarios
In addition to the three scenarios above extreme and corner cases are interesting.
Every second leaf is blank
Only every second leaf in the tree is non-blank.
Use Case Scenarios
A collection of common use cases/flows from everyday scenarios.
Long-time offline device
Suppose a device has been offline for a while. In that case, it has to process a large number of application and protocol messages.
Tree scenarios
In addition to the scenarios above, it is interesting to look at the same scenario but with different states of the tree. For example, take the stable group with N members messaging each other. What is the performance difference between a message sent right after group setup, i.e., each member only joined the group without other messages being sent, and a tree where every member has sent an update before the message?
Measurements
- Group creation
- create group
- create proposals
- create welcome
- apply commit
- Join group
- create group from welcome
- Send application message
- Receive application message
- Send update
- create proposal
- create commit
- apply commit
- Receive update
- apply commit
- Add user sender
- create proposal
- create welcome
- apply commit
- Existing user getting an add
- apply commit
- Remove user sender
- create proposal
- create commit
- apply commit
- Existing user getting a remove
- apply commit
Forward Secrecy
OpenMLS drops key material immediately after a given
key is no longer required by the protocol to achieve forward secrecy. For some keys, this is simple, as they
are used only once, and there is no need to store them for later use. However,
for other keys, the time of deletion is a result of a trade-off between
functionality and forward secrecy. For example, it can be desirable to keep the
SecretTree of past epochs for a while to allow decryption of straggling
application messages sent in previous epochs.
In this chapter, we detail how we achieve forward secrecy for the different types of keys used throughout MLS.
Ratchet Tree
The ratchet tree contains the secret key material of the client’s leaf, as well (potentially) that of nodes in its direct path. The secrets in the tree are changed in the same way as the tree itself: via the merge of a previously prepared diff.
Commit Creation
Upon the creation of a commit, any fresh key material introduced by the committer is stored in the diff. It exists alongside the key material of the ratchet tree before the commit until the client merges the diff, upon which the key material in the original ratchet tree is dropped.
Because the client cannot know if the commit it creates will conflict with another commit created by another client for the same epoch, it MUST wait for the acknowledgement from the Delivery Service before merging the diff and dropping the previous ratchet tree.
Commit Processing
Upon receiving a commit from another group member, the client processes the
commit until they have a StagedCommit, which in turn contains a ratchet tree
diff. The diff contains any potential key material they decrypted from the
commit and any potential key material that was introduced to the tree as
part of an update that someone else committed for them. The key material in the original ratchet tree is dropped as soon as the StagedCommit (and thus the diff) is merged into the tree.
Sending application messages
When an application message is created, the corresponding encryption key is derived from the SecretTree and immediately discarded after encrypting the message to guarantee the best possible Forward Secrecy. This means that the message author cannot decrypt application messages. If access to the message’s content is required after creating the message, a copy of the plaintext message should be kept by the application.
Receiving encrypted messages
When an encrypted message is received, the corresponding decryption key is derived from the SecretTree. By default, the key material is discarded immediately after decryption for the best possible Forward Secrecy. In some cases, the Delivery Service cannot guarantee reliable operation, and applications need to be more tolerant to accommodate this – at the expense of Forward Secrecy.
OpenMLS can address 3 scenarios:
-
The Delivery Service cannot guarantee that application messages from one epoch are sent before the beginning of the next epoch. To address this, applications can configure their groups to keep the necessary key material around for past epochs by configuring the past epoch deletion policy on the
MlsGroupCreateConfig.For more information, see Past epoch secret deletion. -
The Delivery Service cannot guarantee that application messages will arrive in order within the same epoch. To address this, applications can configure the
out_of_order_toleranceparameter of theSenderRatchetConfiguration. The configuration can be set as thesender_ratchet_configurationparameter of theMlsGroupCreateConfig -
The Delivery Service cannot guarantee that application messages won’t be dropped within the same epoch. To address this, applications can configure the
maximum_forward_distanceparameter of theSenderRatchetConfiguration. The configuration can be set as thesender_ratchet_configurationparameter of theMlsGroupCreateConfig.
Past epoch secret deletion
The Delivery Service may not be able to guarantee that application messages from one epoch are sent before the beginning of the next epoch. To address this, applications can configure their groups to keep the necessary key material around for past epochs by configuring the past epoch deletion policy on the MlsGroupCreateConfig.
The PastEpochDeletionPolicy will be applied to the group automatically when a commit is merged.
Setting a past epoch secrets deletion policy for a group
As part of creating a group, the PastEpochDeletionPolicy can be set on a group creation config:
// set up the group creation config
let mls_group_create_config = MlsGroupCreateConfig::builder()
// keep at most 3 past epoch secrets
.set_past_epoch_deletion_policy(PastEpochDeletionPolicy::MaxEpochs(3))
.ciphersuite(ciphersuite)
.build();
The policy can also be updated on an existing group:
// keep all past epoch secrets by default
mls_group
.set_past_epoch_deletion_policy(provider, PastEpochDeletionPolicy::KeepAll)
.expect("error setting past epoch deletion policy");
// keep a maximum of 3 past epoch secrets
mls_group
.set_past_epoch_deletion_policy(provider, PastEpochDeletionPolicy::MaxEpochs(3))
.expect("error setting past epoch deletion policy");
Time-based deletion schedules
It is possible to configure time-based deletion schedules for past epoch secrets. The application can periodically apply a PastEpochDeletion using the MlsGroup::delete_past_epoch_secrets() API.
Generally, when time-based deletion schedules are used, it can be helpful to configure the group to use PastEpochDeletionPolicy::KeepAll, to ensure that automatic deletion conducted by the group is not applied early to a past epoch secret.
Delete all past epoch secrets before a provided timestamp:
// delete all past epoch secrets before a timestamp
mls_group
.delete_past_epoch_secrets(provider, PastEpochDeletion::before_timestamp(timestamp))
.expect("error deleting past epoch secrets");
Delete all past epoch secrets before a provided timestamp, leaving at most a provided number of epochs:
// delete past epoch secrets before a timestamp, leaving the latest three, at most
mls_group
.delete_past_epoch_secrets(
provider,
PastEpochDeletion::before_timestamp(timestamp).max_past_epochs(3),
)
.expect("error deleting past epoch secrets");
Delete all past epoch secrets older than a provided duration:
// delete all past epoch secrets older than a duration
mls_group
.delete_past_epoch_secrets(
provider,
PastEpochDeletion::older_than_duration(Duration::from_hours(48)),
)
.expect("error deleting past epoch secrets");
Delete all past epoch secrets older than a provided duration, leaving at most a provided number of epochs:
// delete all past epoch secrets older than a duration, leaving the latest three, at most
mls_group
.delete_past_epoch_secrets(
provider,
PastEpochDeletion::older_than_duration(Duration::from_hours(48)).max_past_epochs(3),
)
.expect("error deleting past epoch secrets");
Migration and deleting legacy entries
Epoch secrets that were created using openmls=0.8.1 or earlier will not yet include a timestamp.
After migration, these may not always be deleted by applying a time-based PastEpochDeletion. Only if a new secret that does include a timestamp is added later, and it matches the time-based condition in the PastEpochDeletion, all earlier past epoch secrets without timestamps will be deleted, as well. However, otherwise, past epoch secrets without timestamps will not be affected by applying time-based PastEpochDeletions.
After migration, it is possible to manually delete all past epoch secrets without timestamps:
// delete all past epoch secrets without timestamps
mls_group
.delete_past_epoch_secrets(provider, PastEpochDeletion::delete_all_without_timestamps())
.expect("error deleting past epoch secrets");
Deleting all past epoch secrets
All past epoch secrets can also be deleted at once:
// delete all past epoch secrets
mls_group
.delete_past_epoch_secrets(provider, PastEpochDeletion::delete_all())
.expect("error deleting past epoch secrets");
Setting the group’s PastEpochDeletionPolicy to PastEpochDeletionPolicy::MaxEpochs(0) will also delete all past epoch secrets.
Virtual Clients (draft)
OpenMLS has experimental support for virtual clients, following draft-ietf-mls-virtual-clients. A virtual client lets several real clients act jointly as a single member of an MLS group. The group sees one leaf. Behind that leaf, any of the cooperating clients can speak for it.
This feature is a moving draft. Everything described here lives behind the
virtual-clients-draftcargo feature and is not part of the stable API. Wire formats, storage layout, and method names can change between releases with no migration path. Do not assume interoperability with other implementations.
The idea
The clients that cooperate to act as one member are called emulator clients. The member they present to the outside world is the virtual client. The emulator clients coordinate through a separate MLS group of their own, the emulation group, while the virtual client appears as a single leaf in one or more higher-level groups.
The point of the construction is that the emulator clients never share raw private keys with each other. Instead they all derive the same key material from the emulation group’s epoch secrets, so any of them can produce a commit, an application message, or a KeyPackage on behalf of the virtual client, and any other can reproduce the matching private state. To the higher-level group, the result is indistinguishable from an ordinary single member.
Two mechanisms make this work without secret sharing:
- A Virtual Client Operation Secret Tree, derived from the emulation group’s
epoch through the Safe Exporter. It has the same shape as an MLS secret tree.
Each emulator client’s leaf carries one ratchet per operation type
(
KeyPackage,LeafNode,Application). Advancing a ratchet yields an operation secret, and from that secret each client derives the leaf encryption key, init key, signature key seed, and path secrets for a single operation. - A
DerivationInfocomponent embedded in every leaf node the virtual client produces. It carries, encrypted, the derivation epoch’sleaf_indexand thegenerationthat was used. A sibling emulator client reads it, derives the same operation secret from the operation tree at that position, and so reconstructs the private keys for the leaf without ever receiving them.
Each derivation epoch also produces a generation_id_secret (so the Delivery
Service can detect when two emulator clients pick the same ratchet generation)
and a reuse_guard_secret (so two emulator clients never reuse a key and nonce
pair while still looking random to outside observers).
Enabling the feature
Add the feature to your dependency on openmls:
[dependencies]
openmls = { version = "...", features = ["virtual-clients-draft"] }
Storage backends carry their own virtual-clients-draft feature, which the
openmls feature turns on for them. For tests that exercise the storage
provider trait methods directly, enable
virtual-clients-draft-test-dependencies on the openmls crate.
Leaf requirements
Every leaf that carries virtual-client material must declare support for the
AppDataDictionary extension and list the VC component id in its
AppComponents entry. Build the capabilities and leaf-node extensions
accordingly when you create or join an emulation group or a higher-level group:
use openmls::component::{ComponentId, ComponentType};
use openmls::components::vc_derivation_info::VC_COMPONENT_ID;
use openmls::extensions::{
AppDataDictionary, AppDataDictionaryExtension, Extension, ExtensionType, Extensions,
};
use openmls::prelude::Capabilities;
use tls_codec::Serialize as _;
let capabilities = Capabilities::builder()
.extensions(vec![ExtensionType::AppDataDictionary])
.build();
let supported_components: Vec<ComponentId> = vec![VC_COMPONENT_ID];
let app_components_body = supported_components.tls_serialize_detached().unwrap();
let mut dictionary = AppDataDictionary::new();
dictionary.insert(ComponentType::AppComponents.into(), app_components_body);
let leaf_extensions = Extensions::from_vec(vec![Extension::AppDataDictionary(
AppDataDictionaryExtension::new(dictionary),
)])
.unwrap();
Pass capabilities and leaf_extensions to MlsGroupCreateConfig::builder()
through .capabilities(...) and .with_leaf_node_extensions(...), and to the
KeyPackage::builder() through .leaf_node_capabilities(...) and
.leaf_node_extensions(...).
Derivation epochs
An emulation group is an ordinary MlsGroup. Each emulator client maintains its
own copy. What makes it an emulation group is a flag every emulator client has to
set when it creates or joins the group. The creator sets it on the create config:
let create_config = MlsGroupCreateConfig::builder()
.emulation_group(true)
// ... the leaf requirements above, ciphersuite, wire format policy
.build();
A member joining by Welcome sets it on the StagedWelcome:
let group = StagedWelcome::new_from_welcome(provider, &join_config, welcome, ratchet_tree)?
.emulation_group(true)
.into_group(provider)?;
A member resyncing by external commit sets it on the ExternalCommitBuilder,
via .emulation_group(true) before build_group.
The flag is local state. Nothing about it travels on the wire, and OpenMLS does not verify that the other members set it. Loading a group recovers the flag from storage, so it only has to be set once.
Virtual-client secrets are not derived from every epoch of the emulation group,
only from its derivation epochs. The initial epoch is one, and so is the output
epoch of any commit that changes membership or that carries a
new_derivation_epoch action. Other commits leave the newest derivation epoch in
place.
OpenMLS registers those epochs itself: at group creation, at a Welcome join, and
when such a commit is merged. Registration sources the root secret from the
epoch’s Safe Exporter under VC_COMPONENT_ID, builds the operation secret tree,
and persists the per-epoch state under a derived EpochId. Because the secret
comes from the Safe Exporter, all emulator clients derive the same EpochId
and the same operation tree for a given derivation epoch.
Every new virtual-client operation uses the newest derivation epoch of the
emulation group, as the draft requires. The sender-side entry points take the
emulation group and resolve that epoch themselves, so an application cannot keep
operating from an older, possibly compromised epoch. They fail with
VirtualClientsError::NoDerivationEpoch if the group has none registered. To
inspect the epoch, for example for logging, ask the emulation group:
let epoch_id = emulator_group
.newest_vc_derivation_epoch(provider.storage())?
.expect("an emulation group has a derivation epoch");
That epoch may be older than the emulation group’s current epoch. The EpochId
is the key under which all per-epoch state is stored, and it is the value
embedded in the leaves the virtual client produces.
Registration writes happen alongside the writes of the operation that triggered
them, so wrap merge_staged_commit and merge_pending_commit calls on an
emulation group in a storage transaction.
To start a fresh derivation epoch without changing membership, for instance to bound the damage of a compromise, mark a commit on the emulation group:
let bundle = emulator_group
.commit_builder()
.derivation_epoch(true)
.force_self_update(true)
.load_psks(provider.storage())?
.build(provider.rand(), provider.crypto(), &emulator_signer, |_| true)?
.stage_commit(provider)?;
The marker travels in the commit’s Safe AAD, so the emulation group’s GroupContext has to require Safe AAD framing. Like all actions, the marker applies relative to the commit’s input state: operations that reference a derivation epoch, including ones carried by this very commit, keep using the input state’s newest derivation epoch.
Committing in a higher-level group
To commit on behalf of the virtual client, set vc_emulation on the commit
builder, passing the emulation group’s id. The builder resolves that group’s newest
derivation epoch, allocates the next LeafNode operation generation, derives the
new leaf’s encryption key and the first path secret from it, and embeds the
encrypted DerivationInfo in the leaf:
let bundle = main_group
.commit_builder()
.vc_emulation(provider.crypto(), provider.storage(), emulator_group.group_id())?
.load_psks(provider.storage())?
.build(provider.rand(), provider.crypto(), &vc_signer, |_| true)?
.stage_commit(provider)?;
main_group.merge_pending_commit(provider)?;
let commit = bundle.into_commit();
Allocation advances the operation ratchet and persists it immediately, which is
a deliberate exception to the usual rule that nothing is written before a commit
is staged. The spec requires that a generation is never used for more than one
operation, so the generation is consumed at allocation time. If you discard the
builder, or the Delivery Service rejects the commit, the generation stays
burned. clear_pending_commit does not roll the ratchet back. A burned
generation is harmless, because sibling ratchets skip over it and retain the
skipped secret.
Processing a sibling’s commit
process_message detects the DerivationInfo component in a committer’s leaf.
If the committer’s leaf index is the receiver’s own leaf, the commit came from a
sibling emulator client. The receiver loads the derivation epoch state, decrypts
the derivation info, derives the same operation secret positionally from the
operation tree (advancing or skipping the sibling’s ratchet as needed),
reconstructs the path secrets, and processes the commit as if it had created it:
let processed = receiver_group
.process_message(provider, commit.into_protocol_message().unwrap())?;
A receiver that does not hold the referenced derivation epoch state, for example a real member of the higher-level group that is not an emulator client, processes the commit as an ordinary commit. The permissive handling is framed around the receiver, who may not be a sibling. The sender is always a sibling.
When a commit that carries a DerivationInfo is merged, the client stores a
binding from (GroupId, GroupEpoch) to the EpochId from that leaf. The
binding is keyed by epoch, not just group id, because a delayed application
message from an earlier higher-level epoch must be processed with the derivation
epoch that was active then. Bindings follow the same retention window as the
message secrets store.
Confirming handshake messages
When the group frames handshake messages as PrivateMessage (a ciphertext outgoing wire format policy), proposals and commits draw their generations from the per-leaf handshake ratchet, the same way application messages draw from the application ratchet. Like an application send, a private handshake send retains its key and nonce until the Delivery Service accepts it, so two emulator clients that race for the same handshake generation can both recover.
A commit framed as PrivateMessage exposes its confirmation data on the bundle.
Take it out with take_confirmation before consuming the bundle, since the
consuming accessors (into_commit, into_contents, into_messages) drop the
confirmation data:
let mut bundle = main_group
.commit_builder()
.vc_emulation(provider.crypto(), provider.storage(), emulator_group.group_id())?
.load_psks(provider.storage())?
.build(provider.rand(), provider.crypto(), &vc_signer, |_| true)?
.stage_commit(provider)?;
if let Some(confirmation) = bundle.take_confirmation() {
// Attach confirmation.generation_id when fanning out the commit, so the
// Delivery Service can detect a generation collision with a sibling.
send_to_delivery_service(bundle.into_commit(), confirmation.generation_id.clone());
}
For standalone proposals, propose_unconfirmed mirrors propose but also
returns the confirmation data alongside the framed proposal:
let (proposal, proposal_ref, confirmation) = main_group.propose_unconfirmed(
provider,
&vc_signer,
Propose::Add(key_package),
ProposalOrRefType::Reference,
)?;
The confirmation from either path is Some when the message was framed as
PrivateMessage and None when it was framed as a plaintext PublicMessage. As
with application messages, the generation_id is Some only on a group bound
to a derivation epoch.
Once the Delivery Service accepts the message, drop the retained key with
confirm_handshake_message, passing the confirmation’s epoch and
generation:
main_group.confirm_handshake_message(
provider.storage(),
confirmation.epoch,
confirmation.generation,
)?;
The epoch carried by a commit’s confirmation is the epoch the commit was encrypted in, which is the epoch before the commit is merged. Confirming is epoch-scoped, so it stays correct even when called after the merge has advanced the group to a later epoch. Proposals and commits share the handshake ratchet, so this single endpoint covers both.
Classic proposal functions
The classic proposal functions (propose, propose_add_member,
propose_self_update, propose_self_update_with_new_signer,
propose_group_context_extensions, leave_group, and the rest) are not
available with the feature, the same way the committing convenience functions
are not. They return only the framed MlsMessageOut and discard the handshake
confirmation data.
Use propose_unconfirmed instead. It retains the handshake secret and returns
the confirmation data alongside the framed proposal, which you confirm with
confirm_handshake_message once the Delivery Service accepts the send. For a
signature-key rotation use propose_self_update_with_new_signer_unconfirmed,
which returns the same confirmation data.
propose_unconfirmed dispatches on the Propose variant, so it also covers
Propose::GroupContextExtensions and the AppDataDictionary update variants.
The committing convenience functions (add_members, add_members_without_update,
swap_members, remove_members, self_update, self_update_with_new_signer,
commit_to_pending_proposals, and update_group_context_extensions) are not
available with the feature. They return only the commit and discard its
confirmation data. Build commits through commit_builder instead, and read the
confirmation from the bundle’s confirmation() as shown above.
Application messages
With the feature enabled, the single-shot create_message is replaced by a two
step send flow, because two emulator clients can race for the same ratchet
generation.
create_unconfirmed_message encrypts the payload, retains the key and nonce,
and returns the message together with the ratchet generation and a
generation_id:
let unconfirmed = main_group
.create_unconfirmed_message(provider, &vc_signer, b"hello")?;
// Attach unconfirmed.generation_id when fanning out, so the Delivery Service
// can detect a generation collision with a sibling.
send_to_delivery_service(unconfirmed.message, unconfirmed.generation_id);
The generation_id is Some on a group bound to a derivation epoch and None
otherwise. It is derived from generation_id_secret over the spec’s
PrivateMessageContext. The reuse guard is computed rather than sampled: the
client resolves the derivation epoch through the (GroupId, GroupEpoch) binding,
picks a value congruent to its emulation leaf index modulo the emulation group
size, and encrypts it with a small-space PRP keyed from reuse_guard_secret.
Once the Delivery Service accepts the message, drop the retained key:
main_group.confirm_application_message(provider.storage(), unconfirmed.epoch, unconfirmed.generation)?;
If the Delivery Service reports a collision, the sibling won that generation.
Process the winning message through process_message, which has a carve-out for
messages arriving from the receiver’s own leaf. Decrypting the winner consumes
the retained key for that generation, so no explicit cleanup is needed. Then
call create_unconfirmed_message again to re-encrypt from the ratchet head.
There is no explicit discard call. A retained unconfirmed key is, from the
receiving side, the same as a skipped-generation key. It is cleaned up by
confirm_application_message, by decrypting a sibling’s message at that
generation, or by
aging out under bounded retention.
On the receiving side, process_message inverts the reuse guard PRP and
recovers the sender’s leaf index in the emulation group, so the application can
attribute the message to a specific emulator client:
let processed = receiver_group
.process_message(provider, message.into_protocol_message().unwrap())?;
if let Some(emulation_leaf) = processed.emulator_sender_leaf_index() {
// The message came from this emulator client of the virtual client.
}
emulator_sender_leaf_index() returns None for messages that did not come
from a virtual client, or on a group with no emulation binding.
KeyPackages and Welcomes
A virtual client publishes KeyPackages so that a sibling can later recover the
private keys and join a higher-level group on its behalf. KeyPackages are built
in batches, because one key_package operation generation seeds a whole batch.
Build the batch with build_vc_batch. It allocates one key_package
generation, derives a per-KeyPackage seed for each index, embeds a
DerivationInfo in every leaf, writes each bundle to local storage, and returns
the generation plus one (KeyPackageBundle, KeyPackageInfo) per KeyPackage:
let batch = KeyPackage::builder()
.leaf_node_capabilities(capabilities)
.leaf_node_extensions(leaf_extensions)?
.build_vc_batch(
ciphersuite,
provider,
&vc_signer,
vc_credential,
emulator_group.group_id(),
count, // number of KeyPackages, must be > 0
)?;
The operation tree is advanced in memory and persisted only after every
KeyPackage is built, so a build failure consumes no generation. A count of 0
returns EmptyBatch before any state is touched.
Assemble the upload the virtual client hands to its sibling from the batch’s
epoch and generation and its KeyPackageInfos. Take the epoch from the batch
rather than resolving it again: the emulation group may have moved on to a newer
derivation epoch in the meantime. OpenMLS fills the emulation leaf_index from
the stored epoch state:
use openmls::components::vc_derivation_info::assemble_vc_key_package_upload;
let infos = batch
.key_packages
.iter()
.map(|(_bundle, info)| info.clone())
.collect();
let upload = assemble_vc_key_package_upload(
provider.storage(),
batch.epoch_id.clone(),
batch.generation,
infos,
)?;
How the upload reaches the sibling emulator clients is up to the application. It
must reach them before the KeyPackages are offered to anyone else. On receipt, a
sibling calls process_vc_key_package_upload, which derives the init and leaf
encryption private keys for each listed reference and stores them keyed by
KeyPackageRef:
use openmls::components::vc_derivation_info::process_vc_key_package_upload;
process_vc_key_package_upload(provider, &upload)?;
After that, Welcome processing runs through the ordinary ProcessedWelcome and
StagedWelcome entry points unchanged. The lookup by KeyPackageRef finds
either a locally generated KeyPackageBundle or the derived virtual-client key
material. Eager derivation costs no forward secrecy: the derived private keys
take the place of any retained operation secret and have to be kept until the
KeyPackage is no longer live anyway.
What is not implemented yet
The implementation tracks the draft but does not yet cover everything in it:
- Onboarding a new emulator client by state transfer (the draft’s
NewEmulatorClientState, Variant A) is not implemented. Onboarding through an external commit (Variant B) works, because it is an application-orchestrated sequence of operations the code already supports. - VC Update proposals are not implemented. Only commits and external commits emit virtual-client leaves.
- The
VirtualClientActioncoordination channel over SafeAAD (the draft’sexternal_joinandkey_package_uploadactions) is not implemented. The transport of the KeyPackage upload is left entirely to the application. - Per-epoch state for dead derivation epochs is not garbage collected automatically.
Refer to the virtual clients draft for the authoritative protocol description.
Release management
The process for releasing a new version of OpenMLS.
Versioning
The versioning follows the Rust and semantic versioning guidelines.
Release Notes
Release notes are published on GitHub with a full changelog and a discussion in the “Release” section. In addition, the release notes are prepended to the CHANGELOG file in each crate’s root folder. The entries in the CHANGELOG file should follow the keep a changelog guide.
Pre-release strategy
Before releasing a minor or major version of the OpenMLS crate, a pre-release version
must be published to crates.io.
Pre-release versions are defined by appending a hyphen, and a series of dot-separated identifiers, i.e., -rc.x where x gets counted up starting at 1.
Pre-releases must be tagged but don’t require release notes or other documentation.
It is also sufficient to tag only the most high-level crate being published.
Crates in this Repository
Publish the workspace with:
cargo publish --workspace
This publishes every workspace member that isn’t marked publish = false (currently: traits, memory_storage, openmls_rust_crypto, libcrux_crypto, basic_credential, openmls_test, sqlite_storage, serialization_helpers, openmls), resolving the dependency order automatically — you no longer need to publish crate-by-crate in a fixed order. Use --dry-run first to verify everything resolves before publishing for real.
openmls_sqlx_storage is not a workspace member (it’s excluded in the root Cargo.toml due to build conflicts) and is not covered by cargo publish --workspace. Publish it separately:
cd sqlx_storage
cargo publish --dry-run
cargo publish
Its only workspace dependency is openmls_traits, so publish it any time after traits has gone out — either before or after the --workspace batch.
Release note and changelog template
## 0.0.0 (2022-02-22)
### Added
- the feature ([#000])
### Changed
- the change ([#000])
### Deprecated
- the deprecated feature ([#000])
### Removed
- the removed feature ([#000])
### Fixed
- the fixed bug ([#000])
### Security
- the fixed security bug ([#000])
[#000]: https://github.com/openmls/openmls/pull/000
Release checklist
- If this is a minor or major release, has a pre-release version been published at least a week before the release?
- If not, first do so and push the release one week.
- Describe the release in the CHANGELOG.md file of each crate.
- Create and publish a git tag for each crate, e.g.
openmls/v0.4.0-rc.99. - Create and publish release notes on Github.
- Publish the workspace with
cargo publish --workspace(see Crates in this Repository above), then publishopenmls_sqlx_storageseparately.
Releases
Release notes for openmls, one page per version.
0.9.0 (2026-08-03)
This release is going out first as
0.9.0-rc.1, per the pre-release strategy. The final0.9.0tag will follow after the rc is deemed good.
Highlights
- Storage. Non-self describing storage formats are officially not supported anymore in OpenMLS starting with this release.
- Storage migration helper. A new opt-in
migration-importfeature lets applications move existing group state to a storage provider using a different serde codec (e.g. bincode -> CBOR), via new import entry points onMlsGroupandPublicGroup. See Migrating from a previous version. - Targeted messages. Initial support for targeted messages, behind the new
targeted-messages-draftfeature flag. - Reworked
AppDataUpdateprocessing. BothMlsGroupandPublicGroupnow return commits carryingAppDataUpdateproposals asProcessedMessageContent::UnresolvedAppDataCommit, replacing the previousunprotect_message/process_unverified_message_with_app_data_updatesflow that exposed unverified content. - MSRV raised to Rust 1.91.
Added
- #1972: APIs for time-based deletion of past epoch secrets, and for setting the past epoch deletion policy for an
MlsGroup. - #2010:
MlsGroup::propose_self_update_with_new_signer. - #2084:
ProcessedMessageContent::OwnPendingCommitvariant. - #2109:
Capabilities::for_provider, plus dedicatedUnsupportedCiphersuiteerrors returned early when a provider doesn’t support a requested ciphersuite. - #2099:
ProcessedMessageContent::OwnPrivateMessagevariant for processing echoed-back own messages. - #2128: Storage-format migration helper (
migration-importfeature). - #2028: Targeted messages (
targeted-messages-draftfeature). - #2046, #2118: P-384, ML-DSA and additional PQ ciphersuites.
- #2037: Safe AAD capabilities from the extensions draft.
- #2045: Unchecked constructors to bypass
KeyPackage/LeafNodevalidation for already-verified input. - #2095:
AppDataUpdateproposals allowed in external commits; group-context getter onVerifiableGroupInfo. - #1979: Application data dictionary extension in key packages.
- #2113:
VcKeyPackageBatchBuilderfor virtual clients (experimental).
Changed
- #2109: Group/key-package creation, welcome processing, external commits and
PublicGroupcreation now fail early withUnsupportedCiphersuiteinstead of failing deep in a crypto operation. - #1980: Enriched lifetime-related leaf node validation errors.
- #1972:
MlsGroup::max_past_epochs()now returnsOption<usize>. - #1963:
MessageEncryptionErroris now public (behindvirtual-clients-draft). - #2043: Renamed and deprecated
MlsGroup::propose_external_psktopropose_pre_shared_key(and the_by_valuevariant). - #2084: Renamed
StageCommitError::OwnCommittoOwnCommitMismatch. - #2099: Removed
ValidationError::CannotDecryptOwnMessage— processing an ownPrivateMessageis no longer an error. - #2060: Renamed the
extensions-draft-08feature flag toextensions-draft(across theopenmls,openmls_sqlite_storageandopenmls_sqlx_storagecrates). - #2092, #2098, #2099: Reworked the
AppDataUpdatereceive-side API onMlsGroupandPublicGroup(see Highlights above). - #2083: Raised MSRV to Rust 1.91.
Fixed
- #2186: Fix wrong computation of tree node indexes when provided invalid inputs.
- #2134: Known structured extension payloads now reject trailing bytes during decoding.
- #2109:
OpenMlsRustCrypto’ssupports()now agrees withsupported_ciphersuites()forMLS_256_MLKEM1024_AES256GCM_SHA512_MLDSA87. - #2089: A Commit without an UpdatePath from this client’s own leaf that doesn’t match the pending commit is now staged normally instead of rejected.
- #2034: Fixed incorrect storage-format deserialization caused by changed integer storage tags; storage-format compatibility with
openmlsv0.7.1 and earlier is restored by default (0-8-1-storage-formatfeature keeps v0.8.1 compatibility). - #2125: A bare
GroupContextExtensionsproposal could bypass theAppDataUpdateimmutability check on theapp_data_dictionaryextension. - #2051: Backward-incompatible deserialization of
PastEpochDeletionPolicy.
Supporting crates
None of these contain breaking changes to their own stable public APIs — all additive (new PQ ciphersuite support, virtual-clients-draft storage plumbing) or internal fixes. Several also carry the extensions-draft-08 → extensions-draft feature-flag rename from #2060 (an unstable/draft feature that was never part of a stable release):
openmls_traits0.6.0 (feature-flag rename, new PQ/virtual-clients/targeted-messages draft feature flags)openmls_memory_storage0.6.0 (feature-flag rename, new features)openmls_rust_crypto0.6.0openmls_libcrux_crypto0.4.0 (feature-flag rename, new features, fix)openmls_basic_credential0.6.0openmls_sqlite_storage0.3.0 (feature-flag rename, new features)openmls_sqlx_storage0.3.0 (feature-flag renameextensions-draft-08→extensions-draft)openmls_test0.3.0 (dev-only)openmls_serialization_helpers0.1.0 (new crate)
See each crate’s own CHANGELOG.md for details.