From 4ec192347e7109d0fd814eedc1d5c5ee4d07540c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 04:56:20 +0000 Subject: [PATCH] =?UTF-8?q?fix(quic):=20RFC=209001=20=C2=A76.6=20AEAD=20in?= =?UTF-8?q?vocation=20limit=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-key AEAD usage limits per RFC 9001 §6.6 / §B.1: * Confidentiality limit (encrypt count per send key): AES-128-GCM = 2^23, ChaCha20-Poly1305 = 2^62. Reaching this means AEAD security is no longer assured; the endpoint MUST initiate a key update or close. * Integrity limit (forged-packet count per receive key): AES-128-GCM = 2^52, ChaCha20-Poly1305 = 2^36. Reaching this means an attacker has been grinding for AEAD key recovery; MUST close with AEAD_LIMIT_REACHED. Pre-fix neither limit was tracked. Long-running AES-128-GCM sessions (~2hrs at 1000pkt/s) could roll past the confidentiality limit; an attacker spamming forged packets had no failure ceiling. Implementation: * `Aead.confidentialityLimit` / `Aead.integrityLimit` properties surface the spec values per cipher; AES-128-GCM and ChaCha20-Poly1305 (commonMain singletons + jvmAndroid JCA classes) return the §B.1 numbers. * `QuicConnection.aeadEncryptCount` / `aeadDecryptFailureCount` — per-key counters, reset on every 1-RTT key rotation (`commitKeyUpdate` and `initiateKeyUpdate`). * Writer increments encrypt count after each application-level build. At half the limit it soft-triggers `initiateKeyUpdate` (latched via `aeadKeyUpdateRequested` so the in-flight rotation isn't re-issued); at the limit it closes with AEAD_LIMIT_REACHED if rotation hasn't completed. * Parser increments decrypt-failure count on every 1-RTT AEAD auth-tag failure; closes when the count hits the integrity limit. Initial / Handshake levels are out of scope — their keys' lifetime is too short to approach the limit. Test: `AeadInvocationLimitTest` (6 cases) covers spec-value verification, counter increment on application send, counter reset on rotation, confidentiality-limit close, integrity-limit close (via a real ciphertext-tampered datagram from the in-process pipe). https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik --- .../quic/connection/QuicConnection.kt | 51 ++++++ .../quic/connection/QuicConnectionParser.kt | 12 ++ .../quic/connection/QuicConnectionWriter.kt | 23 +++ .../com/vitorpamplona/quic/crypto/Aead.kt | 30 +++ .../connection/AeadInvocationLimitTest.kt | 172 ++++++++++++++++++ .../quic/crypto/JcaAesGcmAead.kt | 4 + .../quic/crypto/JcaChaCha20Poly1305Aead.kt | 4 + 7 files changed, 296 insertions(+) create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/AeadInvocationLimitTest.kt diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt index 7ae599cae..5e9eddd8e 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -499,6 +499,46 @@ class QuicConnection( @Volatile internal var connectionInboundOffsetSum: Long = 0L + /** + * RFC 9001 §6.6 / §B.1: per-key encryption count for the active + * 1-RTT send key. The writer increments this on each + * application-level packet it builds. When the count reaches the + * AEAD's `confidentialityLimit` we MUST initiate a key update + * before the next packet (else AEAD security degrades). The + * counter resets to 0 every time the send key phase rotates. + * + * Initial / Handshake levels share their keys briefly enough that + * the limit isn't reachable, so we only track 1-RTT. + */ + @Volatile + internal var aeadEncryptCount: Long = 0L + + /** + * RFC 9001 §6.6 / §B.1: count of failed AEAD verifications on + * the active 1-RTT receive key. Each `parseAndDecrypt` that + * returns null at APPLICATION level bumps this. When it reaches + * the AEAD's `integrityLimit` we MUST close the connection with + * AEAD_LIMIT_REACHED. + * + * Resets every time the receive key phase rotates. The integrity + * limit for AES-128-GCM (2^52) is unreachable in practice; for + * ChaCha20-Poly1305 (2^36) it's reachable only by a determined + * attacker spamming forged packets — close in either case is + * spec-mandated. + */ + @Volatile + internal var aeadDecryptFailureCount: Long = 0L + + /** + * Soft-trigger latch for [aeadEncryptCount]. Set true once we've + * called [initiateKeyUpdate] in response to crossing the soft + * threshold (half the confidentiality limit). Prevents repeatedly + * firing key-updates while the in-progress one is still pending. + * Cleared when the rotation actually completes (counter resets). + */ + @Volatile + internal var aeadKeyUpdateRequested: Boolean = false + /** * Cumulative count of peer-initiated unidirectional streams we've * accepted (incremented in [getOrCreatePeerStreamLocked]). Compared @@ -1952,6 +1992,13 @@ class QuicConnection( currentSendKeyPhase = !currentSendKeyPhase } } + // RFC 9001 §6.6 limits are PER-KEY: every rotation resets both + // the encrypt count (now using fresh send keys) and the + // decrypt-failure count (fresh receive keys mean prior failures + // can't accumulate further toward the integrity limit). + aeadEncryptCount = 0L + aeadDecryptFailureCount = 0L + aeadKeyUpdateRequested = false qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION) qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION) } @@ -2039,6 +2086,10 @@ class QuicConnection( currentReceiveKeyPhase = !currentReceiveKeyPhase currentSendKeyPhase = !currentSendKeyPhase keyUpdateInProgress = true + // RFC 9001 §6.6 per-key counters reset on rotation (see commitKeyUpdate). + aeadEncryptCount = 0L + aeadDecryptFailureCount = 0L + aeadKeyUpdateRequested = false qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION) qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION) return true diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt index f663e39b9..cea82ad04 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -423,6 +423,18 @@ private fun feedShortHeaderPacket( "AEAD auth failed or header parse failed at level APPLICATION", datagram.size - offset, ) + // RFC 9001 §6.6 / §B.1 integrity limit. A 1-RTT packet that + // failed AEAD verification counts as a forgery attempt against + // the active receive key. Closing here prevents an attacker + // from grinding through the integrity-limit-many forgeries + // searching for a key recovery on the underlying AEAD. + conn.aeadDecryptFailureCount += 1L + val limit = live.aead.integrityLimit + if (conn.aeadDecryptFailureCount >= limit) { + conn.markClosedExternally( + "AEAD_LIMIT_REACHED: 1-RTT decrypt-failure count ${conn.aeadDecryptFailureCount} >= integrity limit $limit", + ) + } return } // AEAD succeeded with the candidate next-phase keys → commit the diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt index c7e76c3e9..530e7ead1 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt @@ -911,6 +911,29 @@ private fun buildApplicationPacket( // idle timer. (Non-ack-eliciting packets — pure ACK or PADDING-only // — do NOT reset.) if (ackEliciting) conn.lastActivityMs = nowMillis + // RFC 9001 §6.6 / §B.1 confidentiality limit. Track per-key + // encryption count for the active 1-RTT send key. At half the + // AEAD's confidentiality limit we soft-trigger a key update; at + // the limit we close the connection if rotation hasn't completed. + if (sizeBytes.isSuccess) { + conn.aeadEncryptCount += 1L + val limit = proto.aead.confidentialityLimit + if (!conn.aeadKeyUpdateRequested && conn.aeadEncryptCount >= limit / 2L) { + // Soft trigger — try to rotate. initiateKeyUpdate is a + // no-op if a previous rotation is still in flight, so the + // latch only flips on the first successful initiation. + conn.aeadKeyUpdateRequested = true + conn.initiateKeyUpdate() + } + if (conn.aeadEncryptCount >= limit) { + // Hard limit — rotation didn't complete in time. RFC 9001 + // §6.6 mandates closing rather than continuing to encrypt + // under a key whose confidentiality is no longer assured. + conn.markClosedExternally( + "AEAD_LIMIT_REACHED: 1-RTT encryption count ${conn.aeadEncryptCount} >= confidentiality limit $limit", + ) + } + } sizeBytes.getOrNull()?.let { built -> emitQlogSent(conn, EncryptionLevel.APPLICATION, pn, built.size, frames) } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/Aead.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/Aead.kt index e0671b904..2a4feec53 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/Aead.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/Aead.kt @@ -38,6 +38,28 @@ abstract class Aead { abstract val nonceLength: Int abstract val tagLength: Int + /** + * RFC 9001 §B.1 confidentiality limit — the maximum number of + * packets the endpoint can encrypt with a single key before MUST + * initiating a key update / closing the connection. + * + * AES-128-GCM: 2^23 = 8_388_608. + * ChaCha20-Poly1305: 2^62 (effectively unlimited for practical + * sessions, but still a finite cap). + */ + abstract val confidentialityLimit: Long + + /** + * RFC 9001 §B.1 integrity limit — the maximum number of forged + * packets (failed AEAD verifications) the endpoint may attempt to + * decrypt before MUST closing the connection with + * AEAD_LIMIT_REACHED. + * + * AES-128-GCM: 2^52 (effectively unreachable). + * ChaCha20-Poly1305: 2^36. + */ + abstract val integrityLimit: Long + abstract fun seal( key: ByteArray, nonce: ByteArray, @@ -153,6 +175,10 @@ object Aes128Gcm : Aead() { override val nonceLength = 12 override val tagLength = 16 + // RFC 9001 §B.1 limits for AEAD_AES_128_GCM. + override val confidentialityLimit: Long = 1L shl 23 + override val integrityLimit: Long = 1L shl 52 + override fun seal( key: ByteArray, nonce: ByteArray, @@ -186,6 +212,10 @@ object ChaCha20Poly1305Aead : Aead() { override val nonceLength = 12 override val tagLength = 16 + // RFC 9001 §B.1 limits for AEAD_CHACHA20_POLY1305. + override val confidentialityLimit: Long = (1L shl 62) + override val integrityLimit: Long = 1L shl 36 + override fun seal( key: ByteArray, nonce: ByteArray, diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/AeadInvocationLimitTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/AeadInvocationLimitTest.kt new file mode 100644 index 000000000..58d966907 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/AeadInvocationLimitTest.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quic.connection + +import com.vitorpamplona.quic.crypto.Aead +import com.vitorpamplona.quic.crypto.Aes128Gcm +import com.vitorpamplona.quic.crypto.ChaCha20Poly1305Aead +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * RFC 9001 §6.6 / §B.1 AEAD invocation limit enforcement. + * + * - Confidentiality limit: max number of packets encrypted with one + * send key. AES-128-GCM = 2^23, ChaCha20-Poly1305 = 2^62. + * - Integrity limit: max number of forged-packet AEAD failures on + * one receive key. AES-128-GCM = 2^52, ChaCha20-Poly1305 = 2^36. + * + * Pre-fix neither limit was tracked. A long-running session with + * AES-128-GCM would silently roll past 2^23 encrypts (security + * argument no longer holds); an attacker spamming forged packets + * could indefinitely grind for AEAD key recovery. + * + * The counter / limit logic is verified directly via the internal + * fields rather than spinning the writer for ~8 million encrypts — + * that would take minutes per test. + */ +class AeadInvocationLimitTest { + @Test + fun aes_128_gcm_limits_match_rfc_b1() { + val aead: Aead = Aes128Gcm + assertEquals(1L shl 23, aead.confidentialityLimit, "AES-128-GCM confidentiality limit per RFC 9001 §B.1") + assertEquals(1L shl 52, aead.integrityLimit, "AES-128-GCM integrity limit per RFC 9001 §B.1") + } + + @Test + fun chacha20_poly1305_limits_match_rfc_b1() { + val aead: Aead = ChaCha20Poly1305Aead + assertEquals(1L shl 62, aead.confidentialityLimit, "ChaCha20-Poly1305 confidentiality limit per RFC 9001 §B.1") + assertEquals(1L shl 36, aead.integrityLimit, "ChaCha20-Poly1305 integrity limit per RFC 9001 §B.1") + } + + @Test + fun encrypt_count_increments_per_application_packet() { + val (client, _) = newConnectedClient() + val before = client.aeadEncryptCount + // Open a uni stream and write to it — guaranteed to produce an + // ack-eliciting application packet on the next drain, regardless + // of whether the peer advertised `max_datagram_frame_size`. + runBlocking { + val stream = client.openUniStream() + stream.send.enqueue(byteArrayOf(0x01, 0x02, 0x03)) + stream.send.finish() + client.streamsLock.lock() + try { + drainOutbound(client, nowMillis = 0L) + } finally { + client.streamsLock.unlock() + } + } + assertTrue(client.aeadEncryptCount > before, "encrypt count must advance on outbound build, got $before -> ${client.aeadEncryptCount}") + } + + @Test + fun encrypt_count_resets_on_key_update_initiation() { + val (client, pipe) = newConnectedClient() + runBlocking { pipe.drive(maxRounds = 4) } + client.aeadEncryptCount = 10_000L + val rotated = client.initiateKeyUpdate() + // initiateKeyUpdate may legitimately fail if handshake-confirm + // hasn't propagated yet — the counter MUST still get reset on + // success. + if (rotated) { + assertEquals(0L, client.aeadEncryptCount, "counter resets on rotation") + assertEquals(0L, client.aeadDecryptFailureCount, "decrypt-failure counter resets on rotation too") + } + } + + @Test + fun encrypt_at_confidentiality_limit_closes_connection() { + val (client, pipe) = newConnectedClient() + runBlocking { pipe.drive(maxRounds = 4) } + // Pin the counter so the next outbound build crosses the limit. + // Skip the key-update soft trigger by also latching + // [aeadKeyUpdateRequested]. + client.aeadKeyUpdateRequested = true + val limit = + client.application.sendProtection + ?.aead + ?.confidentialityLimit + assertNotNull(limit, "client must have application-level send keys after handshake") + client.aeadEncryptCount = limit - 1L + runBlocking { + // Open a uni stream + write a few bytes; the resulting + // STREAM frame guarantees an ack-eliciting application + // packet on the next drain. The writer's post-encrypt + // limit check fires at the boundary. + val stream = client.openUniStream() + stream.send.enqueue(byteArrayOf(0xff.toByte())) + stream.send.finish() + client.streamsLock.lock() + try { + drainOutbound(client, nowMillis = 0L) + } finally { + client.streamsLock.unlock() + } + } + assertEquals(QuicConnection.Status.CLOSED, client.status) + val reason = client.closeReason + assertNotNull(reason) + assertTrue(reason.contains("AEAD_LIMIT_REACHED")) + } + + @Test + fun decrypt_failure_at_integrity_limit_closes_connection() { + val (client, pipe) = newConnectedClient() + // Craft a real, properly-encrypted 1-RTT datagram from the + // server's perspective, then flip one ciphertext byte so AEAD + // verification fails on the client side. This exercises the + // exact parser path (HP unmask succeeds, AEAD returns null, + // counter advances) — feeding garbage bytes would trip the + // reserved-bits PROTOCOL_VIOLATION check first. + val cleanDatagram = + // 100 PING frames pad the packet well past the 20 bytes of + // header-protection sample range (sample ends at + // byte (1 + dcidLen + 4 + 16) ≈ byte 29 for dcidLen=8). + // Tampering a byte well past that range leaves the HP + // mask intact while breaking the AEAD tag, so the + // ShortHeaderPacket.parseAndDecrypt path returns null + // (AEAD failure) instead of throwing on reserved-bit + // mismatch (HP-mask divergence). + pipe.buildServerApplicationDatagram(List(100) { com.vitorpamplona.quic.frame.PingFrame })!! + val tampered = cleanDatagram.copyOf() + // Flip the last byte of the AEAD tag — far past the HP sample + // range so HP unmask still recovers the correct first byte. + tampered[tampered.size - 1] = (tampered[tampered.size - 1].toInt() xor 0x01).toByte() + val limit = + client.application.receiveProtection + ?.aead + ?.integrityLimit + assertNotNull(limit) + // Pin the counter just under the limit so the tampered packet's + // AEAD failure crosses the threshold. + client.aeadDecryptFailureCount = limit - 1L + feedDatagram(client, tampered, nowMillis = 0L) + assertEquals(QuicConnection.Status.CLOSED, client.status) + val reason = client.closeReason + assertNotNull(reason) + assertTrue(reason.contains("AEAD_LIMIT_REACHED"), "expected AEAD_LIMIT_REACHED, got: $reason") + } +} diff --git a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaAesGcmAead.kt b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaAesGcmAead.kt index e6ddc533d..501e9166b 100644 --- a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaAesGcmAead.kt +++ b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaAesGcmAead.kt @@ -48,6 +48,10 @@ class JcaAesGcmAead( override val nonceLength = 12 override val tagLength = 16 + // RFC 9001 §B.1 limits — same as commonMain `Aes128Gcm`. + override val confidentialityLimit: Long = 1L shl 23 + override val integrityLimit: Long = 1L shl 52 + private val keySpec = SecretKeySpec(key, "AES") // Separate ciphers per direction. JCA's AES-GCM tracks the (key, iv) pair diff --git a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaChaCha20Poly1305Aead.kt b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaChaCha20Poly1305Aead.kt index a2c45705e..e66e60695 100644 --- a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaChaCha20Poly1305Aead.kt +++ b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaChaCha20Poly1305Aead.kt @@ -47,6 +47,10 @@ class JcaChaCha20Poly1305Aead( override val nonceLength = 12 override val tagLength = 16 + // RFC 9001 §B.1 limits — same as commonMain `ChaCha20Poly1305Aead`. + override val confidentialityLimit: Long = (1L shl 62) + override val integrityLimit: Long = 1L shl 36 + private val keySpec = SecretKeySpec(key, "ChaCha20") private val encryptCipher: Cipher = Cipher.getInstance("ChaCha20-Poly1305") private val decryptCipher: Cipher = Cipher.getInstance("ChaCha20-Poly1305")