From 647acb5909483af0ea0bdc42b6b5a32a73eda9e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 03:59:04 +0000 Subject: [PATCH] fix(marmot): short-circuit past/future PrivateMessage commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../quartz/marmot/MarmotInboundProcessor.kt | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index 5ec6c0dd8..dc1dc9209 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -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}", + ) + } + } } }