fix(marmot): short-circuit past/future PrivateMessage commits

Extend the past-epoch dedup check to PrivateMessage commits too.
Previously only the PublicMessage branch returned
`GroupEventResult.Duplicate` for a stale commit echo; the PrivateMessage
branch called `groupManager.decrypt()` directly, which consumed a
generation on the sender's ratchet and then failed with
`Message epoch X doesn't match current epoch Y` — polluting the harness
log and (worse) burning the real commit's generation slot.

Peek the epoch from the parsed PrivateMessage before touching the
secret tree: past-epoch → Duplicate, future-epoch → Error, same-epoch
falls through to the existing decrypt-and-dispatch path.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
This commit is contained in:
Claude
2026-04-22 03:59:04 +00:00
parent e9df0155c1
commit 647acb5909
@@ -511,16 +511,37 @@ class MarmotInboundProcessor(
when (mlsMessage.wireFormat) {
WireFormat.PRIVATE_MESSAGE -> {
// For private commits, MLS decrypt handles epoch advancement
val decrypted = groupManager.decrypt(groupId, mlsMessage.toTlsBytes())
if (decrypted.contentType == ContentType.COMMIT) {
val group = groupManager.getGroup(groupId)
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0)
} else {
GroupEventResult.Error(
groupId,
"Expected COMMIT but got ${decrypted.contentType}",
)
// Sniff the PrivateMessage epoch without consuming any
// ratchet state. Past-epoch echoes and future-epoch
// arrivals must not advance the secret tree — otherwise
// the real handshake / application message gets rejected
// when it finally arrives.
val privPeek = PrivateMessage.decodeTls(TlsReader(mlsMessage.payload))
val currentEpoch = groupManager.getGroup(groupId)?.epoch
when {
currentEpoch != null && privPeek.epoch < currentEpoch -> {
GroupEventResult.Duplicate(groupId)
}
currentEpoch != null && privPeek.epoch > currentEpoch -> {
GroupEventResult.Error(
groupId,
"PrivateMessage epoch ${privPeek.epoch} is ahead of local epoch $currentEpoch; ignoring",
)
}
else -> {
val decrypted = groupManager.decrypt(groupId, mlsMessage.toTlsBytes())
if (decrypted.contentType == ContentType.COMMIT) {
val group = groupManager.getGroup(groupId)
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0)
} else {
GroupEventResult.Error(
groupId,
"Expected COMMIT but got ${decrypted.contentType}",
)
}
}
}
}