Change:
via option (3)
Bringing up the issue mentioned in the comments to : this can be inconvenient for implementations because this way the update path can't be generated and encrypted at the same time. From security perspective, I think it can't break anything, since we include strictly more in the context. On the other hand, it's not clear to me what it buys.
I sympathize with the idea that this constraints applications. MLSpp does exactly what you're talking about, updating and encrypting at the same time. But I'm not really seeing why that refactoring matters all that much. It's not any more net work, aside from maybe computing a filtered direct path twice.
In , we removed the requirement for an external Commit to have an Add proposal, under the theory that the required already has a leaf node for the new joiner. When I went to implement this, however, I noticed some ambiguities. First, the Commit processing section doesn't mention when the new joiner is added. The obvious approach is to do the add as part of the processing, but that processing presumes that the sender is already in the tree (for example, there is no provision for extending the tree). Second, the UpdatePath for an external commit needs to be done in a specific order in order to avoid a circular dependency. The TreeKEM encapsulation done by the joiner uses the GroupContext a context parameter, including the hash of the tree being used. But the LeafNode in the has to have a parent hash in it, which can only be computed after the encapsulation operation. This circularity can be resolved, but only by preparing the UpdatePath with the following specific steps: Compute the path secrets Compute the parent hash and the new leaf node Compute the new tree hash Encrypt the path secrets to the rest of the group That rules out some implementations that compute path secrets and encrypt them in one traversal of the direct path (this is what MLSpp does now). The options here are basically: Re-add the Add proposal, thus having a throw-away temporary LeafNode for the joiner Specify that the UpdatePath must be computed in the specific order above To me, (2) seems like a pretty clear winner.
Actually, it seems like there's a simpler option here: Extend the definition of an ExternalInit proposal to say that part of how it is applied is to ensure there is a blank leaf in the tree, extending the tree if necessary. (This implies that ExternalInit must be done after any Remove.) The leftmost blank leaf is the sender's leaf for purposes of UpdatePath processing. The provisional tree used in encrypting path secrets is still the provisional tree, in the sense that all proposals have been applied. This tree has a blank leaf for the UpdatePath sender. If we clarify these points, I think everything else works unmodified.
I agree with the first point. Even if the Add is not explicitly present, the external commit effectively emulates an Add anyway. To the second point: The solution is only simpler if that's how you want to implement it . I haven't tried it, but if you have chosen a different order of implementation, i.e. perform all changes to the provisional tree (including the update path and parent hash), then compute the tree hash and then encrypt the path, you now have to go and compute an additional tree hash before applying the update path, then apply the update path and keep the partial tree hash around for later encryption/decryption. This was not a part of MLS that I have looked at from a provable security perspective, but I'm wondering what the consequences are from a security perspective if we're including a parent hash for an incomplete provisional tree.
It seems like the main question here is what tree is provided to TreeKEM encryptions of path secrets. I'll call this the "context tree" below. It sounds like you have in mind that the tree provided to TreeKEM has the UpdatePath applied as well as the proposals, but that's not what the spec says right now: The starting point for this issue is that the "provisional" tree right now doesn't really faithfully capture the tree you're encrypting to, at least in the sense that it has a different number of leaves. That seemed wrong to me. It seems like we have the following options here: Do nothing. The context tree doesn't necessarily have a blank for the joiner. Maximal divergence between the new tree and the context tree (among these options) TreeKEM encryption operates on a different tree than the context tree Ensure the provisional tree has a blank leaf for the joiner (as up-thread). Context tree has a blank leaf for the joiner, but direct path doesn't match new tree TreeKEM encryption can operate on the context tree, because the structure is correct Use the "new" ratchet tree as the TreeKEM context. Context tree is exactly the new tree -- could probably eliminate provisional Requires separate steps for (1) updating tree nodes and (2) encrypting path secrets ... when these are currently described together as effectively one operation Personally, it seems like the answer is (2) or (3), and I probably lean toward (3). I don't think (1) is insecure, because the context tree does capture all of the nodes to which path secrets are encrypted. But the divergence is worrisome and it's cheap to do better. Between (2) and (3), there's a trade-off between fidelity and spec change size. The impact of (2) is more localized (just the ExternalInit section). Updating the spec to do (3) will require amending the description of update paths as well as the Commit creation / processing rules (to separate update/encrypt and remove provisional, respectively). But honestly, removing the provisional tree seems like a worthwhile simplification, so between that and the greater fidelity of the context tree, (3) seems worth the cost.
Actually, as I look into implementing this: We still need a provisional GroupContext, because the will differ. But we can at least have two trees instead of three.

Discussion:
The HPKECiphertext values are computed as where "node_public_key" is the public key of the node that the path <del> secret is being encrypted for, group_context is the current </del> <ins> secret is being encrypted for, group_context is the provisional </ins> GroupContext object for the group, and the functions "SetupBaseS" and "Seal" are defined according to RFC9180.

13.2.1. <del> When creating or processing a Commit, three different ratchet trees and their associated GroupContexts are used: "Old" refers to the ratchet tree and GroupContext for the epoch before the commit.  The old GroupContext is used when signing the MLSPlainText so that existing group members can verify the signature before processing the commit. "Provisional" refers to the ratchet tree and GroupContext constructed after applying the proposals that are referenced by the Commit.  The provisional GroupContext uses the epoch number for the new epoch, and the old confirmed transcript hash.  This is used when creating the UpdatePath, if the UpdatePath is needed. "New" refers to the ratchet tree and GroupContext constructed after applying the proposals and the UpdatePath (if any).  The new GroupContext uses the epoch number for the new epoch, and the new confirmed transcript hash.  This is used when deriving the new epoch secrets, and is the only GroupContext that newly-added members will have. </del> <ins> 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. </ins> A member of the group creates a Commit message and the corresponding Welcome message at the same time, by taking the following steps:

populated from Proposals received during the current epoch, and an empty "path" field. <del> Generate the provisional ratchet tree and GroupContext by applying the proposals referenced in the initial Commit object, as described in proposals.  Update proposals are applied first, followed by Remove proposals, and then finally Add proposals.  Add proposals are applied in the order listed in the "proposals" vector, and always to the leftmost unoccupied leaf in the tree, or the right edge of the tree if all leaves are occupied. </del> <ins> Initialize the new ratchet tree and GroupContext by applying the proposals referenced in the initial Commit object, as described in proposals.  Update proposals are applied first, followed by Remove proposals, and then finally Add proposals.  Add proposals are applied in the order listed in the "proposals" vector, and always to the leftmost unoccupied leaf in the tree, or the right edge of the tree if all leaves are occupied. </ins> Note that the order in which different types of proposals are applied should be updated by the implementation to include any

above), then it MUST be populated.  Otherwise, the sender MAY omit the "path" field at its discretion. <del> If populating the "path" field: Create an UpdatePath using the provisional ratchet tree and GroupContext.  Any new member (from an Add proposal) MUST be excluded from the resolution during the computation of the UpdatePath.  The "leaf_node" for this UpdatePath MUST have "leaf_node_source" set to "commit".  Note that the LeafNode in the "UpdatePath" effectively updates an existing LeafNode in the group and thus MUST adhere to the same restrictions as LeafNodes used in "Update" proposals (aside from "leaf_node_source"). </del> <ins> If populating the "path" field: </ins> <del> Assign this UpdatePath to the "path" field in the Commit. </del> <ins> 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. </ins> <del> Apply the UpdatePath to the tree, as described in synchronizing-views-of-the-tree, creating the new ratchet tree. Define "commit_secret" as the value "path_secret[n+1]" derived from the "path_secret[n]" value assigned to the root node. </del> <ins> Generate path secrets for the parent nodes along the sender's filtered direct path, as described in synchronizing-views-of- the-tree.  Define "commit_secret" as the value "path_secret[n+1]" derived from the "path_secret[n]" value assigned to the root node. Update the new ratchet tree by setting the parent nodes on the sender's filtered direct path based on the path secrets. Compute parent hashes for these nodes.  Set the sender's leaf node in the new tree to a new leaf node including the resulting parent hash for its nearest ancestor. The new leaf node MUST have "leaf_node_source" set to "commit". Since the new leaf node effectively updates an existing leaf node in the group, it MUST adhere to the same restrictions as LeafNodes used in "Update" proposals (aside from "leaf_node_source"). The application MAY specify other changes to the leaf node, e.g., providing a new signature key, updated capabilities, or different extensions. 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 a GroupContextExtensions proposal) Create an UpdatePath that encrypts the path secrets for the nodes along the sender's filtered direct path to the new ratchet tree, using the provisional GroupContext as context. Any new member (from an Add proposal) MUST be excluded from the resolution during the computation of the UpdatePath. Assign this UpdatePath to the "path" field in the Commit. </ins> 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" <del> value would be).  In this case, the new ratchet tree is the same as the provisional ratchet tree. </del> <ins> value would be). </ins> Derive the "psk_secret" as specified in pre-shared-keys, where the order of PSKs in the derivation corresponds to the order of

context. Use the MLSMessageContent to update the confirmed transcript <del> hash and generate the new GroupContext. </del> <ins> hash and update the new GroupContext. </ins> Use the "init_secret" from the previous epoch, the "commit_secret" and the "psk_secret" as defined in the previous

Verify that all PreSharedKey proposals in the "proposals" vector are available. <del> Generate the provisional ratchet tree and GroupContext by applying the proposals referenced in the initial Commit object, as described in proposals.  Update proposals are applied first, followed by Remove proposals, and then finally Add proposals.  Add proposals are applied in the order listed in the "proposals" vector, and always to the leftmost unoccupied leaf in the tree, or the right edge of the tree if all leaves are occupied. </del> <ins> Initialize the new ratchet tree and GroupContext by applying the proposals referenced in the initial Commit object, as described in proposals.  Update proposals are applied first, followed by Remove proposals, and then finally Add proposals.  Add proposals are applied in the order listed in the "proposals" vector, and always to the leftmost unoccupied leaf in the tree, or the right edge of the tree if all leaves are occupied. </ins> Note that the order in which different types of proposals are applied should be updated by the implementation to include any

vector contains any Update or Remove proposals, or if it's empty. Otherwise, the "path" value MAY be omitted. <del> If the "path" value is populated: Process the "path" value using the provisional ratchet tree and GroupContext, to generate the new ratchet tree and the "commit_secret": </del> <ins> 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. </ins> Validate the LeafNode as specified in leaf-node-validation. 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. <del> Apply the UpdatePath to the tree, as described in synchronizing-views-of-the-tree, and store "leaf_node" at the committer's leaf. </del> <ins> Compute parent hashes for the parent nodes in the UpdatePath (relative to the new ratchet tree) and verify that the "parent_hash" field of the leaf node matches the parent hash for the first node its filtered direct path. </ins> <del> Verify that the LeafNode has a "parent_hash" field and that its value matches the new parent of the sender's leaf node. </del> <ins> 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 a GroupContextExtensions proposal) Apply the UpdatePath to the tree, as described in synchronizing-views-of-the-tree, using the provisional GroupContext when decrypting the path secret and storing "leaf_node" at the committer's leaf. </ins> Define "commit_secret" as the value "path_secret[n+1]" derived from the "path_secret[n]" value assigned to the root node.