fix(marmot): exclude newly-added leaves from UpdatePath resolution

RFC 9420 §12.4.1: when a Commit contains Add proposals, the committer
MUST exclude the new leaves from the copath-resolution used for path-
secret encryption. Joiners get the group state via the Welcome at epoch
N+1; they neither need nor expect a path secret for epoch N.

Quartz was including them. openmls (via mdk-core) then runs its own
resolution-minus-exclusion-list when picking which ciphertext to
decrypt, so its `resolution_position` landed one slot off from where
quartz had put the ciphertext for the existing member. The AEAD tag
naturally mismatched and the commit died with
`InvalidCommit(UpdatePathError(UnableToDecrypt))` — the blocker behind
every test that needs an existing openmls member to process a commit
produced by quartz (add, rename, remove, leave, promote, catchup).

Fix: in `MlsGroup.commit()`, capture `newLeafIndices` from the Add
proposals applied in this epoch and `filterNot` them out of every
copath node's resolution before HPKE-encrypting the path secret.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
This commit is contained in:
Claude
2026-04-22 02:41:09 +00:00
parent a07e22d7ab
commit a853ac66b0
@@ -445,13 +445,24 @@ class MlsGroup private constructor(
val leafSecret = MlsCryptoProvider.randomBytes(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
val pathSecrets = tree.derivePathSecrets(myLeafIndex, leafSecret)
// Build UpdatePath with HPKE-encrypted path secrets for each copath node
// Build UpdatePath with HPKE-encrypted path secrets for each copath node.
// RFC 9420 §12.4.1: newly-added leaves (from Add proposals in THIS commit)
// MUST be excluded from the copath resolution — they join via the Welcome
// at epoch N+1 and don't need the path secret. Keeping them in the list
// shifts every other resolution index by one, so strict receivers
// (openmls/mdk) pick the wrong ciphertext and fail with
// UpdatePathError(UnableToDecrypt).
val newLeafIndices = addedMembers.map { it.first }.toSet()
val updatePath =
if (needsPath && pathSecrets.isNotEmpty()) {
val copath = BinaryTree.copath(myLeafIndex, tree.leafCount)
val pathNodes =
pathSecrets.zip(copath).map { (pathKey, copathNode) ->
val resolution = tree.resolution(copathNode)
val resolution =
tree.resolution(copathNode).filterNot { resNode ->
BinaryTree.isLeaf(resNode) &&
BinaryTree.nodeToLeaf(resNode) in newLeafIndices
}
val encryptedSecrets =
resolution.mapNotNull { resNode ->
val node = tree.getNode(resNode) ?: return@mapNotNull null