fix(marmot): sign + membership-tag PublicMessage commits

openmls (via mdk-core) strict-verifies both the FramedContentAuthData
signature and the membership_tag on every inbound PublicMessage from a
Member sender (RFC 9420 §6.1, §6.2). Quartz was framing commits with
empty placeholders for both fields, so any existing member processing a
quartz-originated commit tripped `openmls::ciphersuite: Incompatible
values` (constant-time compare of a zero-length tag against the 32-byte
expected HMAC). That's the reason every interop test that requires B to
process a commit from amy (add-member, rename, remove, leave, promote,
catchup) was failing at step one.

Fix computes both fields correctly:
  * capture pre-commit groupContext bytes, membership_key, and
    signingPrivateKey before the key schedule / epoch advance
  * build FramedContentTBS = version || wire_format || FramedContent ||
    serialized_context and SignWithLabel("FramedContentTBS", ..) with
    the committer's pre-commit leaf signing key
  * build AuthenticatedContentTBM = TBS || signature<V> ||
    confirmation_tag<V> and HMAC-SHA256 it with the pre-commit
    membership_key

No change to quartz's own processCommit path (it doesn't re-verify these
fields on inbound), so this is a one-way fix for outbound to openmls
peers.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
This commit is contained in:
Claude
2026-04-22 02:22:09 +00:00
parent 0b7b1353e8
commit a07e22d7ab
@@ -560,6 +560,17 @@ class MlsGroup private constructor(
val committerLeafIndex = myLeafIndex
val newEpoch = oldEpoch + 1
// Capture pre-commit values needed to sign and membership-MAC the
// outbound PublicMessage (RFC 9420 §6.1 / §6.2). The signature and
// membership_tag are computed under the epoch in which the commit
// 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()
val preCommitMembershipKey = epochSecrets.membershipKey
val preCommitSigningKey = signingPrivateKey
groupContext =
groupContext.copy(
epoch = newEpoch,
@@ -607,6 +618,9 @@ class MlsGroup private constructor(
senderLeafIndex = committerLeafIndex,
commitBytes = commitBytes,
confirmationTag = confirmationTag,
signingPrivateKey = preCommitSigningKey,
membershipKey = preCommitMembershipKey,
groupContextBytes = preCommitContextBytes,
)
return CommitResult(
commitBytes = commitBytes,
@@ -1755,7 +1769,44 @@ class MlsGroup private constructor(
senderLeafIndex: Int,
commitBytes: ByteArray,
confirmationTag: ByteArray,
signingPrivateKey: ByteArray,
membershipKey: ByteArray,
groupContextBytes: ByteArray,
): ByteArray {
// RFC 9420 §6.1 FramedContentTBS for a member-sender commit over
// PublicMessage wire format. Receivers recompute this exact byte
// string to verify the signature and membership_tag — the layout
// must match openmls / mdk-core byte-for-byte.
val tbsWriter = TlsWriter()
tbsWriter.putUint16(MlsMessage.MLS_VERSION_10)
tbsWriter.putUint16(WireFormat.PUBLIC_MESSAGE.value)
tbsWriter.putOpaqueVarInt(groupId)
tbsWriter.putUint64(epoch)
encodeSender(tbsWriter, Sender(SenderType.MEMBER, senderLeafIndex))
tbsWriter.putOpaqueVarInt(ByteArray(0)) // authenticated_data
tbsWriter.putUint8(ContentType.COMMIT.value)
tbsWriter.putBytes(commitBytes) // Commit struct — no outer length prefix
tbsWriter.putBytes(groupContextBytes) // member sender appends context raw
val tbs = tbsWriter.toByteArray()
// SignWithLabel over TBS — produces the FramedContentAuthData.signature.
val signature = MlsCryptoProvider.signWithLabel(signingPrivateKey, "FramedContentTBS", tbs)
// AuthenticatedContentTBM = TBS || FramedContentAuthData
// FramedContentAuthData = signature<V> || (commit: confirmation_tag<V>)
val tbmWriter = TlsWriter()
tbmWriter.putBytes(tbs)
tbmWriter.putOpaqueVarInt(signature)
tbmWriter.putOpaqueVarInt(confirmationTag)
val tbm = tbmWriter.toByteArray()
// membership_tag = MAC(membership_key, TBM) — HMAC-SHA256 for
// ciphersuite 0x0001. Length = 32; openmls's equal_ct logs
// "Incompatible values" when an empty tag is compared to this.
val macInstance = MacInstance("HmacSHA256", membershipKey)
macInstance.update(tbm)
val membershipTag = macInstance.doFinal()
val publicMessage =
PublicMessage(
groupId = groupId,
@@ -1764,9 +1815,9 @@ class MlsGroup private constructor(
authenticatedData = ByteArray(0),
contentType = ContentType.COMMIT,
content = commitBytes,
signature = ByteArray(0),
signature = signature,
confirmationTag = confirmationTag,
membershipTag = ByteArray(0),
membershipTag = membershipTag,
)
return MlsMessage.fromPublicMessage(publicMessage).toTlsBytes()
}