Introduction

OpenMLS Chat OpenMLS List

Tests & Checks ARM64 Build Status codecov

Docs Book Rust Version

OpenMLS is a Rust implementation of Messaging Layer Security based on draft 12+. OpenMLS provides a high-level API to create and manage MLS groups. It supports basic ciphersuites, as well as an interchangable cryptographic backend, key store and random number generator.

This book provides guidance on how to use OpenMLS and its MlsGroup API to perform basic group operations, illustrated with examples.

Supported ciphersuites

  • MLS10_128_HPKEX25519_AES128GCM_SHA256_Ed25519 (MTI)
  • MLS10_128_DHKEMP256_AES128GCM_SHA256_P256
  • MLS10_128_HPKEX25519_CHACHA20POLY1305_SHA256_Ed25519

Supported platforms

OpenMLS is built and tested on the Github CI for the following rust targets.

  • x86_64-unknown-linux-gnu
  • i686-unknown-linux-gnu
  • x86_64-pc-windows-msvc
  • i686-pc-windows-msvc
  • x86_64-apple-darwin

Additionally, we're building and testing aarch64-unknown-linux-gnu on drone.io.

The Github CI also builds (but doesn't test) the following rust targets.

  • aarch64-apple-darwin
  • aarch64-unknown-linux-gnu
  • aarch64-linux-android
  • aarch64-apple-ios
  • aarch64-apple-ios-sim
  • 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 backends implemented right now. But consumers can bring their own implementation. See traits for more details.

Working on OpenMLS

For more details when working on OpenMLS itself please see the Developer.md.

User manual

The user manual describes how to use the different parts of the OpenMLS API.

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). All credentials have in common that they contain a signature public key (with the owner of the credential holding the corresponding private key) which is used to authenticate messages by the owner in the context of one or more groups.

A credential is always embedded in a key package, which is ultimately used to represent a client in a group and which is signed by the private key corresponding to the signature public key of the credential it contains. Clients can decide to use the same credential in multiple key packages (and thus multiple groups) or to use distinct credential per key package.

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 is not specified by MLS.

Creating and using credentials

OpenMLS allows clients to create CredentialBundles, each bundling a credential and the private key corresponding to the signature public key inside it. A BasicCredential, which is currently the only credential type supported by MLS, consists only of the identity, an opaque byte-vector, as well as the signature public key and the corresponding signature scheme. Thus, to create a fresh CredentialBundle, 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 case CredentialType::Basic.
  • signature_scheme: SignatureScheme: The signature scheme of the signature keypair, e.g. SignatureScheme::ED25519.
    let credential_bundle =
        CredentialBundle::new(identity, credential_type, signature_algorithm, backend)?;

After creating the credential bundle, clients should store it in the key store so that it can be automatically retrieved when performing a group operation through the MlsGroup API that requires the client to sign a message.

    let credential = credential_bundle.credential().clone();
    backend
        .key_store()
        .store(
            &credential
                .signature_key()
                .tls_serialize_detached()
                .expect("Error serializing signature key."),
            &credential_bundle,
        )
        .expect("An unexpected error occurred.");

All functions and structs related to credentials can be found in the credentials module.

Key packages

To enable asynchronous establishment of groups through pre-publishing key material, as well as to represent clients in group, MLS relies on key packages. Key packages hold a number of pieces of information:

  • a public HPKE encryption key to enable MLS' basic group key distribution feature
  • the life time 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

Creating key packages

Before clients can communicate with each other using OpenMLS they need to generate key packages and publish them with the Delivery Service. Clients can generate an arbitrary number of key packages ahead of time.

Clients keep the private key material corresponding to a key package locally in the key store and fetch it from there when a key package was used to add them to a new group.

Clients need to choose a few parameters to create a KeyPackageBundle:

  • ciphersuites: &[CiphersuiteName]: A list of ciphersuites supported by the client.
  • extensions: Vec<Extensions>: A list of supported extensions.

Clients must specify at least one ciphersuite, and must not advertize ciphersuites they do not support.

Clients should specify all extensions they support. Mandatory extensions, like the LifetimeExtension can be specified here with specific values. If no extensions are specified, mandatory extensions are created on the fly with default values. See the documentation of extensions for more details.

    // Define extensions
    let extensions = vec![Extension::LifeTime(LifetimeExtension::new(
        60 * 60 * 24 * 90, // Maximum lifetime of 90 days, expressed in seconds
    ))];

    // Fetch the credential bundle from the key store
    let credential_bundle = backend
        .key_store()
        .read(
            &credential
                .signature_key()
                .tls_serialize_detached()
                .expect("Error serializing signature key."),
        )
        .expect("An unexpected error occurred.");

    // Create the key package bundle
    let key_package_bundle =
        KeyPackageBundle::new(ciphersuites, &credential_bundle, backend, extensions)?;

After creating the key package bundle, clients should store it in the key store so that it can be reused during group operations:

    let key_package = key_package_bundle.key_package().clone();

    // Store it in the key store
    backend
        .key_store()
        .store(
            key_package
                .hash_ref(backend.crypto())
                .expect("Could not hash KeyPackage.")
                .value(),
            &key_package_bundle,
        )
        .expect("An unexpected error occurred.");

All functions and structs related to key packages can be found in the key_packages module.

Group configuration

The group configuration can be specified by building a MlsGroupConfig object or choosing the default value. The default velaue contains safe values for all parameters and is suitable for scenarios whithout particular requirements.

The following parameters can be set:

NameTypeExplanation
wire_format_policyWireFormatPolicyDefines the wire format policy for outgoing and incoming handshake messages.
padding_sizeusizeSize of padding in bytes. The default is 0.
max_past_epochsusizeMaximum number of past epochs for which application messages can be decrypted. The default is 0.
number_of_resumption_secretsusizeNumber of resumtion secrets to keep. The default is 0.
use_ratchet_tree_extensionboolFlag indicating the Ratchet Tree Extension should be used. The default is false.
required_capabilitiesRequiredCapabilitiesExtensionRequired capabilities (extensions and proposal types).
sender_ratchet_configurationSenderRatchetConfigurationSender ratchet configuration.

Example configuration:

    let mls_group_config = MlsGroupConfig::builder()
        .padding_size(100)
        .sender_ratchet_configuration(SenderRatchetConfiguration::new(
            10,   // out_of_order_tolerance
            2000, // maximum_forward_distance
        ))
        .use_ratchet_tree_extension(true)
        .build();

Creating groups

Before a group can be created, a group configuration (MlsGroupConfiguration) needs to be defined. The default values of configuration parameters are picked for safety, however be sure to check all parameters carefully to ascertain if they match your implementation's requirements. See Group configuration for more details.

In addition to the group configuration, the client should define all supported and requitred extension for the group. The negotatiation mechanism for extension in MLS consists in setting an intial 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:

    // Define extensions
    let extensions = vec![Extension::LifeTime(LifetimeExtension::new(
        60 * 60 * 24 * 90, // Maximum lifetime of 90 days, expressed in seconds
    ))];

    // Fetch the credential bundle from the key store
    let credential_bundle = backend
        .key_store()
        .read(
            &credential
                .signature_key()
                .tls_serialize_detached()
                .expect("Error serializing signature key."),
        )
        .expect("An unexpected error occurred.");

    // Create the key package bundle
    let key_package_bundle =
        KeyPackageBundle::new(ciphersuites, &credential_bundle, backend, extensions)?;

Every group has a unique group ID that needs to be specified at the time of the group creation. The group ID cannot be changed after the group creation and therefore remains immutable throughout the group's lifetime. It should be chosen so that it doesn't collide with any other group IDs in the same system:

    let group_id = GroupId::from_slice(b"Test Group");

After that the group can be created:

    let mut alice_group = MlsGroup::new(
        backend,
        &mls_group_config,
        group_id,
        alice_key_package
            .hash_ref(backend.crypto())
            .expect("Could not hash KeyPackage.")
            .as_slice(),
    )
    .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 directly from the Welcome message. If the group configuration does not use the ratchet tree extension, the ratchet tree needs to be provided as well.

    let mut bob_group = MlsGroup::new_from_welcome(
        backend,
        &mls_group_config,
        welcome,
        None, // We use the ratchet tree extension, so we don't provide a ratchet tree here
    )
    .expect("Error joining group from Welcome");

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) = alice_group
        .add_members(backend, &[bob_key_package])
        .expect("Could not add members.");

The function returns the tuple (MlsMessageOut, Welcome). The MlsMessageOut contains a Commit message that needs to be fanned out to existing members of the group. The Welcome message needs to be sent to the newly added members.

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 = alice_group
        .propose_add_member(backend, &bob_key_package)
        .expect("Could not create proposal to add Bob");

In this case the the function returns an MlsMessageOut that needs to be fanned out to existing group members.

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) = charlie_group
        .remove_members(backend, &[bob_kp_ref])
        .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 members of the group. Despite the fact that members were only removed in this operation, the Commit message could potentially also cover Add Proposals that were previously received in the epoch. Therefore the function can also optionally return a Welcome message. The Welcome message needs to 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 = alice_group
        .propose_remove_member(
            backend,
            charlie_group
                .key_package_ref()
                .expect("An unexpected error occurred."),
        )
        .expect("Could not create proposal to remove Charlie.");

In this case the 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 ProcessedMessage::StagedCommitMessage(staged_commit) = bob_processed_message {
        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_old_kpr));
            }
            RemoveOperation::TheyLeft(_) => unreachable!(),
            RemoveOperation::TheyWereRemovedBy(_) => unreachable!(),
            RemoveOperation::WeRemovedThem(_) => unreachable!(),
        }

        // Merge staged Commit
        bob_group
            .merge_staged_commit(*staged_commit)
            .expect("Could not merge 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();
    assert_eq!(members.len(), 2);
    assert_eq!(members[0].credential().identity(), b"Alice");
    assert_eq!(members[1].credential().identity(), b"Charlie");

Updating own key package

Immediate operation

Members can update their own leaf key package atomically with the .self_update() function. The application can optionally provide a KeyPackage manually. If not, a key package will be created on the fly with the same extensions as the current one, but with a fresh HPKE init key.

    let (mls_message_out, welcome_option) = bob_group
        .self_update(
            backend,
            None, // We don't provide a key package, it will be created on the fly instead
        )
        .expect("Could not update own key package.");

The function returns the tuple (MlsMessageOut, Option<Welcome>). The MlsMessageOut contains a Commit message that needs to be fanned out to existing members of the group. Despite the fact that the member only updates its own key package in this operation, the Commit message could potentially also cover Add Proposals that were previously received in the epoch. Therefore the function can also optionally return a Welcome message. The Welcome message needs to be sent to the newly added members.

Proposal

Members can also update their key package as a proposal (without the corresponding Commit message) by using the .propose_self_update() function. Just like with the .self_update() function, an optional key package can be provided:

    let mls_message_out = alice_group
        .propose_self_update(
            backend,
            None, // We don't provide a key package, it will be created on the fly instead
        )
        .expect("Could not create update proposal.");

In this case the the function returns an MlsMessageOut that needs to be fanned out to existing group members.

Leaving a group

Members can indicate to the other members of the group that they wish to leave the group by using the leave_group() function, which creates a remove proposal targeting the member's own leaf. It is not possible for the member to 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(backend)
        .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 how to create Remove Proposals, see Removing members from a group.

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(backend, message_alice)
        .expect("Error creating application message.");

Note that the theoretical maximum length of application messages is 2^32 bytes, however in practice messages should be much shorter unless the Delivery Service can cope with very long messages.

The function returns an MlsMessageOut that needs to be sent to the Delivery Service for fanout to other members of the group. 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 messages 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 needs to 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) = alice_group
        .commit_to_pending_proposals(backend)
        .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 members of the group. In case the Commit message also covers Add Proposals that were 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. The Welcome message needs to be sent to the newly added members.

Processing incoming messages

Processing incoming messages happens in different phases:

Deserializing messages

Incoming messages can be deserialized from byte slices into an MlsMessageIn:

    let mls_message_in =
        MlsMessageIn::try_from_bytes(&bytes).expect("Could not deserialize message.");

If the message is malformed, the function will fail with an error.

Parsing messages

In the next step, the incoming message needs to be parsed. If the message was encrypted, it will be decrypted automatically:

    let unverified_message = bob_group
        .parse_message(mls_message_in, backend)
        .expect("Could not parse message.");

Parsing can fail, if e.g. dercrypting the message fails. The exact reason for failure is returned in the error.

Processing messages

In the next step the unverified message needs to be processed. This step performs all remaining validity checks and also verifies the message's signature. Optionally, a signature key can be provided to verify the message's signature. This can be used when processing external messages. By default, the sender's credential is used to verify the signature.

    let processed_message = bob_group
        .process_unverified_message(
            unverified_message,
            None, // No external signature key
            backend,
        )
        .expect("Could not process unverified message.");

Interpreting the processed message

In the last step, the message is ready for inspection. There are 3 different kinds of messages:

Application messages

Application messages simply return the original byte slice:

    if let ProcessedMessage::ApplicationMessage(application_message) = processed_message {
        // Check the message
        assert_eq!(application_message.into_bytes(), b"Hi, I'm Alice!");
    }

Proposals

Standalone proposals are returned as a QueuedProposal, indicating the fact that they are pending proposals. The actual 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 ProcessedMessage::ProposalMessage(staged_proposal) = charlie_processed_message {
        // 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().credential(), &bob_credential);
        } else {
            panic!("Expected an AddProposal.");
        }

        // Check that Alice added Bob
        assert!(matches!(
            staged_proposal.sender(),
            Sender::Member(member) if member == alice_group
            .key_package_ref()
            .expect("An unexpected error occurred.")
        ));
        // Store proposal
        charlie_group.store_pending_proposal(*staged_proposal);
    }

Commit messages

Commit messages are returned as StagedCommit objects. The proposals they cover can be inspected through different functions, depending on the proposal type. For more details see the StagedCommit documentation. 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.

    if let ProcessedMessage::StagedCommitMessage(staged_commit) = alice_processed_message {
        // 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
                .key_package_ref()
                .expect("An unexpected error occurred.")
        );
        // Check that Charlie removed Bob
        assert!(matches!(
            remove.sender(),
            Sender::Member(member) if member == &charlies_old_kpr
        ));
        // Merge staged commit
        alice_group
            .merge_staged_commit(*staged_commit)
            .expect("Could not merge 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 its 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 refelect the different scenarios listed above.

    if let ProcessedMessage::StagedCommitMessage(staged_commit) = bob_processed_message {
        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_old_kpr));
            }
            RemoveOperation::TheyLeft(_) => unreachable!(),
            RemoveOperation::TheyWereRemovedBy(_) => unreachable!(),
            RemoveOperation::WeRemovedThem(_) => unreachable!(),
        }

        // Merge staged Commit
        bob_group
            .merge_staged_commit(*staged_commit)
            .expect("Could not merge Commit.");
    } else {
        unreachable!("Expected a StagedCommit.");
    }

Persistence of Group Data

The state of a given MlsGroup instance can be written or read at any time using the .save() or .load() functions respectively. The functions take as input a struct implementing either the Write (.save()) or Read (.load()) trait.

Since some group operations might or might not change the MlsGroup state depending on the context, the group maintains the state_changed flag, which is set to true whenever the state is changed by an MlsGroup function. The state of the flag can be queried using the .state_changed() function.

Group Lockout Upon State Loss

MLS provides strong Post-Compromise Security properties, which means that key material is regularly refreshed and old key material becomes stale very quickly. As a consequence, regularly persisting state is important, especially after the client has created a commit or issued an Update proposal, thus introducing new key material into the group. A loss of state in such a situation is only recoverable in specific cases, where the commit was rejected by the Delivery Service or if the proposed Update was not committed. To continue participating in a group after a loss of group state, a re-join is required in most cases. To avoid a loss of state and the associated re-join, persisting MlsGroup state after each state-changing group operation is mandatory.

Forward-Secrecy Considerations

The MlsGroup state that is persisted using the .save() function contains private key material. As a consequence, the application needs to delete old group states to achieve Forward-Secrecy w.r.t. that key material. Since, as detailed above, an old group state is stale immediately after most group operations, we recommend deleting old group states as soon as a new one was written.

crypto-subtle feature

This feature of the OpenMLS crate allows importing and exporting private signature keys that can be used with credentials.

⚠️ Note that no checks are performed on the keys, use this feature at your own risk. If you create a credential from an existing key, or if you export key material you are responsible for the deletion of that key. If that key is kept outside of OpenMLS, updating a leaf will not be enough to achieve Forward Secrecy/Post-compromise Security.

Importing keys

A signature keypair can be created from existing raw keys with the following function call:

SignatureKeypair::from_bytes(
    signature_scheme: SignatureScheme,
    private_key: Vec<u8>,
    public_key: Vec<u8>,
) -> Self

Exporting keys

The raw signature private key can be exported with the following functions call:

SignaturePrivateKey::as_slice(&self) -> &[u8]

Building a BasicCredential from existing keys

let signature_scheme = SignatureScheme::ED25519;

let private_key = vec![1, 2, 3]; // Sample private key as raw bytes
let public_key = vec![4, 5, 6]; // Sample public key as raw bytes

let identity = vec![7, 8, 9]; // Sample identity

let signature_keypair 
    = SignatureKeypair::from_bytes(signature_scheme, public_key, private_key);

let credential_bundle = CredentialBundle::from_parts(identity, signature_keypair);

Traits & External Types

OpenMLS defines a number of traits that have to be implemented in order 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 the key store

The key store is probably one of the most interesting traits because applications that use OpenMLS will interact with it. See the OpenMlsKeyStore trait description for details but note that the key used to store, read, and delete values in the key store has to be provided as a byte slice.

In the following examples we have a ciphersuite and a backend (OpenMlsCryptoProvider).

    // First we generate a credential and key package for our user.
    let credential_bundle = CredentialBundle::new(
        b"User ID".to_vec(),
        CredentialType::Basic,
        SignatureScheme::from(ciphersuite),
        backend,
    )
    .unwrap();
    let key_package_bundle =
        KeyPackageBundle::new(&[ciphersuite], &credential_bundle, backend, vec![])
            .expect("Error generating new key package bundle.");

    // In order to store something in the key store we need to define an ID.
    // Here we simply take the key package reference.
    let id = key_package_bundle
        .key_package()
        .hash_ref(backend.crypto())
        .expect("Failed to hash KeyPackage.");

    // Now we can store the key_package_bundle.
    backend
        .key_store()
        .store(id.as_slice(), &key_package_bundle)
        .expect("Failed to store key package bundle in keystore.");

In order to delete a value the delete is called with the identifier.

    // Delete the key package bundle.
    backend
        .key_store()
        .delete(id.as_slice())
        .expect("Error deleting key package bundle");

Retrieving a value from the key store is as simple as calling read. In this example we assume that we got a credential where we want to retrieve the credential bundle for, i.e. the private key material.

    // In order to read something from the key store we need to define an ID.
    // Here we simply take the serialized public key of the credential.
    let id = credential
        .signature_key()
        .tls_serialize_detached()
        .expect("Error serializing the credential's public key.");

    let credential_bundle: CredentialBundle = backend
        .key_store()
        .read(&id)
        .expect("Error retrieving the credential bundle");

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.

Evercrypt Provider In addition to the Rust Crypto Provider there's the Evercrypt provider that uses the formally verified HACL*/Evercrypt library. Note that this provider does not work equally well on all platforms yet.

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 into an array or vector.

pub trait OpenMlsRand {
    type Error: Debug + Clone + PartialEq + Into<String>;

    /// 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
pub trait OpenMlsCrypto {

OpenMlsKeyStore

This trait defines a CRUD API for a key store that is used to store long-term key material from OpenMLS.

The key store provides functions to store, read and delete values. Note that it does not allow to update values. Instead entries must be deleted and newly stored.

/// The Key Store trait
pub trait OpenMlsKeyStore: Send + Sync {
    /// The error type returned by the [`OpenMlsKeyStore`].
    type Error: Debug + Clone + PartialEq + Into<String>;

    /// Store a value `v` that implements the [`ToKeyStoreValue`] trait for
    /// serialization for ID `k`.
    ///
    /// Returns an error if storing fails.
    fn store<V: ToKeyStoreValue>(&self, k: &[u8], v: &V) -> Result<(), Self::Error>
    where
        Self: Sized;

    /// Read and return a value stored for ID `k` that implements the
    /// [`FromKeyStoreValue`] trait for deserialization.
    ///
    /// Returns [`None`] if no value is stored for `k` or reading fails.
    fn read<V: FromKeyStoreValue>(&self, k: &[u8]) -> Option<V>
    where
        Self: Sized;

    /// Delete a value stored for ID `k`.
    ///
    /// Returns an error if storing fails.
    fn delete(&self, k: &[u8]) -> Result<(), Self::Error>;
}

NOTE: Right now key material needs to be extractable from the key store. This will most likely change in the future.

OpenMlsCryptoProvider

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 OpenMlsCryptoProvider: Send + Sync {
    type CryptoProvider: crypto::OpenMlsCrypto;
    type RandProvider: random::OpenMlsRand;
    type KeyStoreProvider: key_store::OpenMlsKeyStore;

    /// Get the crypto provider.
    fn crypto(&self) -> &Self::CryptoProvider;

    /// Get the randomness provider.
    fn rand(&self) -> &Self::RandProvider;

    /// Get the key store provider.
    fn key_store(&self) -> &Self::KeyStoreProvider;
}

Implementation Notes

It is not necessary to implement all sub-traits if one functionality is missing. If you want to use a persisting key store for example, it is sufficient to do a new implementation of the key store trait and combine it with one of the provided crypto and randomness trait implementations.

External Types

For interoperability this crate also defines a number of types and algorithm identifiers.

AEADs

The following AEADs are defined.

pub enum AeadType {
    /// AES GCM 128
    Aes128Gcm = 0x0001,

    /// AES GCM 256
    Aes256Gcm = 0x0002,

    /// ChaCha20 Poly1305
    ChaCha20Poly1305 = 0x0003,
}

An AEAD provides the following functions to get the according values for each algorithm.

  • tag_size
  • key_size
  • nonce_size

Hashing

The following hash algorithms are defined.

pub enum HashType {
    Sha2_256 = 0x04,
    Sha2_384 = 0x05,
    Sha2_512 = 0x06,
}

An hash algorithm provides the following functions to get the according values for each algorithm.

  • size

Signatures

The following signature schemes are defined.

pub enum SignatureScheme {
    /// ECDSA_SECP256R1_SHA256
    ECDSA_SECP256R1_SHA256 = 0x0403,
    /// ECDSA_SECP384R1_SHA384
    ECDSA_SECP384R1_SHA384 = 0x0503,
    /// ECDSA_SECP521R1_SHA512
    ECDSA_SECP521R1_SHA512 = 0x0603,
    /// ED25519
    ED25519 = 0x0807,
    /// ED448
    ED448 = 0x0808,
}

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.

#[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 x25519
    DhKem25519 = 0x0020,

    /// DH KEM on x448
    DhKem448 = 0x0021,
#[repr(u16)]
pub enum HpkeKdfType {
    /// HKDF SHA 256
    HkdfSha256 = 0x0001,

    /// HKDF SHA 384
    HkdfSha384 = 0x0002,

    /// HKDF SHA 512
    HkdfSha512 = 0x0003,
#[repr(u16)]
pub enum HpkeAeadType {
    /// AES GCM 128
    AesGcm128 = 0x0001,

    /// AES GCM 256
    AesGcm256 = 0x0002,

    /// ChaCha20 Poly1305
    ChaCha20Poly1305 = 0x0003,

    /// Export-only
    Export = 0xFFFF,

In addition helper structs for HpkeCiphertext and HpkeKeyPair are defined.

)]
pub struct HpkeCiphertext {
    pub kem_output: TlsByteVecU16,
    pub ciphertext: TlsByteVecU16,
#[derive(Debug, Clone)]
pub struct HpkeKeyPair {
    pub private: Vec<u8>,
    pub public: Vec<u8>,

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 an MLSPlaintext or an MLSCiphertext. For the latter case, further syntax checks are applied once the message is decrypted.

Semantic validation

Every function in the processing chain performs a number of semantic validation steps. For a list of these steps, see below. In the following, we will give a brief overview over 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 (ValSem001). The function also performs a number of basic semantic validation steps, such as consistency of Group id, Epoch and Sender data between message and group (ValSem002-ValSem007 and ValSem109). 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 properly authenticated by 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 (ValSem100-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, ValSem247 is checked as part of ValSem010).

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 own 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

ValidationStepDescriptionImplementedTestedTest File
ValSem001Wire formatopenmls/src/group/tests/test_framing_validation.rs
ValSem002Group idopenmls/src/group/tests/test_framing_validation.rs
ValSem003Epochopenmls/src/group/tests/test_framing_validation.rs
ValSem004Sender: Member: check the sender points to a non-blank leafopenmls/src/group/tests/test_framing_validation.rs
ValSem005Application messages must use ciphertextopenmls/src/group/tests/test_framing_validation.rs
ValSem006Ciphertext: decryption needs to workopenmls/src/group/tests/test_framing_validation.rs
ValSem007Membership tag presenceopenmls/src/group/tests/test_framing_validation.rs
ValSem008Membership tag verificationopenmls/src/group/tests/test_framing_validation.rs
ValSem009Confirmation tag presenceopenmls/src/group/tests/test_framing_validation.rs
ValSem010Signature verificationopenmls/src/group/tests/test_framing_validation.rs

Semantic validation of proposals covered by a Commit

ValidationStepDescriptionImplementedTestedTest File
ValSem100Add Proposal: Identity in proposals must be unique among proposalsopenmls/src/group/tests/test_proposal_validation.rs
ValSem101Add Proposal: Signature public key in proposals must be unique among proposalsopenmls/src/group/tests/test_proposal_validation.rs
ValSem102Add Proposal: HPKE init key in proposals must be unique among proposalsopenmls/src/group/tests/test_proposal_validation.rs
ValSem103Add Proposal: Identity in proposals must be unique among existing group membersopenmls/src/group/tests/test_proposal_validation.rs
ValSem104Add Proposal: Signature public key in proposals must be unique among existing group membersopenmls/src/group/tests/test_proposal_validation.rs
ValSem105Add Proposal: HPKE init key in proposals must be unique among existing group membersopenmls/src/group/tests/test_proposal_validation.rs
ValSem106Add Proposal: required capabilitiesopenmls/src/group/tests/test_proposal_validation.rs
ValSem107Remove Proposal: Removed member must be unique among proposalsopenmls/src/group/tests/test_proposal_validation.rs
ValSem108Remove Proposal: Removed member must be an existing group memberopenmls/src/group/tests/test_proposal_validation.rs
ValSem109Update Proposal: Identity must be unchanged between existing member and new proposalopenmls/src/group/tests/test_proposal_validation.rs
ValSem110Update Proposal: HPKE init key must be unique among existing membersopenmls/src/group/tests/test_proposal_validation.rs
ValSem111Update Proposal: The sender of a full Commit must not include own update proposalsopenmls/src/group/tests/test_proposal_validation.rs
ValSem112Update Proposal: The sender of a standalone update proposal must be of type memberopenmls/src/group/tests/test_proposal_validation.rs

Commit message validation

ValidationStepDescriptionImplementedTestedTest File
ValSem200Commit must not cover inline self Remove proposalopenmls/src/group/tests/test_commit_validation.rs
ValSem201Path must be present, if Commit contains Removes or Updatesopenmls/src/group/tests/test_commit_validation.rs
ValSem202Path must be the right lengthopenmls/src/group/tests/test_commit_validation.rs
ValSem203Path secrets must decrypt correctlyopenmls/src/group/tests/test_commit_validation.rs
ValSem204Public keys from Path must be verified and match the private keys from the direct pathopenmls/src/group/tests/test_commit_validation.rs
ValSem205Confirmation tag must be successfully verifiedopenmls/src/group/tests/test_commit_validation.rs

External Commit message validation

ValidationStepDescriptionImplementedTestedTest File
ValSem240External Commit must cover at least one inline ExternalInit proposalopenmls/src/group/tests/test_external_commit_validation.rs
ValSem241External Commit must cover at most one inline ExternalInit proposalopenmls/src/group/tests/test_external_commit_validation.rs
ValSem242External Commit must not cover any inline Add proposalsopenmls/src/group/tests/test_external_commit_validation.rs
ValSem243External Commit must not cover any inline Update proposalsopenmls/src/group/tests/test_external_commit_validation.rs
ValSem244Identity of inline Remove proposal target and external committer must be the sameopenmls/src/group/tests/test_external_commit_validation.rs
ValSem245External Commit must not cover any ExternalInit proposals by referenceopenmls/src/group/tests/test_external_commit_validation.rs
ValSem246External Commit must contain a pathopenmls/src/group/tests/test_external_commit_validation.rs
ValSem247External Commit signature must be verified using the credential in the path KeyPackageopenmls/src/group/tests/test_external_commit_validation.rs

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. Make sure that check in the code which scenarios and group sizes are enabled.

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 an example of high fluctuation groups. It is the same scenario as the somewhat stable group but with a very small Y, Z.

Extreme Scenarios

In addition to the three scenarios above extreme and corner cases are interesting.

Every second leave is blank

Only every second leave in the tree is non-blank.

Use Case Scenarios

A collection of common use cases/flows.

Long-time offline device

If a device has been offline for a while, it has to process a large number of application and protocol messages.

Tree scenarios

In addition 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. How is the performance different 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

To achieve forward secrecy, OpenMLS drops key material immediately after a given key is no longer required by the protocol. 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.

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, as well as 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 messages 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 accomodate 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 setting the max_past_epochs field in the MlsGroupConfig to the desired number of epochs.

  • 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_tolerance parameter of the SenderRatchetConfiguration. The configuration can be set as the sender_ratchet_configuration parameter of the MlsGroupConfig.

  • 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_distance parameter of the SenderRatchetConfiguration. The configuration can be set as the sender_ratchet_configuration parameter of the MlsGroupConfig.

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. -pre.x where x gets counted up starting at 1. Pre-releases must get tagged but don't require release notes or any other documentation. It is also sufficient to tag only for the most high-level crate being published.


Crates in this Repository

It is important that the crates are publish in the order below.

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.
  • Create and publish a git tag for each crate, e.g. openmls/v0.4.0-pre.1.
  • Create and publish release notes on Github.
  • Publish the crates to crates.io