fix(quic): RFC 9000 §17.2/§17.3 fixed-bit + §10.1 idle-timeout enforcement
The two High-severity gaps from the 2026-05-09 four-RFC compliance audit.
Fixed-bit (RFC 9000 §17.2 long-header, §17.3 short-header): the spec
says fixed-bit (0x40) MUST be 1 in every v1 packet; receivers MUST
discard packets where it's 0. Pre-fix the parsers checked only the
form-bit (0x80) and accepted any fixed-bit value — a peer or off-path
attacker could route packets through AEAD that aren't valid v1 packets.
Both `parseAndDecrypt` paths and the `peekKeyPhase` shortcut now
silently drop fixed-bit=0. The bit isn't header-protected (HP only
XORs the low 5 bits), so the check runs on the raw wire byte before
HP unmask + AEAD. Test: `FixedBitValidationTest` (3 cases).
Idle timeout (RFC 9000 §10.1): `max_idle_timeout` was decoded into
config and advertised via the ClientHello transport-params extension,
but never enforced — a black-holed connection lived indefinitely.
This commit:
* adds `QuicConnection.lastActivityMs`, bumped on (a) successful
inbound packet decrypt and (b) outbound ack-eliciting send per
§10.1.1
* adds `effectiveIdleTimeoutMs()` returning the min of local and
peer advertisements (skipping any side that advertised 0), with
the §10.1 `3 * PTO` floor applied
* folds the idle deadline into the driver's send-loop
`withTimeoutOrNull` so an idle connection wakes exactly at expiry
* silently transitions to CLOSED via `markClosedExternally` per
§10.2.1 ("the connection enters the closing state silently —
discarding the connection state without sending a CONNECTION_CLOSE")
Test: `IdleTimeoutTest` (8 cases) covers the min-of-two computation,
0-means-disabled handling, the 3*PTO floor, deadline math, and
postpone-on-activity. Plan updated to mark both items resolved.
https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
This commit is contained in:
@@ -193,7 +193,7 @@ gates on the audio-rooms completion plan
|
||||
| 8 | 2026-05-06 | Path validation pass 1 + 2 | DCID rotation, retire-prior-to monotonicity, abrupt migration | all fixed |
|
||||
| 9 | 2026-05-07 | DoS hardening + reserved-bit checks + TLS bounds + SendBuffer shrink | bound peer-controlled buffers and channels | all fixed |
|
||||
| 10 | 2026-05-08 | Lock-split design + close-under-load + key-update PN gate | streamsLock + lifecycleLock split | all fixed; see [`2026-05-08-lock-split-design.md`](2026-05-08-lock-split-design.md) |
|
||||
| 11 | 2026-05-09 | Four-RFC compliance audit (4 parallel agents: 9000, 9001, 9002, 9221+9114+9204) | ~15 items, mostly receive-side validation gaps | catalogued in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09) below |
|
||||
| 11 | 2026-05-09 | Four-RFC compliance audit (4 parallel agents: 9000, 9001, 9002, 9221+9114+9204) | ~15 items, mostly receive-side validation gaps | catalogued in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09) below; both 🔴 High items (fixed-bit, idle timeout) fixed same-day |
|
||||
|
||||
Every fix carries an inline `audit-N #M` reference comment so the regression
|
||||
test → fix → comment chain is auditable. The whole audit corpus is in the
|
||||
@@ -287,8 +287,8 @@ file:line evidence. Severity:
|
||||
|
||||
| RFC § | Sev | Gap | Evidence |
|
||||
|---|---|---|---|
|
||||
| RFC 9000 §17.3 | 🔴 | **Inbound short-header fixed-bit (0x40) not validated.** RFC says fixed-bit MUST be 1 on receive. Form-bit (0x80) and reserved-bit (0x18) ARE checked, but 0x40 is never asserted post-HP-unmask. | `ShortHeaderPacket.kt:163-187` (form-bit checked, fixed-bit not) |
|
||||
| RFC 9000 §10.1 | 🔴 | **`max_idle_timeout` not enforced.** Decoded into config and advertised, but no idle deadline tracking, no auto-close on silence. Connection lives forever if no I/O happens. | `QuicConnection.kt:1040,1054,1069`; `QuicConnectionDriver.kt` (no idle timer) |
|
||||
| RFC 9000 §17.2 + §17.3 | ~~🔴~~ | ~~**Inbound fixed-bit (0x40) not validated** on long- or short-header receive.~~ **Resolved 2026-05-09** — both `LongHeaderPacket.parseAndDecrypt`, `ShortHeaderPacket.parseAndDecrypt`, and `ShortHeaderPacket.peekKeyPhase` now reject fixed-bit=0 packets per RFC §17.2 / §17.3 (silent discard). Tests in `FixedBitValidationTest`. |
|
||||
| RFC 9000 §10.1 | ~~🔴~~ | ~~**`max_idle_timeout` not enforced.**~~ **Resolved 2026-05-09** — `QuicConnection.lastActivityMs` updated on inbound packet receipt and outbound ack-eliciting send; `effectiveIdleTimeoutMs()` computes min(local, peer) with 3*PTO floor per §10.1; driver send-loop folds the deadline into its `withTimeoutOrNull` and silently closes via `markClosedExternally` per §10.2.1 on expiry. Tests in `IdleTimeoutTest` (8 cases). |
|
||||
| RFC 9000 §4.1 | 🟡 | **Connection-level inbound flow control missing.** Per-stream `receiveLimit` IS enforced (`QuicConnectionParser.kt:655`) — peer overshoot closes the connection. But the aggregate connection-level cap (matching what we advertised in `initial_max_data`) is not tracked on the receive side. | `QuicConnectionParser.kt:735-743` (only updates send-side credit on MAX_DATA) |
|
||||
| RFC 9000 §3 | 🟡 | **Stream state machine implicit, not validated.** The 10-state machine (Ready / Send / Data Sent / Data Recvd / Reset Sent / Reset Recvd × directions) is encoded as flags + buffer content. STREAM frames arriving after a RESET_STREAM are silently absorbed instead of raising STREAM_STATE_ERROR. (FIN-size mismatch *is* caught — `QuicConnectionParser.kt:672-685`.) | `QuicStream.kt:35-51` (no explicit state field) |
|
||||
| RFC 9000 §10.2 | 🟡 | **No DRAINING state.** Closing transitions go CONNECTED → CLOSING → CLOSED without the 3 × PTO hold the spec mandates. Late inbound packets after our own CONNECTION_CLOSE may not get a re-emitted close. | `QuicConnection.kt:301,1441-1531` (Status enum has no DRAINING) |
|
||||
|
||||
@@ -296,7 +296,7 @@ class QuicConnection(
|
||||
*/
|
||||
@Volatile
|
||||
var peerTransportParameters: TransportParameters? = null
|
||||
private set
|
||||
internal set
|
||||
|
||||
enum class Status { HANDSHAKING, CONNECTED, CLOSING, CLOSED }
|
||||
|
||||
@@ -310,6 +310,29 @@ class QuicConnection(
|
||||
var status: Status = Status.HANDSHAKING
|
||||
internal set
|
||||
|
||||
/**
|
||||
* RFC 9000 §10.1 idle-timeout tracking. The driver fires
|
||||
* [markClosedExternally] (silent close, no CONNECTION_CLOSE per
|
||||
* §10.2.1) when the time since this timestamp exceeds
|
||||
* [effectiveIdleTimeoutMs].
|
||||
*
|
||||
* Updated on:
|
||||
* - successful inbound packet decrypt (parser)
|
||||
* - outbound ack-eliciting packet emission (writer)
|
||||
*
|
||||
* Initialized to the connection's start time so the timer can fire
|
||||
* even if no inbound packet ever arrives (e.g. handshake never
|
||||
* completes on a black-holed path).
|
||||
*
|
||||
* @Volatile because the driver reads it from the send loop while the
|
||||
* parser / writer mutate it from feed/drain coroutines under
|
||||
* `streamsLock`. A torn long read would mis-arm the deadline by at
|
||||
* most one packet's worth of time, but volatile is cheaper than the
|
||||
* alternative (locking around every observation).
|
||||
*/
|
||||
@Volatile
|
||||
internal var lastActivityMs: Long = nowMillis()
|
||||
|
||||
/**
|
||||
* Non-suspend monitor protecting the atomic transition of
|
||||
* [status] / [closeReason] / [closeErrorCode] from a non-CLOSED to a
|
||||
@@ -2318,6 +2341,48 @@ class QuicConnection(
|
||||
currentPtoMillis: Long = lossDetection.ptoBaseMs(peerTransportParameters?.maxAckDelay ?: 0L),
|
||||
): PathMigrationResult = streamsLock.withLock { triggerPathMigrationLocked(nowMillis, currentPtoMillis) }
|
||||
|
||||
/**
|
||||
* RFC 9000 §10.1: effective idle-timeout in milliseconds, or null
|
||||
* if neither endpoint advertised a non-zero value (timeout is
|
||||
* disabled). The effective timeout is the minimum of the
|
||||
* locally-configured value and the peer's advertised value, after
|
||||
* dropping any side that advertised 0 (per §18.2 a value of 0
|
||||
* means "no timeout").
|
||||
*
|
||||
* §10.1 also requires we increase the timeout to at least 3 * PTO
|
||||
* to avoid timing out before loss detection has had a chance to
|
||||
* recover the path.
|
||||
*/
|
||||
internal fun effectiveIdleTimeoutMs(): Long? {
|
||||
val local = config.maxIdleTimeoutMillis.takeIf { it > 0L }
|
||||
val peer = peerTransportParameters?.maxIdleTimeoutMillis?.takeIf { it > 0L }
|
||||
val agreed =
|
||||
when {
|
||||
local != null && peer != null -> minOf(local, peer)
|
||||
local != null -> local
|
||||
peer != null -> peer
|
||||
else -> return null
|
||||
}
|
||||
// §10.1: floor at 3 * PTO so loss recovery has time to fire.
|
||||
// Pre-handshake we don't have peer max_ack_delay yet, fall back
|
||||
// to 0 (matches the writer's pre-handshake gating).
|
||||
val maxAckDelayMs = peerTransportParameters?.maxAckDelay ?: 0L
|
||||
val ptoBaseMs = lossDetection.ptoBaseMs(maxAckDelayMs).coerceAtLeast(1L)
|
||||
val floor = ptoBaseMs * 3L
|
||||
return agreed.coerceAtLeast(floor)
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9000 §10.1.1 idle-timer tick. Called by the driver after a
|
||||
* [withTimeoutOrNull] expiry to check whether the silence has
|
||||
* exceeded [effectiveIdleTimeoutMs]. Returns true on timeout
|
||||
* (caller should silently close per §10.2.1).
|
||||
*/
|
||||
internal fun isIdleTimedOut(nowMillis: Long): Boolean {
|
||||
val timeoutMs = effectiveIdleTimeoutMs() ?: return false
|
||||
return nowMillis - lastActivityMs >= timeoutMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an outstanding PATH_CHALLENGE has exceeded the
|
||||
* 3 * PTO budget (RFC 9000 §8.2.4). Called from the driver's
|
||||
|
||||
+29
-7
@@ -288,8 +288,22 @@ class QuicConnectionDriver(
|
||||
connection.handshake.nextLossTimeMs,
|
||||
connection.application.nextLossTimeMs,
|
||||
).minOrNull()?.let { (it - nowForLossTimer).coerceAtLeast(0L) }
|
||||
// RFC 9000 §10.1 idle-timeout deadline. Null when neither
|
||||
// endpoint advertised a non-zero `max_idle_timeout`. We
|
||||
// fold the remaining time into the same `withTimeoutOrNull`
|
||||
// sleep so an idle connection wakes exactly at expiry
|
||||
// instead of polling.
|
||||
val idleTimeoutMs = connection.effectiveIdleTimeoutMs()
|
||||
val idleDelta =
|
||||
idleTimeoutMs?.let {
|
||||
(connection.lastActivityMs + it - nowForLossTimer).coerceAtLeast(0L)
|
||||
}
|
||||
val sleepMillis =
|
||||
if (nextLossDelta != null && nextLossDelta < ptoMillis) nextLossDelta else ptoMillis
|
||||
minOf(
|
||||
ptoMillis,
|
||||
nextLossDelta ?: Long.MAX_VALUE,
|
||||
idleDelta ?: Long.MAX_VALUE,
|
||||
)
|
||||
// Suspend until either: a wakeup arrives, or the timer expires.
|
||||
val woke =
|
||||
withTimeoutOrNull(sleepMillis) {
|
||||
@@ -297,12 +311,20 @@ class QuicConnectionDriver(
|
||||
Unit
|
||||
}
|
||||
if (woke == null) {
|
||||
// Distinguish loss-timer expiry from PTO expiry. A
|
||||
// loss-timer wake just runs `detectAndRemoveLost`
|
||||
// across the levels — newly time-threshold-lost
|
||||
// packets get their tokens re-queued for retransmit on
|
||||
// the next drain. A PTO wake additionally bumps the
|
||||
// consecutive-PTO counter and arms the probe budget.
|
||||
// Distinguish idle-timeout / loss-timer / PTO expiry. An
|
||||
// idle-timeout wake silently closes the connection per
|
||||
// RFC 9000 §10.2.1 ("the connection enters the closing
|
||||
// state silently — discarding the connection state
|
||||
// without sending a CONNECTION_CLOSE frame"). A
|
||||
// loss-timer wake just runs `detectAndRemoveLost`. A
|
||||
// PTO wake additionally bumps the consecutive-PTO
|
||||
// counter and arms the probe budget.
|
||||
if (idleTimeoutMs != null && connection.isIdleTimedOut(nowMillis())) {
|
||||
connection.markClosedExternally(
|
||||
"idle timeout ($idleTimeoutMs ms with no activity)",
|
||||
)
|
||||
continue
|
||||
}
|
||||
val pickedLossTimer = nextLossDelta != null && nextLossDelta < ptoMillis
|
||||
if (pickedLossTimer) {
|
||||
handleLossTimerFired(connection)
|
||||
|
||||
@@ -259,6 +259,9 @@ private fun feedLongHeaderPacket(
|
||||
}
|
||||
|
||||
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
|
||||
// RFC 9000 §10.1.1: any successfully processed inbound packet
|
||||
// resets the idle timer.
|
||||
conn.lastActivityMs = nowMillis
|
||||
|
||||
// The server's source CID becomes our destination CID for subsequent packets.
|
||||
if (level == EncryptionLevel.INITIAL) {
|
||||
@@ -450,6 +453,9 @@ private fun feedShortHeaderPacket(
|
||||
conn.keyUpdateInProgress = false
|
||||
}
|
||||
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
|
||||
// RFC 9000 §10.1.1: any successfully processed inbound packet
|
||||
// resets the idle timer.
|
||||
conn.lastActivityMs = nowMillis
|
||||
if (conn.qlogObserver !== com.vitorpamplona.quic.observability.QlogObserver.NoOp) {
|
||||
conn.qlogObserver.onPacketReceived(
|
||||
level = EncryptionLevel.APPLICATION,
|
||||
|
||||
+12
-2
@@ -544,14 +544,19 @@ private fun buildLongHeaderFromFrames(
|
||||
// map insert below overwrites the prior entry, so the recorded
|
||||
// SentPacket reflects the final padded packet's size — correct
|
||||
// for retransmit purposes.
|
||||
val ackEliciting = isAckEliciting(frames)
|
||||
state.sentPackets[pn] =
|
||||
SentPacket(
|
||||
packetNumber = pn,
|
||||
sentAtMillis = nowMillis,
|
||||
ackEliciting = isAckEliciting(frames),
|
||||
ackEliciting = ackEliciting,
|
||||
sizeBytes = packet.size,
|
||||
tokens = tokens,
|
||||
)
|
||||
// RFC 9000 §10.1.1: an ack-eliciting outbound packet resets the
|
||||
// idle timer. (Non-ack-eliciting packets — pure ACK or PADDING-only
|
||||
// — do NOT reset.)
|
||||
if (ackEliciting) conn.lastActivityMs = nowMillis
|
||||
emitQlogSent(conn, level, pn, packet.size, frames)
|
||||
return packet
|
||||
}
|
||||
@@ -866,14 +871,19 @@ private fun buildApplicationPacket(
|
||||
)
|
||||
}
|
||||
}
|
||||
val ackEliciting = isAckEliciting(frames)
|
||||
state.sentPackets[pn] =
|
||||
SentPacket(
|
||||
packetNumber = pn,
|
||||
sentAtMillis = nowMillis,
|
||||
ackEliciting = isAckEliciting(frames),
|
||||
ackEliciting = ackEliciting,
|
||||
sizeBytes = sizeBytes.getOrNull()?.size ?: 0,
|
||||
tokens = tokens.toList(),
|
||||
)
|
||||
// RFC 9000 §10.1.1: an ack-eliciting outbound packet resets the
|
||||
// idle timer. (Non-ack-eliciting packets — pure ACK or PADDING-only
|
||||
// — do NOT reset.)
|
||||
if (ackEliciting) conn.lastActivityMs = nowMillis
|
||||
sizeBytes.getOrNull()?.let { built ->
|
||||
emitQlogSent(conn, EncryptionLevel.APPLICATION, pn, built.size, frames)
|
||||
}
|
||||
|
||||
@@ -164,6 +164,13 @@ object LongHeaderPacket {
|
||||
val r = QuicReader(bytes, offset)
|
||||
val first = r.readByte()
|
||||
if ((first and 0x80) == 0) return null // not a long header — silently drop
|
||||
// RFC 9000 §17.2: the fixed-bit (0x40) MUST be 1 in a v1 long-header
|
||||
// packet. Packets with 0 here are not valid v1 packets and MUST be
|
||||
// discarded. Version Negotiation (§17.2.1) is the one exception
|
||||
// (its fixed-bit is unconstrained), but VN is detected upstream in
|
||||
// [feedDatagram] before we get here, so any path that reaches
|
||||
// parseAndDecrypt is required to have fixed-bit=1.
|
||||
if ((first and 0x40) == 0) return null
|
||||
val typeBits = (first ushr 4) and 0x03
|
||||
val type = LongHeaderType.fromTypeBits(typeBits)
|
||||
val version = r.readUint32().toInt()
|
||||
|
||||
@@ -130,6 +130,11 @@ object ShortHeaderPacket {
|
||||
if (offset >= bytes.size) return null
|
||||
val first = bytes[offset].toInt() and 0xFF
|
||||
if ((first and 0x80) != 0) return null
|
||||
// RFC 9000 §17.3: short-header fixed-bit (0x40) MUST be 1. Packets
|
||||
// with 0 here are not valid v1 packets and MUST be discarded. The
|
||||
// bit is not header-protected (HP only XORs the low 5 bits), so we
|
||||
// check it on the raw byte before paying for HP unmask + AEAD.
|
||||
if ((first and 0x40) == 0) return null
|
||||
val pnOffset = offset + 1 + dcidLen
|
||||
val sampleStart = pnOffset + 4
|
||||
if (sampleStart + 16 > bytes.size) return null
|
||||
@@ -162,6 +167,9 @@ object ShortHeaderPacket {
|
||||
if (offset >= bytes.size) return null
|
||||
val first = bytes[offset].toInt() and 0xFF
|
||||
if ((first and 0x80) != 0) return null
|
||||
// RFC 9000 §17.3: short-header fixed-bit (0x40) MUST be 1. Packets
|
||||
// with 0 here are not valid v1 packets and MUST be discarded.
|
||||
if ((first and 0x40) == 0) return null
|
||||
val pnOffset = offset + 1 + dcidLen
|
||||
val sampleStart = pnOffset + 4
|
||||
if (sampleStart + 16 > bytes.size) return null
|
||||
|
||||
@@ -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.tls.PermissiveCertificateValidator
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* RFC 9000 §10.1 idle-timeout enforcement.
|
||||
*
|
||||
* - §10.1: effective timeout = min of local + peer advertisements
|
||||
* (skipping any side that advertised 0). Floored at 3 * PTO so loss
|
||||
* recovery has time to fire before the connection times out.
|
||||
* - §10.1.1: idle timer resets on (a) any successfully processed
|
||||
* inbound packet, (b) outbound ack-eliciting packet emission.
|
||||
* - §10.2.1: on expiry the connection enters CLOSED silently — no
|
||||
* CONNECTION_CLOSE frame.
|
||||
*
|
||||
* Pre-fix `max_idle_timeout` was decoded into config + advertised in
|
||||
* the ClientHello transport-params extension, but never enforced — a
|
||||
* black-holed connection lived forever.
|
||||
*/
|
||||
class IdleTimeoutTest {
|
||||
private fun newConnection(
|
||||
nowMillis: () -> Long,
|
||||
localIdleMs: Long,
|
||||
): QuicConnection =
|
||||
QuicConnection(
|
||||
serverName = "example.test",
|
||||
config =
|
||||
QuicConnectionConfig(
|
||||
maxIdleTimeoutMillis = localIdleMs,
|
||||
),
|
||||
tlsCertificateValidator = PermissiveCertificateValidator(),
|
||||
nowMillis = nowMillis,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun effective_timeout_uses_min_of_local_and_peer() {
|
||||
val conn = newConnection(nowMillis = { 0L }, localIdleMs = 30_000L)
|
||||
// No peer params yet → only local advertised.
|
||||
assertEquals(30_000L, conn.effectiveIdleTimeoutMs())
|
||||
|
||||
// Peer advertises smaller — min wins.
|
||||
conn.peerTransportParameters = TransportParameters(maxIdleTimeoutMillis = 10_000L)
|
||||
assertEquals(10_000L, conn.effectiveIdleTimeoutMs())
|
||||
|
||||
// Peer advertises larger — local still smaller, local wins.
|
||||
conn.peerTransportParameters = TransportParameters(maxIdleTimeoutMillis = 60_000L)
|
||||
assertEquals(30_000L, conn.effectiveIdleTimeoutMs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun effective_timeout_skips_zero_advertisements() {
|
||||
// Local 0, peer 30s → peer wins (RFC 9000 §18.2: 0 means disabled).
|
||||
val conn = newConnection(nowMillis = { 0L }, localIdleMs = 0L)
|
||||
conn.peerTransportParameters = TransportParameters(maxIdleTimeoutMillis = 30_000L)
|
||||
assertEquals(30_000L, conn.effectiveIdleTimeoutMs())
|
||||
|
||||
// Both 0 / unset → disabled (null).
|
||||
val conn2 = newConnection(nowMillis = { 0L }, localIdleMs = 0L)
|
||||
assertNull(conn2.effectiveIdleTimeoutMs())
|
||||
|
||||
val conn3 = newConnection(nowMillis = { 0L }, localIdleMs = 0L)
|
||||
conn3.peerTransportParameters = TransportParameters(maxIdleTimeoutMillis = 0L)
|
||||
assertNull(conn3.effectiveIdleTimeoutMs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun effective_timeout_floored_at_three_PTO() {
|
||||
// §10.1: floor = 3 * PTO. Pre-handshake PTO base ≈
|
||||
// smoothed_rtt(100ms) + max(4*rttvar(50ms), 1ms) + max_ack_delay(0)
|
||||
// = 300 ms; 3 * 300 = 900 ms.
|
||||
// A configured 200 ms idle timeout MUST be floored, not honored
|
||||
// verbatim, otherwise a one-RTT loss recovery period would
|
||||
// outlast the connection.
|
||||
val conn = newConnection(nowMillis = { 0L }, localIdleMs = 200L)
|
||||
val effective = conn.effectiveIdleTimeoutMs()
|
||||
assertNotNull(effective)
|
||||
assertTrue(effective >= 900L, "expected floor of 900 ms, got $effective")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isIdleTimedOut_false_at_construction() {
|
||||
val now = mutableLongOf(0L)
|
||||
val conn = newConnection(nowMillis = now::get, localIdleMs = 10_000L)
|
||||
assertFalse(conn.isIdleTimedOut(now.get()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isIdleTimedOut_false_before_deadline() {
|
||||
val now = mutableLongOf(0L)
|
||||
val conn = newConnection(nowMillis = now::get, localIdleMs = 10_000L)
|
||||
// Just under the deadline.
|
||||
now.set(9_999L)
|
||||
assertFalse(conn.isIdleTimedOut(now.get()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isIdleTimedOut_true_at_deadline() {
|
||||
val now = mutableLongOf(0L)
|
||||
val conn = newConnection(nowMillis = now::get, localIdleMs = 10_000L)
|
||||
now.set(10_000L)
|
||||
assertTrue(conn.isIdleTimedOut(now.get()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastActivityMs_bump_postpones_deadline() {
|
||||
val now = mutableLongOf(0L)
|
||||
val conn = newConnection(nowMillis = now::get, localIdleMs = 10_000L)
|
||||
// Halfway through the idle window an inbound packet arrives.
|
||||
now.set(5_000L)
|
||||
conn.lastActivityMs = now.get()
|
||||
// Deadline shifts to 15_000 — still inside at 14_000.
|
||||
now.set(14_000L)
|
||||
assertFalse(conn.isIdleTimedOut(now.get()))
|
||||
// Past the new deadline.
|
||||
now.set(15_000L)
|
||||
assertTrue(conn.isIdleTimedOut(now.get()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isIdleTimedOut_never_when_disabled() {
|
||||
val now = mutableLongOf(0L)
|
||||
val conn = newConnection(nowMillis = now::get, localIdleMs = 0L)
|
||||
// Even at simulated wallclock infinity, no timeout is enforced.
|
||||
now.set(Long.MAX_VALUE / 2)
|
||||
assertFalse(conn.isIdleTimedOut(now.get()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal mutable Long holder for clock injection. Avoids depending on
|
||||
* kotlinx.atomicfu in test sources where atomicity isn't needed (the
|
||||
* clock is bumped from the test thread, read from the same thread).
|
||||
*/
|
||||
private class MutableLong(
|
||||
initial: Long,
|
||||
) {
|
||||
private var value = initial
|
||||
|
||||
fun get(): Long = value
|
||||
|
||||
fun set(v: Long) {
|
||||
value = v
|
||||
}
|
||||
}
|
||||
|
||||
private fun mutableLongOf(initial: Long): MutableLong = MutableLong(initial)
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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.packet
|
||||
|
||||
import com.vitorpamplona.quic.connection.ConnectionId
|
||||
import com.vitorpamplona.quic.crypto.Aes128Gcm
|
||||
import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection
|
||||
import com.vitorpamplona.quic.crypto.InitialSecrets
|
||||
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* RFC 9000 §17.2 (long-header) and §17.3 (short-header): the fixed-bit
|
||||
* (0x40) of the first byte MUST be 1 in every v1 packet. Packets with
|
||||
* fixed-bit=0 MUST be discarded.
|
||||
*
|
||||
* Pre-fix the parsers checked only the form-bit (0x80) and accepted the
|
||||
* packet regardless of fixed-bit. A peer (or off-path attacker) sending a
|
||||
* packet with fixed-bit=0 would have its bytes routed through AEAD —
|
||||
* cheap denial-of-service amplifier and a wire-spec violation we accepted
|
||||
* silently.
|
||||
*
|
||||
* The fixed-bit is NOT header-protected (HP only XORs the low five bits
|
||||
* of the first byte), so we can validate it on the raw wire byte BEFORE
|
||||
* paying for HP unmask + AEAD. Both `parseAndDecrypt` and the
|
||||
* `peekKeyPhase` shortcut do the check.
|
||||
*/
|
||||
class FixedBitValidationTest {
|
||||
private val dcid = ConnectionId("8394c8f03e515708".hexToByteArray())
|
||||
private val scid = ConnectionId("01020304".hexToByteArray())
|
||||
private val proto = InitialSecrets.derive(dcid.bytes)
|
||||
private val hp = AesEcbHeaderProtection(PlatformAesOneBlock)
|
||||
|
||||
@Test
|
||||
fun long_header_fixed_bit_zero_is_discarded() {
|
||||
val plain =
|
||||
LongHeaderPlaintextPacket(
|
||||
type = LongHeaderType.INITIAL,
|
||||
dcid = dcid,
|
||||
scid = scid,
|
||||
packetNumber = 0L,
|
||||
payload = "01".hexToByteArray(),
|
||||
)
|
||||
val wire =
|
||||
LongHeaderPacket.build(
|
||||
plain = plain,
|
||||
aead = Aes128Gcm,
|
||||
key = proto.clientKey,
|
||||
iv = proto.clientIv,
|
||||
hp = hp,
|
||||
hpKey = proto.clientHp,
|
||||
largestAckedInSpace = -1L,
|
||||
)
|
||||
|
||||
// Sanity check: the unmodified wire round-trips.
|
||||
val good =
|
||||
LongHeaderPacket.parseAndDecrypt(
|
||||
bytes = wire,
|
||||
offset = 0,
|
||||
aead = Aes128Gcm,
|
||||
key = proto.clientKey,
|
||||
iv = proto.clientIv,
|
||||
hp = hp,
|
||||
hpKey = proto.clientHp,
|
||||
largestReceivedInSpace = -1L,
|
||||
)
|
||||
assertNotNull(good)
|
||||
|
||||
// Flip fixed-bit (0x40) on the wire's first byte. The bit is NOT
|
||||
// header-protected, so flipping it does not affect HP unmask of
|
||||
// the type / pnLen bits — the packet is structurally valid in
|
||||
// every other way; it just isn't a v1 packet anymore.
|
||||
val tampered = wire.copyOf()
|
||||
tampered[0] = (tampered[0].toInt() xor 0x40).toByte()
|
||||
val parsed =
|
||||
LongHeaderPacket.parseAndDecrypt(
|
||||
bytes = tampered,
|
||||
offset = 0,
|
||||
aead = Aes128Gcm,
|
||||
key = proto.clientKey,
|
||||
iv = proto.clientIv,
|
||||
hp = hp,
|
||||
hpKey = proto.clientHp,
|
||||
largestReceivedInSpace = -1L,
|
||||
)
|
||||
assertNull(parsed, "RFC 9000 §17.2: long-header fixed-bit=0 MUST be discarded")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun short_header_fixed_bit_zero_is_discarded_in_parseAndDecrypt() {
|
||||
val plain =
|
||||
ShortHeaderPlaintextPacket(
|
||||
dcid = dcid,
|
||||
packetNumber = 0L,
|
||||
payload = byteArrayOf(0x01),
|
||||
)
|
||||
val wire =
|
||||
ShortHeaderPacket.build(
|
||||
plain = plain,
|
||||
aead = Aes128Gcm,
|
||||
key = proto.clientKey,
|
||||
iv = proto.clientIv,
|
||||
hp = hp,
|
||||
hpKey = proto.clientHp,
|
||||
largestAckedInSpace = -1L,
|
||||
)
|
||||
|
||||
val good =
|
||||
ShortHeaderPacket.parseAndDecrypt(
|
||||
bytes = wire,
|
||||
offset = 0,
|
||||
dcidLen = dcid.length,
|
||||
aead = Aes128Gcm,
|
||||
key = proto.clientKey,
|
||||
iv = proto.clientIv,
|
||||
hp = hp,
|
||||
hpKey = proto.clientHp,
|
||||
largestReceivedInSpace = -1L,
|
||||
)
|
||||
assertNotNull(good)
|
||||
|
||||
val tampered = wire.copyOf()
|
||||
tampered[0] = (tampered[0].toInt() xor 0x40).toByte()
|
||||
val parsed =
|
||||
ShortHeaderPacket.parseAndDecrypt(
|
||||
bytes = tampered,
|
||||
offset = 0,
|
||||
dcidLen = dcid.length,
|
||||
aead = Aes128Gcm,
|
||||
key = proto.clientKey,
|
||||
iv = proto.clientIv,
|
||||
hp = hp,
|
||||
hpKey = proto.clientHp,
|
||||
largestReceivedInSpace = -1L,
|
||||
)
|
||||
assertNull(parsed, "RFC 9000 §17.3: short-header fixed-bit=0 MUST be discarded")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun short_header_fixed_bit_zero_is_discarded_in_peekKeyPhase() {
|
||||
val plain =
|
||||
ShortHeaderPlaintextPacket(
|
||||
dcid = dcid,
|
||||
packetNumber = 0L,
|
||||
payload = byteArrayOf(0x01),
|
||||
)
|
||||
val wire =
|
||||
ShortHeaderPacket.build(
|
||||
plain = plain,
|
||||
aead = Aes128Gcm,
|
||||
key = proto.clientKey,
|
||||
iv = proto.clientIv,
|
||||
hp = hp,
|
||||
hpKey = proto.clientHp,
|
||||
largestAckedInSpace = -1L,
|
||||
)
|
||||
// peekKeyPhase is the upstream gate the parser uses to pick keys
|
||||
// before AEAD; if it accepts a fixed-bit=0 packet we waste an
|
||||
// AEAD attempt before parseAndDecrypt drops it.
|
||||
val tampered = wire.copyOf()
|
||||
tampered[0] = (tampered[0].toInt() xor 0x40).toByte()
|
||||
val peek =
|
||||
ShortHeaderPacket.peekKeyPhase(
|
||||
bytes = tampered,
|
||||
offset = 0,
|
||||
dcidLen = dcid.length,
|
||||
hp = hp,
|
||||
hpKey = proto.clientHp,
|
||||
)
|
||||
assertNull(peek, "RFC 9000 §17.3: peekKeyPhase MUST reject fixed-bit=0")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user