From 9ae07894c795a38691285e5bc2e1d0ec950c19f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 03:35:09 +0000 Subject: [PATCH] fix(marmot): sign commits under pre-proposal extensions (fix InvalidMembershipTag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a GroupContextExtensions commit, openmls signs the `FramedContentTBS` over `group.public_group.group_context()` โ€” the UNMUTATED context, before the `diff` applies the GCE proposal. The post-proposal extensions only make it into the HPKE path-encryption context and the subsequent key schedule, not into the TBS / signature / membership_tag. Quartz's `applyProposal` handler for `GroupContextExtensions` mutates `groupContext.extensions` inline โ€” by the time `commit()` captured `preCommitContextBytes`, the extensions were already updated. The signature and membership_tag were then minted over the post-GCE context, and openmls's `verify_membership` recomputed the MAC over the pre-GCE context and reported `ValidationError(InvalidMembershipTag)` on every cross-impl rename / promote / demote / leave. Fix: snapshot `groupContext.extensions` before any proposal is applied, and rebuild `preCommitContextBytes` with those original extensions. The path-encryption context and post-commit key schedule still use the updated extensions, matching openmls. 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 3e61fc91f9..dd8da88a5d 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 @@ -411,6 +411,15 @@ class MlsGroup private constructor( val preCommitExporterSecret = exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + // Snapshot the pre-proposal extensions. GroupContextExtensions proposals + // mutate `groupContext.extensions` the moment they're applied, but + // openmls signs the commit's FramedContentTBS over the UNMUTATED + // context (the one in `public_group` before `diff` applies proposals). + // Without this snapshot, a GCE commit on the quartz side uses the new + // extensions for TBS + membership_tag, and openmls rejects it with + // `ValidationError(InvalidMembershipTag)`. + val preCommitExtensions = groupContext.extensions + val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) } // Check if we need an UpdatePath. RFC 9420 ยง12.4.1: the path value @@ -601,8 +610,10 @@ class MlsGroup private constructor( // is sent โ€” the one we're about to leave. Receivers (openmls/mdk) // strict-verify both, so we must use the leaf signing key that's // still in the pre-commit tree and the membership_key derived from - // the pre-commit epoch secrets. - val preCommitContextBytes = groupContext.toTlsBytes() + // the pre-commit epoch secrets. Extensions need explicit rewind to + // pre-proposal state for GroupContextExtensions commits. + val preCommitContextBytes = + groupContext.copy(extensions = preCommitExtensions).toTlsBytes() val preCommitMembershipKey = epochSecrets.membershipKey val preCommitSigningKey = signingPrivateKey