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 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 =
if (pathSecrets.isNotEmpty()) {
pathSecrets.last().pathSecret
MlsCryptoProvider.deriveSecret(pathSecrets.last().pathSecret, "path")
} else {
ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
}
@@ -1131,15 +1138,18 @@ class MlsGroup private constructor(
ct.ciphertext,
)
// Derive remaining path secrets from common ancestor up to root.
// pathSecret is the secret AT commonAncestorIdx, so we derive
// (directPath.size - commonAncestorIdx - 1) more steps to reach root.
val remainingSteps = directPath.size - commonAncestorIdx - 1
// Derive remaining path secrets from common ancestor up to root,
// then one more step to reach the `commit_secret` (RFC 9420 §9.2:
// commit_secret = DeriveSecret(root_path_secret, "path")). Openmls
// 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
repeat(remainingSteps) {
repeat(stepsToRoot) {
currentSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
}
commitSecret = currentSecret
commitSecret = MlsCryptoProvider.deriveSecret(currentSecret, "path")
}
}
}
@@ -2452,10 +2462,11 @@ class MlsGroup private constructor(
)
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 =
if (pathSecrets.isNotEmpty()) {
pathSecrets.last().pathSecret
MlsCryptoProvider.deriveSecret(pathSecrets.last().pathSecret, "path")
} else {
ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
}