diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt index db6b9e638..f01a6f465 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt @@ -196,11 +196,17 @@ data class PublicMessage( override fun hashCode(): Int = groupId.contentHashCode() companion object { + /** Same DoS cap that PrivateMessage applies to its AAD field. */ + const val MAX_AUTHENTICATED_DATA_BYTES = 1 shl 16 // 64 KiB + fun decodeTls(reader: TlsReader): PublicMessage { val groupId = reader.readOpaqueVarInt() val epoch = reader.readUint64() val sender = decodeSender(reader) val authenticatedData = reader.readOpaqueVarInt() + require(authenticatedData.size <= MAX_AUTHENTICATED_DATA_BYTES) { + "PublicMessage authenticated_data too large: ${authenticatedData.size} > $MAX_AUTHENTICATED_DATA_BYTES" + } val contentType = ContentType.fromValue(reader.readUint8()) // RFC 9420 §6 FramedContent.content body varies by content_type. @@ -289,15 +295,47 @@ data class PrivateMessage( override fun hashCode(): Int = groupId.contentHashCode() companion object { - fun decodeTls(reader: TlsReader): PrivateMessage = - PrivateMessage( - groupId = reader.readOpaqueVarInt(), - epoch = reader.readUint64(), - contentType = ContentType.fromValue(reader.readUint8()), - authenticatedData = reader.readOpaqueVarInt(), - encryptedSenderData = reader.readOpaqueVarInt(), - ciphertext = reader.readOpaqueVarInt(), + /** + * Per-field DoS cap for `authenticated_data` and + * `encrypted_sender_data`. The underlying [TlsReader] already + * refuses any opaque field larger than 1 MiB (its global + * `MAX_OPAQUE_SIZE`), but those fields shouldn't carry anything + * close to that — `encrypted_sender_data` is always exactly the + * sender-data AEAD ciphertext (a couple hundred bytes), and + * Marmot doesn't put anything in `authenticated_data` today. + * Tightening to 64 KiB makes the per-frame memory floor + * predictable and turns "we'd allocate up to 3 MiB on every + * inbound packet that escapes verification" into "we'll + * allocate at most ~1 MiB even before AEAD." + */ + const val MAX_AAD_BYTES = 1 shl 16 // 64 KiB + + fun decodeTls(reader: TlsReader): PrivateMessage { + val groupId = reader.readOpaqueVarInt() + val epoch = reader.readUint64() + val contentType = ContentType.fromValue(reader.readUint8()) + val authenticatedData = reader.readOpaqueVarInt() + require(authenticatedData.size <= MAX_AAD_BYTES) { + "PrivateMessage authenticated_data too large: ${authenticatedData.size} > $MAX_AAD_BYTES" + } + val encryptedSenderData = reader.readOpaqueVarInt() + require(encryptedSenderData.size <= MAX_AAD_BYTES) { + "PrivateMessage encrypted_sender_data too large: ${encryptedSenderData.size} > $MAX_AAD_BYTES" + } + // `ciphertext` size is bounded by [TlsReader.MAX_OPAQUE_SIZE] + // (1 MiB) — which dominates any peer-side practical limit + // (Marmot kind:445 events ride a Nostr relay's per-event + // ceiling, typically 64 KiB). No tighter cap needed here. + val ciphertext = reader.readOpaqueVarInt() + return PrivateMessage( + groupId = groupId, + epoch = epoch, + contentType = contentType, + authenticatedData = authenticatedData, + encryptedSenderData = encryptedSenderData, + ciphertext = ciphertext, ) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt index 596b80262..ccb8dfeab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt @@ -71,6 +71,23 @@ class SecretTree( /** Maximum consumed generation entries to track per sender before pruning. */ const val MAX_CONSUMED_GENERATIONS_PER_SENDER = 1000 + + /** + * Hard cap on how many ratchet steps a single decrypt request may + * fast-forward through. Without this an attacker who can deliver a + * single PrivateMessage with a forged `generation` field can force + * the receiver into ~`generation` HKDF-Expand steps — trivially + * many seconds of CPU per packet. The skipped-key cache itself + * stops at [MAX_SKIPPED_KEYS] entries, but the ratchet keeps + * advancing past that point, so the cache cap doesn't bound + * compute cost. + * + * 4096 leaves room for a realistic application/handshake gap + * (e.g. mobile catching up after a long sleep) while making the + * attack worst-case ~4096 SHA-256 invocations — bounded enough + * that the receiver stays responsive. + */ + const val MAX_RATCHET_STEPS_PER_CALL = 4096 } /** @@ -149,6 +166,15 @@ class SecretTree( require(generation >= state.applicationGeneration) { "Generation $generation already consumed (current: ${state.applicationGeneration})" } + // DoS guard: a malicious sender can put any uint32 in the + // PrivateMessage generation field, and without this cap a single + // packet would force unbounded HKDF-Expand work. See + // MAX_RATCHET_STEPS_PER_CALL. + require(generation - state.applicationGeneration <= MAX_RATCHET_STEPS_PER_CALL) { + "Application generation jump too large: " + + "${generation - state.applicationGeneration} steps (cap $MAX_RATCHET_STEPS_PER_CALL); " + + "sender $leafIndex from ${state.applicationGeneration} to $generation" + } // Replay detection: reject if this (sender, generation) was already used val senderConsumed = consumedGenerations.getOrPut(leafIndex) { mutableSetOf() } @@ -219,6 +245,11 @@ class SecretTree( require(generation >= state.handshakeGeneration) { "Handshake generation $generation already consumed (current: ${state.handshakeGeneration})" } + require(generation - state.handshakeGeneration <= MAX_RATCHET_STEPS_PER_CALL) { + "Handshake generation jump too large: " + + "${generation - state.handshakeGeneration} steps (cap $MAX_RATCHET_STEPS_PER_CALL); " + + "sender $leafIndex from ${state.handshakeGeneration} to $generation" + } val senderConsumed = consumedHandshakeGenerations.getOrPut(leafIndex) { mutableSetOf() } require(generation !in senderConsumed) { diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt index f21557b38..6c90c280c 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipBehaviorTest.kt @@ -551,6 +551,67 @@ class MarmotMipBehaviorTest { ) } + // ---------------------------------------------------------------------- + // DoS guards: forward-ratchet cap + PrivateMessage size caps + // ---------------------------------------------------------------------- + + /** + * A receiver that's been silent at generation 0 for one peer must + * NOT be forced to do unbounded HKDF-Expand work just because the + * peer (or an attacker) sets a multi-billion `generation` field on + * an inbound PrivateMessage. The SecretTree caps the per-call + * ratchet jump at 4096 — anything past that throws before any HKDF + * runs. + */ + @Test + fun secretTree_rejectsRatchetJumpsBeyondCap() { + val st = + com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree( + encryptionSecret = ByteArray(32), + leafCount = 1, + ) + // 4096 is allowed (boundary case — fast-forwards 4096 steps). + st.applicationKeyNonceForGeneration(0, 4096) + // 4097 above the previous head is rejected. + val ex = + assertFailsWith { + st.applicationKeyNonceForGeneration(0, 4096 + 4097 + 1) + } + assertTrue(ex.message!!.contains("jump too large"), "must explain the cap: ${ex.message}") + } + + /** + * Marmot tightens `authenticated_data` to 64 KiB even though the + * underlying TlsReader cap is 1 MiB — those fields are never + * legitimately that large, and tightening turns a 1 MiB pre- + * verification allocation into 64 KiB. Hand-build a frame with a + * 65 KiB authenticated_data and verify rejection. + */ + @Test + fun privateMessage_rejectsOversizedAuthenticatedData() { + val w = + com.vitorpamplona.quartz.marmot.mls.codec + .TlsWriter() + w.putOpaqueVarInt(ByteArray(32)) // group_id + w.putUint64(0L) // epoch + w.putUint8(1) // content_type = APPLICATION + w.putOpaqueVarInt(ByteArray((1 shl 16) + 1)) // authenticated_data: 64 KiB + 1 + w.putOpaqueVarInt(ByteArray(0)) // encrypted_sender_data + w.putOpaqueVarInt(ByteArray(64)) // ciphertext (small) + val ex = + assertFailsWith { + com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage + .decodeTls( + com.vitorpamplona.quartz.marmot.mls.codec + .TlsReader(w.toByteArray()), + ) + } + assertTrue( + ex.message!!.contains("authenticated_data too large"), + "must name the field: ${ex.message}", + ) + } + // ---------------------------------------------------------------------- // RFC 9420 §7.9 parent_hash chain validation on Welcome // ----------------------------------------------------------------------