Over the lifetime of a group, its membership can change, and existing members might want to change their keys in order to achieve post-compromise security. In MLS, each such change is accomplished by a two-step process:
-
A proposal to make the change is broadcast to the group in a Proposal message.
-
A member of the group or a new member broadcasts a Commit message that causes one or more proposed changes to enter into effect.
In cases where the Proposal and Commit are sent by the same member, these two steps can be combined by sending the proposals in the commit.
The group thus evolves from one cryptographic state to another each time a Commit message is sent and processed. These states are referred to as "epochs" and are uniquely identified among states of the group by eight-octet epoch values. When a new group is initialized, its initial state epoch is 0x0000000000000000. Each time a state transition occurs, the epoch number is incremented by one.
Proposals are included in a FramedContent by way of a Proposal structure that indicates their type:
// See the "MLS Proposal Types" IANA registry for values
uint16 ProposalType;
struct {
ProposalType proposal_type;
select (Proposal.proposal_type) {
case add: Add;
case update: Update;
case remove: Remove;
case psk: PreSharedKey;
case reinit: ReInit;
case external_init: ExternalInit;
case group_context_extensions: GroupContextExtensions;
};
} Proposal;
On receiving a FramedContent containing a Proposal, a client
MUST verify the signature inside FramedContentAuthData and that the
epoch field of the enclosing FramedContent is equal to the
epoch field of the current GroupContext object. If the verification is successful, then the Proposal should be cached in such a way that it can be retrieved by hash (as a ProposalOrRef object) in a later Commit message.
An Add proposal requests that a client with a specified KeyPackage be added to the group.
struct {
KeyPackage key_package;
} Add;
An Add proposal is invalid if the KeyPackage is invalid according to
Section 10.1.
An Add is applied after being included in a Commit message. The position of the Add in the list of proposals determines the leaf node where the new member will be added. For the first Add in the Commit, the corresponding new member will be placed in the leftmost empty leaf in the tree, for the second Add, the next empty leaf to the right, etc. If no empty leaf exists, the tree is extended to the right.
-
Identify the leaf L for the new member: if there are empty leaves in the tree, L is the leftmost empty leaf. Otherwise, the tree is extended to the right as described in Section 7.7, and L is assigned the leftmost new blank leaf.
-
For each non-blank intermediate node along the path from the leaf L to the root, add L's leaf index to the unmerged_leaves list for the node.
-
Set the leaf node L to a new node containing the LeafNode object carried in the leaf_node field of the KeyPackage in the Add.
An Update proposal is a similar mechanism to Add with the distinction that it replaces the sender's LeafNode in the tree instead of adding a new leaf to the tree.
struct {
LeafNode leaf_node;
} Update;
An Update proposal is invalid if the LeafNode is invalid for an Update proposal according to
Section 7.3.
A member of the group applies an Update message by taking the following steps:
-
Replace the sender's LeafNode with the one contained in the Update proposal.
-
Blank the intermediate nodes along the path from the sender's leaf to the root.
A Remove proposal requests that the member with the leaf index
removed be removed from the group.
struct {
uint32 removed;
} Remove;
A Remove proposal is invalid if the
removed field does not identify a non-blank leaf node.
A member of the group applies a Remove message by taking the following steps:
-
Identify the leaf node matching removed. Let L be this leaf node.
-
Replace the leaf node L with a blank node.
-
Blank the intermediate nodes along the path from L to the root.
-
Truncate the tree by removing the right subtree until there is at least one non-blank leaf node in the right subtree. If the rightmost non-blank leaf has index L, then this will result in the tree having 2d leaves, where d is the smallest value such that 2d > L.
A PreSharedKey proposal can be used to request that a pre-shared key be injected into the key schedule in the process of advancing the epoch.
struct {
PreSharedKeyID psk;
} PreSharedKey;
A PreSharedKey proposal is invalid if any of the following is true:
-
The PreSharedKey proposal is not being processed as part of a reinitialization of the group (see Section 11.2), and the PreSharedKeyID has psktype set to resumption and usage set to reinit.
-
The PreSharedKey proposal is not being processed as part of a subgroup branching operation (see Section 11.3), and the PreSharedKeyID has psktype set to resumption and usage set to branch.
-
The psk_nonce is not of length KDF.Nh.
The
psk_nonce MUST be randomly sampled. When processing a Commit message that includes one or more PreSharedKey proposals, group members derive
psk_secret as described in
Section 8.4, where the order of the PSKs corresponds to the order of the PreSharedKey proposals in the Commit.
A ReInit proposal represents a request to reinitialize the group with different parameters, for example, to increase the version number or to change the cipher suite. The reinitialization is done by creating a completely new group and shutting down the old one.
struct {
opaque group_id<V>;
ProtocolVersion version;
CipherSuite cipher_suite;
Extension extensions<V>;
} ReInit;
A ReInit proposal is invalid if the
version field is less than the version for the current group.
A member of the group applies a ReInit proposal by waiting for the committer to send the Welcome message that matches the ReInit, according to the criteria in
Section 11.2.
An ExternalInit proposal is used by new members that want to join a group by using an external commit. This proposal can only be used in that context.
struct {
opaque kem_output<V>;
} ExternalInit;
A member of the group applies an ExternalInit message by initializing the next epoch using an init secret computed as described in
Section 8.3. The
kem_output field contains the required KEM output.
A GroupContextExtensions proposal is used to update the list of extensions in the GroupContext for the group.
struct {
Extension extensions<V>;
} GroupContextExtensions;
A GroupContextExtensions proposal is invalid if it includes a
required_capabilities extension and some members of the group do not support some of the required capabilities (including those added in the same Commit, and excluding those removed).
A member of the group applies a GroupContextExtensions proposal with the following steps:
-
Remove all of the existing extensions from the GroupContext object for the group and replace them with the list of extensions in the proposal. (This is a wholesale replacement, not a merge. An extension is only carried over if the sender of the proposal includes it in the new list.)
Note that once the GroupContext is updated, its inclusion in the
confirmation_tag by way of the key schedule will confirm that all members of the group agree on the extensions in use.
Proposals can be constructed and sent to the group by a party that is outside the group in two cases. One case, indicated by the
external SenderType, allows an entity outside the group to submit proposals to the group. For example, an automated service might propose removing a member of a group who has been inactive for a long time, or propose adding a newly hired staff member to a group representing a real-world team. An
external sender might send a ReInit proposal to enforce a changed policy regarding MLS versions or cipher suites.
The
external SenderType requires that signers are pre-provisioned to the clients within a group and can only be used if the
external_senders extension is present in the group's GroupContext.
The other case, indicated by the
new_member_proposal SenderType, is useful when existing members of the group can independently verify that an Add proposal sent by the new joiner itself (not an existing member) is authorized. External proposals that are not authorized are considered invalid.
An external proposal
MUST be sent as a PublicMessage object, since the sender will not have the keys necessary to construct a PrivateMessage object.
Proposals of some types cannot be sent by an
external sender. Among the proposal types defined in this document, only the following types may be sent by an
external sender:
-
add
-
remove
-
psk
-
reinit
-
group_context_extensions
Messages from
external senders containing proposal types other than the above
MUST be rejected as malformed. New proposal types defined in the future
MUST define whether they may be sent by
external senders. The "Ext" column in the "MLS Proposal Types" registry (
Section 17.4) reflects this property.
The
external_senders extension is a group context extension that contains the credentials and signature keys of senders that are permitted to send external proposals to the group.
struct {
SignaturePublicKey signature_key;
Credential credential;
} ExternalSender;
ExternalSender external_senders<V>;
A group member creating a Commit and a group member processing a Commit
MUST verify that the list of committed proposals is valid using one of the following procedures, depending on whether the Commit is external or not. If the list of proposals is invalid, then the Commit message
MUST be rejected as invalid.
For a regular, i.e., not external, Commit, the list is invalid if any of the following occurs:
-
It contains an individual proposal that is invalid as specified in Section 12.1.
-
It contains an Update proposal generated by the committer.
-
It contains a Remove proposal that removes the committer.
-
It contains multiple Update and/or Remove proposals that apply to the same leaf. If the committer has received multiple such proposals they SHOULD prefer any Remove received, or the most recent Update if there are no Removes.
-
It contains multiple Add proposals that contain KeyPackages that represent the same client according to the application (for example, identical signature keys).
-
It contains an Add proposal with a KeyPackage that represents a client already in the group according to the application, unless there is a Remove proposal in the list removing the matching client from the group.
-
It contains multiple PreSharedKey proposals that reference the same PreSharedKeyID.
-
It contains multiple GroupContextExtensions proposals.
-
It contains a ReInit proposal together with any other proposal. If the committer has received other proposals during the epoch, they SHOULD prefer them over the ReInit proposal, allowing the ReInit to be resent and applied in a subsequent epoch.
-
It contains an ExternalInit proposal.
-
It contains a Proposal with a non-default proposal type that is not supported by some members of the group that will process the Commit (i.e., members being added or removed by the Commit do not need to support the proposal type).
-
After processing the Commit the ratchet tree is invalid, in particular, if it contains any leaf node that is invalid according to Section 7.3.
An application may extend the above procedure by additional rules, for example, requiring application-level permissions to add members, or rules concerning non-default proposal types.
For an external Commit, the list is valid if it contains only the following proposals (not necessarily in this order):
-
Exactly one ExternalInit
-
At most one Remove proposal, with which the joiner removes an old version of themselves. If a Remove proposal is present, then the LeafNode in the path field of the external Commit MUST meet the same criteria as would the LeafNode in an Update for the removed leaf (see Section 12.1.2). In particular, the credential in the LeafNode MUST present a set of identifiers that is acceptable to the application for the removed participant.
-
Zero or more PreSharedKey proposals
-
No other proposals
Proposal types defined in the future may make updates to the above validation logic to incorporate considerations related to proposals of the new type.
The sections above defining each proposal type describe how each individual proposal is applied. When creating or processing a Commit, a client applies a list of proposals to the ratchet tree and GroupContext. The client
MUST apply the proposals in the list in the following order:
-
If there is a GroupContextExtensions proposal, replace the extensions field of the GroupContext for the group with the contents of the proposal. The new extensions MUST be used when evaluating other proposals in this list. For example, if a GroupContextExtensions proposal adds a required_capabilities extension, then any Add proposals need to indicate support for those capabilities.
-
Apply any Update proposals to the ratchet tree, in any order.
-
Apply any Remove proposals to the ratchet tree, in any order.
-
Apply any Add proposals to the ratchet tree, in the order they appear in the list.
-
Look up the PSK secrets for any PreSharedKey proposals, in the order they appear in the list. These secrets are then used to advance the key schedule later in Commit processing.
-
If there is an ExternalInit proposal, use it to derive the init_secret for use later in Commit processing.
-
If there is a ReInit proposal, note its parameters for application later in Commit processing.
Proposal types defined in the future
MUST specify how the above steps are to be adjusted to accommodate the application of proposals of the new type.
A Commit message initiates a new epoch for the group, based on a collection of Proposals. It instructs group members to update their representation of the state of the group by applying the proposals and advancing the key schedule.
Each proposal covered by the Commit is included by a ProposalOrRef value, which identifies the proposal to be applied by value or by reference. Commits that refer to new Proposals from the committer can be included by value. Commits for previously sent proposals from anyone (including the committer) can be sent by reference. Proposals sent by reference are specified by including the hash of the AuthenticatedContent object in which the proposal was sent (see
Section 5.2).
enum {
reserved(0),
proposal(1),
reference(2),
(255)
} ProposalOrRefType;
struct {
ProposalOrRefType type;
select (ProposalOrRef.type) {
case proposal: Proposal proposal;
case reference: ProposalRef reference;
};
} ProposalOrRef;
struct {
ProposalOrRef proposals<V>;
optional<UpdatePath> path;
} Commit;
A group member that has observed one or more valid proposals within an epoch
MUST send a Commit message before sending application data. This ensures, for example, that any members whose removal was proposed during the epoch are actually removed before any application data is transmitted.
A sender and a receiver of a Commit
MUST verify that the committed list of proposals is valid as specified in
Section 12.2. A list is invalid if, for example, it includes an Update and a Remove for the same member, or an Add when the sender does not have the application-level permission to add new users.
The sender of a Commit
SHOULD include all proposals that it has received during the current epoch that are valid according to the rules for their proposal types and according to application policy, as long as this results in a valid proposal list.
Due to the asynchronous nature of proposals, receivers of a Commit
SHOULD NOT enforce that all valid proposals sent within the current epoch are referenced by the next Commit. In the event that a valid proposal is omitted from the next Commit, and that proposal is still valid in the current epoch, the sender of the proposal
MAY resend it after updating it to reflect the current epoch.
A member of the group
MAY send a Commit that references no proposals at all, which would thus have an empty
proposals vector. Such a Commit resets the sender's leaf and the nodes along its direct path, and provides forward secrecy and post-compromise security with regard to the sender of the Commit. An Update proposal can be regarded as a "lazy" version of this operation, where only the leaf changes and intermediate nodes are blanked out.
By default, the
path field of a Commit
MUST be populated. The
path field
MAY be omitted if (a) it covers at least one proposal and (b) none of the proposals covered by the Commit are of "path required" types. A proposal type requires a path if it cannot change the group membership in a way that requires the forward secrecy and post-compromise security guarantees that an UpdatePath provides. The only proposal types defined in this document that do not require a path are:
New proposal types
MUST state whether they require a path. If any instance of a proposal type requires a path, then the proposal type requires a path. This attribute of a proposal type is reflected in the "Path Required" field of the "MLS Proposal Types" registry defined in
Section 17.4.
Update and Remove proposals are the clearest examples of proposals that require a path. An UpdatePath is required to evict the removed member or the old appearance of the updated member.
In pseudocode, the logic for validating the
path field of a Commit is as follows:
pathRequiredTypes = [
update,
remove,
external_init,
group_context_extensions
]
pathRequired = false
for proposal in commit.proposals:
pathRequired = pathRequired ||
(proposal.msg_type in pathRequiredTypes)
if len(commit.proposals) == 0 || pathRequired:
assert(commit.path != null)
To summarize, a Commit can have three different configurations, with different uses:
-
An "empty" Commit that references no proposals, which updates the committer's contribution to the group and provides PCS with regard to the committer.
-
A "partial" Commit that references proposals that do not require a path, and where the path is empty. Such a Commit doesn't provide PCS with regard to the committer.
-
A "full" Commit that references proposals of any type, which provides FS with regard to any removed members and PCS for the committer and any updated members.
When creating or processing a Commit, a client updates the ratchet tree and GroupContext for the group. These values advance from an "old" state reflecting the current epoch to a "new" state reflecting the new epoch initiated by the Commit. When the Commit includes an UpdatePath, a "provisional" group context is constructed that reflects changes due to the proposals and UpdatePath, but with the old confirmed transcript hash.
A member of the group creates a Commit message and the corresponding Welcome message at the same time, by taking the following steps:
-
Verify that the list of proposals to be committed is valid as specified in Section 12.2.
-
Construct an initial Commit object with the proposals field populated from Proposals received during the current epoch, and with the path field empty.
-
Create the new ratchet tree and GroupContext by applying the list of proposals to the old ratchet tree and GroupContext, as defined in Section 12.3.
-
Decide whether to populate the path field: If the path field is required based on the proposals that are in the Commit (see above), then it MUST be populated. Otherwise, the sender MAY omit the path field at its discretion.
-
If populating the path field:
-
If this is an external Commit, assign the sender the leftmost blank leaf node in the new ratchet tree. If there are no blank leaf nodes in the new ratchet tree, expand the tree to the right as defined in Section 7.7 and assign the leftmost new blank leaf to the sender.
-
Update the sender's direct path in the ratchet tree as described in Section 7.5. Define commit_secret as the value path_secret[n+1] derived from the last path secret value (path_secret[n]) derived for the UpdatePath.
-
Construct a provisional GroupContext object containing the following values:
-
group_id: Same as the old GroupContext
-
epoch: The epoch number for the new epoch
-
tree_hash: The tree hash of the new ratchet tree
-
confirmed_transcript_hash: Same as the old GroupContext
-
extensions: The new GroupContext extensions (possibly updated by aGroupContextExtensions proposal)
-
Encrypt the path secrets resulting from the tree update to the group as described in Section 7.5, using the provisional group context as the context for HPKE encryption.
-
Create an UpdatePath containing the sender's new leaf node and the new public keys and encrypted path secrets along the sender's filtered direct path. Assign this UpdatePath to the path field in the Commit.
-
If not populating the path field: Set the path field in the Commit to the null optional. Define commit_secret as the all-zero vector of length KDF.Nh (the same length as a path_secret value would be).
-
Derive the psk_secret as specified in Section 8.4, where the order of PSKs in the derivation corresponds to the order of PreSharedKey proposals in the proposals vector.
-
Construct a FramedContent object containing the Commit object. Sign the FramedContent using the old GroupContext as context.
-
Use the FramedContent to update the confirmed transcript hash and update the new GroupContext.
-
Use the init_secret from the previous epoch, the commit_secret and psk_secret defined in the previous steps, and the new GroupContext to compute the new joiner_secret, welcome_secret, epoch_secret, and derived secrets for the new epoch.
-
Use the confirmation_key for the new epoch to compute the confirmation_tag value.
-
Calculate the interim transcript hash using the new confirmed transcript hash and the confirmation_tag from the FramedContentAuthData.
-
Protect the AuthenticatedContent object using keys from the old epoch:
-
If encoding as PublicMessage, compute the membership_tag value using the membership_key.
-
If encoding as a PrivateMessage, encrypt the message using the sender_data_secret and the next (key, nonce) pair from the sender's handshake ratchet.
-
Construct a GroupInfo reflecting the new state:
-
Set the group_id, epoch, tree, confirmed_transcript_hash, interim_transcript_hash, and group_context_extensions fields to reflect the new state.
-
Set the confirmation_tag field to the value of the corresponding field in the FramedContentAuthData object.
-
Add any other extensions as defined by the application.
-
Optionally derive an external key pair as described in Section 8. (required for external Commits, see Section 12.4.3.2).
-
Sign the GroupInfo using the member's private signing key.
-
Encrypt the GroupInfo using the key and nonce derived from the joiner_secret. for the new epoch (see Section 12.4.3.1).
-
For each new member in the group:
-
Identify the lowest common ancestor in the tree of the new member's leaf node and the member sending the Commit.
-
If the path field was populated above: Compute the path secret corresponding to the common ancestor node.
-
Compute an EncryptedGroupSecrets object that encapsulates the init_secret for the current epoch and the path secret (if present).
-
Construct one or more Welcome messages from the encrypted GroupInfo object, the encrypted key packages, and any PSKs for which a proposal was included in the Commit. The order of the psks MUST be the same as the order of PreSharedKey proposals in the proposals vector. As discussed in Section 12.4.3.1, the committer is free to choose how many Welcome messages to construct. However, the set of Welcome messages produced in this step MUST cover every new member added in the Commit.
-
If a ReInit proposal was part of the Commit, the committer MUST create a new group with the parameters specified in the ReInit proposal, and with the same members as the original group. The Welcome message MUST include a PreSharedKeyID with the following parameters:
-
psktype: resumption
-
usage: reinit
-
group_id: The group ID for the current group
-
epoch: The epoch that the group will be in after this Commit
A member of the group applies a Commit message by taking the following steps:
-
Verify that the epoch field of the enclosing FramedContent is equal to the epoch field of the current GroupContext object.
-
Unprotect the Commit using the keys from the current epoch:
-
If the message is encoded as PublicMessage, verify the membership MAC using the membership_key.
-
If the message is encoded as PrivateMessage, decrypt the message using the sender_data_secret and the (key, nonce) pair from the step on the sender's hash ratchet indicated by the generation field.
-
Verify the signature on the FramedContent message as described in Section 6.1.
-
Verify that the proposals vector is valid according to the rules in Section 12.2.
-
Verify that all PreSharedKey proposals in the proposals vector are available.
-
Create the new ratchet tree and GroupContext by applying the list of proposals to the old ratchet tree and GroupContext, as defined in Section 12.3.
-
Verify that the path value is populated if the proposals vector contains any Update or Remove proposals, or if it's empty. Otherwise, the path value MAY be omitted.
-
If the path value is populated, validate it and apply it to the tree:
-
If this is an external Commit, assign the sender the leftmost blank leaf node in the new ratchet tree. If there are no blank leaf nodes in the new ratchet tree, add a blank leaf to the right side of the new ratchet tree and assign it to the sender.
-
Validate the LeafNode as specified in Section 7.3. The leaf_node_source field MUST be set to commit.
-
Verify that the encryption_key value in the LeafNode is different from the committer's current leaf node.
-
Verify that none of the public keys in the UpdatePath appear in any node of the new ratchet tree.
-
Merge the UpdatePath into the new ratchet tree, as described in Section 7.5.
-
Construct a provisional GroupContext object containing the following values:
-
group_id: Same as the old GroupContext
-
epoch: The epoch number for the new epoch
-
tree_hash: The tree hash of the new ratchet tree
-
confirmed_transcript_hash: Same as the old GroupContext
-
extensions: The new GroupContext extensions (possibly updated by aGroupContextExtensions proposal)
-
Decrypt the path secrets for UpdatePath as described in Section 7.5, using the provisional GroupContext as the context for HPKE decryption.
-
Define commit_secret as the value path_secret[n+1] derived from the last path secret value (path_secret[n]) derived for the UpdatePath.
-
If the path value is not populated, define commit_secret as the all-zero vector of length KDF.Nh (the same length as a path_secret value would be).
-
Update the confirmed and interim transcript hashes using the new Commit, and generate the new GroupContext.
-
Derive the psk_secret as specified in Section 8.4, where the order of PSKs in the derivation corresponds to the order of PreSharedKey proposals in the proposals vector.
-
Use the init_secret from the previous epoch, the commit_secret and psk_secret defined in the previous steps, and the new GroupContext to compute the new joiner_secret, welcome_secret, epoch_secret, and derived secrets for the new epoch.
-
Use the confirmation_key for the new epoch to compute the confirmation tag for this message, as described below, and verify that it is the same as the confirmation_tag field in the FramedContentAuthData object.
-
If the above checks are successful, consider the new GroupContext object as the current state of the group.
-
If the Commit included a ReInit proposal, the client MUST NOT use the group to send messages anymore. Instead, it MUST wait for a Welcome message from the committer meeting the requirements of Section 11.2.
Note that clients need to be prepared to receive a valid Commit message that removes them from the group. In this case, the client cannot send any more messages in the group and
SHOULD promptly delete its group state and secret tree. (A client might keep the secret tree for a short time to decrypt late messages in the previous epoch.)
New members can join the group in two ways: by being added by a group member or by adding themselves through an external Commit. In both cases, the new members need information to bootstrap their local group state.
struct {
GroupContext group_context;
Extension extensions<V>;
MAC confirmation_tag;
uint32 signer;
/* SignWithLabel(., "GroupInfoTBS", GroupInfoTBS) */
opaque signature<V>;
} GroupInfo;
The
group_context field represents the current state of the group. The
extensions field allows the sender to provide additional data that might be useful to new joiners. The
confirmation_tag represents the confirmation tag from the Commit that initiated the current epoch, or for epoch 0, the confirmation tag computed in the creation of the group (see
Section 11). (In either case, the creator of a GroupInfo may recompute the confirmation tag as
MAC(confirmation_key, confirmed_transcript_hash).)
As discussed in
Section 13, unknown extensions in
GroupInfo.extensions MUST be ignored, and the creator of a GroupInfo object
SHOULD include some random GREASE extensions to help ensure that other clients correctly ignore unknown extensions. Extensions in
GroupInfo.group_context.extensions, however,
MUST be supported by the new joiner.
New members
MUST verify that
group_id is unique among the groups they are currently participating in.
New members also
MUST verify the
signature using the public key taken from the leaf node of the ratchet tree with leaf index
signer. The signature covers the following structure, comprising all the fields in the GroupInfo above
signature:
struct {
GroupContext group_context;
Extension extensions<V>;
MAC confirmation_tag;
uint32 signer;
} GroupInfoTBS;
The sender of a Commit message is responsible for sending a Welcome message to each new member added via Add proposals. The format of the Welcome message allows a single Welcome message to be encrypted for multiple new members. It is up to the committer to decide how many Welcome messages to create for a given Commit. The committer could create one Welcome that is encrypted for all new members, a different Welcome for each new member, or Welcome messages for batches of new members (according to some batching scheme that works well for the application). The processes for creating and processing the Welcome are the same in all cases, aside from the set of new members for whom a given Welcome is encrypted.
The Welcome message provides the new members with the current state of the group after the application of the Commit message. The new members will not be able to decrypt or verify the Commit message, but they will have the secrets they need to participate in the epoch initiated by the Commit message.
In order to allow the same Welcome message to be sent to multiple new members, information describing the group is encrypted with a symmetric key and nonce derived from the
joiner_secret for the new epoch. The
joiner_secret is then encrypted to each new member using HPKE. In the same encrypted package, the committer transmits the path secret for the lowest (closest to the leaf) node that is contained in the direct paths of both the committer and the new member. This allows the new member to compute private keys for nodes in its direct path that are being reset by the corresponding Commit.
If the sender of the Welcome message wants the receiving member to include a PSK in the derivation of the
epoch_secret, they can populate the
psks field indicating which PSK to use.
struct {
opaque path_secret<V>;
} PathSecret;
struct {
opaque joiner_secret<V>;
optional<PathSecret> path_secret;
PreSharedKeyID psks<V>;
} GroupSecrets;
struct {
KeyPackageRef new_member;
HPKECiphertext encrypted_group_secrets;
} EncryptedGroupSecrets;
struct {
CipherSuite cipher_suite;
EncryptedGroupSecrets secrets<V>;
opaque encrypted_group_info<V>;
} Welcome;
The client processing a Welcome message will need to have a copy of the group's ratchet tree. The tree can be provided in the Welcome message, in an extension of type
ratchet_tree. If it is sent otherwise (e.g., provided by a caching service on the Delivery Service), then the client
MUST download the tree before processing the Welcome.
On receiving a Welcome message, a client processes it using the following steps:
-
Identify an entry in the secrets array where the new_member value corresponds to one of this client's KeyPackages, using the hash indicated by the cipher_suite field. If no such field exists, or if the cipher suite indicated in the KeyPackage does not match the one in the Welcome message, return an error.
-
Decrypt the encrypted_group_secrets value with the algorithms indicated by the cipher suite and the private key init_key_priv corresponding to init_key in the referenced KeyPackage.
encrypted_group_secrets =
EncryptWithLabel(init_key, "Welcome",
encrypted_group_info, group_secrets)
group_secrets =
DecryptWithLabel(init_key_priv, "Welcome",
encrypted_group_info, kem_output, ciphertext)
-
If a PreSharedKeyID is part of the GroupSecrets and the client is not in possession of the corresponding PSK, return an error. Additionally, if a PreSharedKeyID has type resumption with usage reinit or branch, verify that it is the only such PSK.
-
From the joiner_secret in the decrypted GroupSecrets object and the PSKs specified in the GroupSecrets, derive the welcome_secret and then the welcome_key and welcome_nonce. Use the key and nonce to decrypt the encrypted_group_info field.
welcome_nonce = ExpandWithLabel(welcome_secret, "nonce", "", AEAD.Nn)
welcome_key = ExpandWithLabel(welcome_secret, "key", "", AEAD.Nk)
-
Verify the signature on the GroupInfo object. The signature input comprises all of the fields in the GroupInfo object except the signature field. The public key is taken from the LeafNode of the ratchet tree with leaf index signer. If the node is blank or if signature verification fails, return an error.
-
Verify that the group_id is unique among the groups that the client is currently participating in.
-
Verify that the cipher_suite in the GroupInfo matches the cipher_suite in the KeyPackage.
-
Verify the integrity of the ratchet tree.
-
Verify that the tree hash of the ratchet tree matches the tree_hash field in GroupInfo.
-
For each non-empty parent node, verify that it is "parent-hash valid", as described in Section 7.9.2.
-
For each non-empty leaf node, validate the LeafNode as described in Section 7.3.
-
For each non-empty parent node and each entry in the node's unmerged_leaves field:
-
Verify that the entry represents a non-blank leaf node that is a descendant of the parent node.
-
Verify that every non-blank intermediate node between the leaf node and the parent node also has an entry for the leaf node in its unmerged_leaves.
-
Verify that the encryption key in the parent node does not appear in any other node of the tree.
-
Identify a leaf whose LeafNode is identical to the one in the KeyPackage. If no such field exists, return an error. Let my_leaf represent this leaf in the tree.
-
Construct a new group state using the information in the GroupInfo object.
-
Initialize the GroupContext for the group from the group_context field from the GroupInfo object.
-
Update the leaf my_leaf with the private key corresponding to the public key in the node, where my_leaf is the new member's leaf node in the ratchet tree, as defined above.
-
If the path_secret value is set in the GroupSecrets object: Identify the lowest common ancestor of the leaf node my_leaf and of the node of the member with leaf index GroupInfo.signer. Set the private key for this node to the private key derived from the path_secret.
-
For each parent of the common ancestor, up to the root of the tree, derive a new path secret, and set the private key for the node to the private key derived from the path secret. The private key MUST be the private key that corresponds to the public key in the node.
-
Use the joiner_secret from the GroupSecrets object to generate the epoch secret and other derived secrets for the current epoch.
-
Set the confirmed transcript hash in the new state to the value of the confirmed_transcript_hash in the GroupInfo.
-
Verify the confirmation tag in the GroupInfo using the derived confirmation key and the confirmed_transcript_hash from the GroupInfo.
-
Use the confirmed transcript hash and confirmation tag to compute the interim transcript hash in the new state.
-
If a PreSharedKeyID was used that has type resumption with usage reinit or branch, verify that the epoch field in the GroupInfo is equal to 1.
-
For usage reinit, verify that the last Commit to the referenced group contains a ReInit proposal and that the group_id, version, cipher_suite, and group_context.extensions fields of the GroupInfo match the ReInit proposal. Additionally, verify that all the members of the old group are also members of the new group, according to the application.
-
For usage branch, verify that the version and cipher_suite of the new group match those of the old group, and that the members of the new group compose a subset of the members of the old group, according to the application.
External Commits are a mechanism for new members (external parties that want to become members of the group) to add themselves to a group, without requiring that an existing member has to come online to issue a Commit that references an Add proposal.
Whether existing members of the group will accept or reject an external Commit follows the same rules that are applied to other handshake messages.
New members can create and issue an external Commit if they have access to the following information for the group's current epoch:
-
group ID
-
epoch ID
-
cipher suite
-
public tree hash
-
confirmed transcript hash
-
confirmation tag of the most recent Commit
-
group extensions
-
external public key
In other words, to join a group via an external Commit, a new member needs a GroupInfo with an
external_pub extension present in its
extensions field.
struct {
HPKEPublicKey external_pub;
} ExternalPub;
Thus, a member of the group can enable new clients to join by making a GroupInfo object available to them. Note that because a GroupInfo object is specific to an epoch, it will need to be updated as the group advances. In particular, each GroupInfo object can be used for one external join, since that external join will cause the epoch to change.
Note that the
tree_hash field is used the same way as in the Welcome message. The full tree can be included via the
ratchet_tree extension (see
Section 12.4.3.3).
The information in a GroupInfo is not generally public information, but applications can choose to make it available to new members in order to allow External Commits.
In principle, external Commits work like regular Commits. However, their content has to meet a specific set of requirements:
-
External Commits MUST contain a path field (and is therefore a "full" Commit). The joiner is added at the leftmost free leaf node (just as if they were added with an Add proposal), and the path is calculated relative to that leaf node.
-
The Commit MUST NOT include any proposals by reference, since an external joiner cannot determine the validity of proposals sent within the group.
-
External Commits MUST be signed by the new member. In particular, the signature on the enclosing AuthenticatedContent MUST verify using the public key for the credential in the leaf_node of the path field.
-
When processing a Commit, both existing and new members MUST use the external init secret as described in Section 8.3.
-
The sender type for the AuthenticatedContent encapsulating the external Commit MUST be new_member_commit.
External Commits come in two "flavors" -- a "join" Commit that adds the sender to the group or a "resync" Commit that replaces a member's prior appearance with a new one.
Note that the "resync" operation allows an attacker that has compromised a member's signature private key to introduce themselves into the group and remove the prior, legitimate member in a single Commit. Without resync, this can still be done, but it requires two operations: the external Commit to join and a second Commit to remove the old appearance. Applications for whom this distinction is salient can choose to disallow external commits that contain a Remove, or to allow such resync commits only if they contain a "reinit" PSK proposal that demonstrates the joining member's presence in a prior epoch of the group. With the latter approach, the attacker would need to compromise the PSK as well as the signing key, but the application will need to ensure that continuing, non-resynchronizing members have the required PSK.
By default, a GroupInfo message only provides the joiner with a hash of the group's ratchet tree. In order to process or generate handshake messages, the joiner will need to get a copy of the ratchet tree from some other source. (For example, the DS might provide a cached copy.) The inclusion of the tree hash in the GroupInfo message means that the source of the ratchet tree need not be trusted to maintain the integrity of the tree.
In cases where the application does not wish to provide such an external source, the whole public state of the ratchet tree can be provided in an extension of type
ratchet_tree, containing a
ratchet_tree object of the following form:
struct {
NodeType node_type;
select (Node.node_type) {
case leaf: LeafNode leaf_node;
case parent: ParentNode parent_node;
};
} Node;
optional<Node> ratchet_tree<V>;
Each entry in the
ratchet_tree vector provides the value for a node in the tree, or the null optional for a blank node.
The nodes are listed in the order specified by a left-to-right in-order traversal of the ratchet tree. Each node is listed between its left subtree and its right subtree. (This is the same ordering as specified for the array-based trees outlined in
Appendix C.)
If the tree has 2
d leaves, then it has 2
d+1 - 1 nodes. The
ratchet_tree vector logically has this number of entries, but the sender
MUST NOT include blank nodes after the last non-blank node. The receiver
MUST check that the last node in
ratchet_tree is non-blank, and then extend the tree to the right until it has a length of the form 2
d+1 - 1, adding the minimum number of blank values possible. (Obviously, this may be done "virtually", by synthesizing blank nodes when required, as opposed to actually changing the structure in memory.)
The leaves of the tree are stored in even-numbered entries in the array (the leaf with index
L in array position
2*L). The root node of the tree is at position 2
d - 1 of the array. Intermediate parent nodes can be identified by performing the same calculation to the subarrays to the left and right of the root, following something like the following algorithm:
# Assuming a class Node that has left and right members
def subtree_root(nodes):
# If there is only one node in the array, return it
if len(nodes) == 1:
return Node(nodes[0])
# Otherwise, the length of the array MUST be odd
if len(nodes) % 2 == 0:
raise Exception("Malformed node array {}", len(nodes))
# Identify the root of the subtree
d = 0
while (2**(d+1)) < len(nodes):
d += 1
R = 2**d - 1
root = Node(nodes[R])
root.left = subtree_root(nodes[:R])
root.right = subtree_root(nodes[(R+1):])
return root
(Note that this is the same ordering of nodes as in the array-based tree representation described in
Appendix C. The algorithms in that section may be used to simplify decoding this extension into other representations.)
For example, the following tree with six non-blank leaves would be represented as an array of eleven elements,
[A, W, B, X, C, _, D, Y, E, Z, F]. The above decoding procedure would identify the subtree roots as follows (using R to represent a subtree root):
Y
|
.-----+-----.
/ \
X _
| |
.-+-. .-+-.
/ \ / \
W _ Z _
/ \ / \ / \ / \
A B C D E F _ _
1
0 1 2 3 4 5 6 7 8 9 0
<-----------> R <----------->
<---> R <---> <---> R <--->
- R - - R - - R - - R -
The presence of a
ratchet_tree extension in a GroupInfo message does not result in any changes to the GroupContext extensions for the group. The ratchet tree provided is simply stored by the client and used for MLS operations.
If this extension is not provided in a Welcome message, then the client will need to fetch the ratchet tree over some other channel before it can generate or process Commit messages. Applications should ensure that this out-of-band channel is provided with security protections equivalent to the protections that are afforded to Proposal and Commit messages. For example, an application that encrypts Proposal and Commit messages might distribute ratchet trees encrypted using a key exchanged over the MLS channel.
Regardless of how the client obtains the tree, the client
MUST verify that the root hash of the ratchet tree matches the
tree_hash of the GroupContext before using the tree for MLS operations.