Introduction
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 caseCredentialType::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:
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. |
max_past_epochs | usize | Maximum number of past epochs for which application messages can be decrypted. The default is 0. |
number_of_resumption_secrets | usize | Number of resumtion secrets to keep. The default is 0. |
use_ratchet_tree_extension | bool | Flag indicating the Ratchet Tree Extension should be used. The default is false . |
required_capabilities | RequiredCapabilitiesExtension | Required capabilities (extensions and proposal types). |
sender_ratchet_configuration | SenderRatchetConfiguration | Sender 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 KeyPackage
s 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 ofValSem010
).
After performing these steps, messages are returned as ProcessedMessage
s 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
ValidationStep | Description | Implemented | Tested | Test File |
---|---|---|---|---|
ValSem001 | Wire format | ✅ | ✅ | openmls/src/group/tests/test_framing_validation.rs |
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 |
Semantic validation of proposals covered by a Commit
ValidationStep | Description | Implemented | Tested | Test File |
---|---|---|---|---|
ValSem100 | Add Proposal: Identity in proposals must be unique among proposals | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem101 | Add Proposal: Signature public key in proposals must be unique among proposals | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem102 | Add Proposal: HPKE init key in proposals must be unique among proposals | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem103 | Add Proposal: Identity in proposals must be unique among existing group members | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem104 | Add Proposal: Signature public key in proposals must be unique among existing group members | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem105 | Add Proposal: HPKE init key in proposals must be unique among existing group members | ✅ | ✅ | 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: Identity must be unchanged between existing member and new proposal | ✅ | ✅ | openmls/src/group/tests/test_proposal_validation.rs |
ValSem110 | Update Proposal: HPKE init key must be unique among existing 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 |
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 Commit contains Removes or Updates | ✅ | ✅ | 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 |
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 not cover any inline Add proposals | ✅ | ✅ | openmls/src/group/tests/test_external_commit_validation.rs |
ValSem243 | External Commit must not cover any inline Update proposals | ✅ | ✅ | openmls/src/group/tests/test_external_commit_validation.rs |
ValSem244 | Identity of inline Remove proposal target and external committer must be the same | ✅ | ✅ | openmls/src/group/tests/test_external_commit_validation.rs |
ValSem245 | External Commit must not cover any ExternalInit proposals by reference | ✅ | ✅ | openmls/src/group/tests/test_external_commit_validation.rs |
ValSem246 | External Commit must contain a path | ✅ | ✅ | openmls/src/group/tests/test_external_commit_validation.rs |
ValSem247 | External Commit signature must be verified using the credential in the path KeyPackage | ✅ | ✅ | openmls/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 theMlsGroupConfig
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 theSenderRatchetConfiguration
. The configuration can be set as thesender_ratchet_configuration
parameter of theMlsGroupConfig
. -
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 theSenderRatchetConfiguration
. The configuration can be set as thesender_ratchet_configuration
parameter of theMlsGroupConfig
.
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