fix(marmot): thread actual wire_format into ConfirmedTranscriptHashInput

RFC 9420 §8.2 says ConfirmedTranscriptHashInput carries the
wire_format of the AuthenticatedContent being committed, not a
hard-coded value. openmls/mdk pass mls_content.wire_format()
through, so B's PrivateMessage commits use wire_format=2 in their
transcript-hash input. Quartz always wrote wire_format=1
(PublicMessage), so amy's receiver-side new_confirmed_transcript_hash
diverged from B's — and every tag derived from it
(confirmation_key, confirmation_tag, epoch secrets) diverged too,
surfacing as "Confirmation tag verification failed" once my
error-surfacing fix stopped hiding it.

Plumb the real wire format through processCommit and its decrypt()
dispatch so PrivateMessage commits from B are hashed as
PrivateMessage on amy's side.

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
This commit is contained in:
Claude
2026-04-22 07:23:06 +00:00
parent 0e23f2cde5
commit 0f3d4e04f2
2 changed files with 20 additions and 7 deletions
@@ -967,9 +967,11 @@ class MlsGroup private constructor(
}
}
// Apply the commit inline — after this returns, the caller
// only needs to know the epoch advanced. The signature rides
// into the transcript-hash computation inside processCommit.
processCommit(commitBytes, senderLeafIndex, confirmationTag, signature)
// only needs to know the epoch advanced. The signature + wire
// format ride into the transcript-hash computation inside
// processCommit (RFC 9420 §8.2 requires the real wire format
// for ConfirmedTranscriptHashInput).
processCommit(commitBytes, senderLeafIndex, confirmationTag, signature, WireFormat.PRIVATE_MESSAGE)
return DecryptedMessage(
senderLeafIndex = senderLeafIndex,
@@ -1039,6 +1041,7 @@ class MlsGroup private constructor(
senderLeafIndex: Int,
confirmationTag: ByteArray,
signature: ByteArray = ByteArray(0),
wireFormat: WireFormat = WireFormat.PUBLIC_MESSAGE,
) {
val commit = Commit.decodeTls(TlsReader(commitBytes))
@@ -1215,7 +1218,7 @@ class MlsGroup private constructor(
// Update transcript hashes (RFC 9420 Section 8.2)
// ConfirmedTranscriptHashInput = wire_format || FramedContent || signature
val confirmedTranscriptHashInput = buildConfirmedTranscriptHashInput(commit, senderLeafIndex, signature)
val confirmedTranscriptHashInput = buildConfirmedTranscriptHashInput(commit, senderLeafIndex, signature, wireFormat)
val confirmedInput = TlsWriter()
confirmedInput.putBytes(interimTranscriptHash)
confirmedInput.putBytes(confirmedTranscriptHashInput)
@@ -1393,7 +1396,8 @@ class MlsGroup private constructor(
commit: Commit,
senderLeafIndex: Int,
signature: ByteArray = ByteArray(0),
): ByteArray = buildConfirmedTranscriptHashInput(commit, senderLeafIndex, groupId, epoch, signature)
wireFormat: WireFormat = WireFormat.PUBLIC_MESSAGE,
): ByteArray = buildConfirmedTranscriptHashInput(commit, senderLeafIndex, groupId, epoch, signature, wireFormat)
/**
* Compute the RFC 9420 §7.9.2 parent_hash chain for a commit we are
@@ -1992,9 +1996,17 @@ class MlsGroup private constructor(
groupId: ByteArray,
epoch: Long,
signature: ByteArray = ByteArray(0),
wireFormat: WireFormat = WireFormat.PUBLIC_MESSAGE,
): ByteArray {
// RFC 9420 §8.2: ConfirmedTranscriptHashInput carries the wire_format
// of the actual AuthenticatedContent — openmls passes
// `mls_content.wire_format()` through. When B sends a commit as a
// PrivateMessage (mdk default = AlwaysCiphertext outgoing), amy
// MUST recompute the transcript hash with wire_format=2, or the
// resulting confirmed_transcript_hash — and thus the
// confirmation_tag derived from it — silently diverges from B's.
val writer = TlsWriter()
writer.putUint16(WireFormat.PUBLIC_MESSAGE.value)
writer.putUint16(wireFormat.value)
writer.putOpaqueVarInt(groupId)
writer.putUint64(epoch)
writer.putUint8(1) // SenderType.MEMBER
@@ -289,6 +289,7 @@ class MlsGroupManager(
senderLeafIndex: Int,
confirmationTag: ByteArray,
signature: ByteArray = ByteArray(0),
wireFormat: com.vitorpamplona.quartz.marmot.mls.framing.WireFormat = com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE,
) = mutex.withLock {
val group = requireGroup(nostrGroupId)
@@ -299,7 +300,7 @@ class MlsGroupManager(
// the current epoch key, wasting the finite retention slots.
val retainedBefore = group.retainedSecrets()
group.processCommit(commitBytes, senderLeafIndex, confirmationTag, signature)
group.processCommit(commitBytes, senderLeafIndex, confirmationTag, signature, wireFormat)
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)