fix(marmot): derive commit_secret one step past the root path_secret

RFC 9420 §9.2: `commit_secret = DeriveSecret(path_secret_at_root, "path")`.
Openmls / mdk implement this exactly — after deriving the ratchet-tree's
own path_secrets up to (and including) the root, they advance one more
step and use THAT as the commit_secret contribution to the new epoch's
key schedule.

Quartz was using the root's own path_secret as commit_secret on both
the encryption side (MlsGroup.commit) and the decryption side
(MlsGroup.processCommit), plus in externalJoin's commit construction.
Internally consistent, so quartz↔quartz worked; but every cross-impl
commit (quartz→openmls or openmls→quartz) derived a different
epoch_secret, cascaded into a different confirmation_key, and failed at
`InvalidCommit(ConfirmationTagMismatch)` — the blocker behind every
test that actually needed an openmls peer to accept a quartz-produced
commit or vice-versa once the UpdatePath-layer bugs were out of the way.

Fix: add one `DeriveSecret(pathSecrets.last().pathSecret, "path")` step
on the sender side, and one extra `DeriveSecret(..., "path")` past the
root on the receiver side, mirroring openmls's `ParentNode::derive_path`
loop post-condition.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
This commit is contained in:
Claude
2026-04-22 03:26:09 +00:00
parent 0163d7e75a
commit c50fd18cb5
@@ -574,10 +574,17 @@ class MlsGroup private constructor(
val commit = Commit(proposalOrRefs, updatePath) val commit = Commit(proposalOrRefs, updatePath)
val commitBytes = commit.toTlsBytes() val commitBytes = commit.toTlsBytes()
// Advance epoch // Advance epoch.
// RFC 9420 §9.2: commit_secret is the path_secret for the "virtual" node
// one step past the root — i.e. `DeriveSecret(path_secret_at_root, "path")`,
// NOT the path_secret at the root itself. Openmls/mdk derives exactly this
// value; quartz was using the root's own path_secret, which is the
// encryption-key seed rather than the key-schedule contribution. That
// one-step gap silently diverged the two sides' epoch_secret and made
// every cross-impl commit fail `ConfirmationTagMismatch`.
val commitSecret = val commitSecret =
if (pathSecrets.isNotEmpty()) { if (pathSecrets.isNotEmpty()) {
pathSecrets.last().pathSecret MlsCryptoProvider.deriveSecret(pathSecrets.last().pathSecret, "path")
} else { } else {
ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH) ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
} }
@@ -1131,15 +1138,18 @@ class MlsGroup private constructor(
ct.ciphertext, ct.ciphertext,
) )
// Derive remaining path secrets from common ancestor up to root. // Derive remaining path secrets from common ancestor up to root,
// pathSecret is the secret AT commonAncestorIdx, so we derive // then one more step to reach the `commit_secret` (RFC 9420 §9.2:
// (directPath.size - commonAncestorIdx - 1) more steps to reach root. // commit_secret = DeriveSecret(root_path_secret, "path")). Openmls
val remainingSteps = directPath.size - commonAncestorIdx - 1 // advances one step past the root; quartz was stopping at the root
// and diverging — that's what caused `ConfirmationTagMismatch` on
// every cross-impl commit.
val stepsToRoot = directPath.size - commonAncestorIdx - 1
var currentSecret = pathSecret var currentSecret = pathSecret
repeat(remainingSteps) { repeat(stepsToRoot) {
currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path") currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
} }
commitSecret = currentSecret commitSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
} }
} }
} }
@@ -2452,10 +2462,11 @@ class MlsGroup private constructor(
) )
val commitBytes = commit.toTlsBytes() val commitBytes = commit.toTlsBytes()
// Derive epoch secrets using external init_secret // Derive epoch secrets using external init_secret.
// commit_secret = DeriveSecret(root_path_secret, "path") (RFC 9420 §9.2).
val commitSecret = val commitSecret =
if (pathSecrets.isNotEmpty()) { if (pathSecrets.isNotEmpty()) {
pathSecrets.last().pathSecret MlsCryptoProvider.deriveSecret(pathSecrets.last().pathSecret, "path")
} else { } else {
ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH) ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
} }