From a853ac66b02fb1d53b87f7acc636239f4ab9484b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 02:41:09 +0000 Subject: [PATCH] fix(marmot): exclude newly-added leaves from UpdatePath resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../quartz/marmot/mls/group/MlsGroup.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index eb05a4671..e0268d55a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -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