Merge pull request #2805 from vitorpamplona/claude/quic-followups-round7-9

QUIC: audit follow-ups — RFC 9002 loss timer, AEAD allocation, SETTINGS validation
This commit is contained in:
Vitor Pamplona
2026-05-08 20:27:33 -04:00
committed by GitHub
19 changed files with 522 additions and 66 deletions
@@ -287,7 +287,7 @@ class QuicReader(
}
}
class QuicCodecException(
open class QuicCodecException(
message: String,
cause: Throwable? = null,
) : RuntimeException(message, cause)
@@ -85,6 +85,21 @@ class LevelState {
*/
var largestAckedSentTimeMs: Long? = null
/**
* RFC 9002 §6.1.2 timer-driven loss-detection deadline for this
* encryption level. Absolute monotonic time at which the next
* earliest in-flight sub-largest packet will cross the time
* threshold. The driver's send loop uses
* `min(nextLossTimeMs across levels, ptoDeadline)` as its wakeup
* deadline so tail-loss recovery doesn't wait for the next ACK
* or PTO. Null when no sub-largest packets are in flight (or
* before the first ACK arrives).
*
* Updated by [com.vitorpamplona.quic.connection.QuicConnectionParser]
* each time a fresh ACK runs `detectAndRemoveLost`.
*/
var nextLossTimeMs: Long? = null
/**
* RFC 9001 §4.9: latches true once [discardKeys] runs. Used by
* the writer / parser to short-circuit operations on a discarded
@@ -131,6 +146,7 @@ class LevelState {
sentPackets.clear()
largestAckedPn = null
largestAckedSentTimeMs = null
nextLossTimeMs = null
keysDiscarded = true
}
@@ -163,6 +179,7 @@ class LevelState {
sentPackets.clear()
largestAckedPn = null
largestAckedSentTimeMs = null
nextLossTimeMs = null
keysDiscarded = false
this.sendProtection = sendProtection
this.receiveProtection = receiveProtection
@@ -204,6 +221,7 @@ class LevelState {
sentPackets.clear()
largestAckedPn = null
largestAckedSentTimeMs = null
nextLossTimeMs = null
keysDiscarded = false
this.sendProtection = sendProtection
this.receiveProtection = receiveProtection
@@ -1586,14 +1586,28 @@ class QuicConnection(
streams[id] = stream
streamsList = streamsList + stream
newPeerStreams.addLast(stream)
// Track lifetime peer-stream counts so the writer can emit a
// refreshed MAX_STREAMS_* once the peer's usage approaches the
// currently-advertised cap. Without this, our initial cap of
// [config.initialMaxStreams*] is the lifetime maximum the peer
// can open and any longer broadcast silently truncates.
// Track the peer's own stream-counter, derived from the stream
// id's index field (bits 2..63 hold the per-direction sequence
// — see RFC 9000 §2.1). Pre-fix we incremented on every
// create-event, which double-counted when the peer retransmits
// a STREAM frame on a stream id we've retired AND aged out of
// [retiredStreamIdSet] (FIFO ring of bounded size). The
// phantom-stream guard in the parser drops most retransmits,
// but a sufficiently long broadcast can age the id out of the
// ring and the next retransmit lands here, bumping the
// counter and eventually triggering a spurious MAX_STREAMS_*
// emission. Using max(current, peerIndex + 1) makes the
// counter idempotent: re-creating the same id is a no-op,
// matching the peer's view exactly.
val peerIndexPlusOne = (id ushr 2) + 1L
when (kind) {
StreamId.Kind.SERVER_UNI, StreamId.Kind.CLIENT_UNI -> peerInitiatedUniCount += 1
StreamId.Kind.SERVER_BIDI, StreamId.Kind.CLIENT_BIDI -> peerInitiatedBidiCount += 1
StreamId.Kind.SERVER_UNI, StreamId.Kind.CLIENT_UNI -> {
peerInitiatedUniCount = maxOf(peerInitiatedUniCount, peerIndexPlusOne)
}
StreamId.Kind.SERVER_BIDI, StreamId.Kind.CLIENT_BIDI -> {
peerInitiatedBidiCount = maxOf(peerInitiatedBidiCount, peerIndexPlusOne)
}
}
// Wake any awaitIncomingPeerStream caller. trySend on a CONFLATED
// channel can never fail in steady state.
@@ -2183,10 +2197,16 @@ class QuicConnection(
)
return
}
// Sequence 0: peer wants us off our initial SCID, but we
// haven't issued any replacements. Close — see kdoc above.
// Sequence 0: peer wants us off our initial SCID, but we never
// advertised additional SCIDs (we don't issue NEW_CONNECTION_ID
// frames yet). RFC 9000 §19.16 lets us close — and we MUST,
// because there's no replacement SCID to switch to. Reclassify
// as PROTOCOL_VIOLATION (peer-side fault) rather than
// INTERNAL_ERROR (our-side fault) — the original diagnostic
// misdescribed which side made the mistake.
markClosedExternally(
"INTERNAL_ERROR: peer asked to retire our initial SCID but we have no replacement to offer",
"PROTOCOL_VIOLATION: peer asked to retire our initial SCID (seq=0) but we never " +
"advertised additional SCIDs to switch to",
)
}
@@ -268,18 +268,78 @@ class QuicConnectionDriver(
.coerceAtLeast(1L)
val backoff = (1L shl connection.consecutivePtoCount.coerceAtMost(6))
val ptoMillis = (ptoBaseMs * backoff).coerceAtMost(60_000L)
// Suspend until either: a wakeup arrives, or the PTO timer expires.
// RFC 9002 §6.1.2 timer-driven loss detection: take the
// earliest of (PTO deadline, next-loss-time across levels).
// Without this, tail loss waits for the PTO instead of the
// shorter `9/8 * max_rtt` time threshold — visible to the
// user as a recovery delay equal to the PTO minus that
// threshold (often ~5x worse than necessary on lossy
// links). A null nextLossTime contributes nothing.
val nowForLossTimer = nowMillis()
val nextLossDelta =
listOfNotNull(
connection.initial.nextLossTimeMs,
connection.handshake.nextLossTimeMs,
connection.application.nextLossTimeMs,
).minOrNull()?.let { (it - nowForLossTimer).coerceAtLeast(0L) }
val sleepMillis =
if (nextLossDelta != null && nextLossDelta < ptoMillis) nextLossDelta else ptoMillis
// Suspend until either: a wakeup arrives, or the timer expires.
val woke =
withTimeoutOrNull(ptoMillis) {
withTimeoutOrNull(sleepMillis) {
sendWakeup.receive()
Unit
}
if (woke == null) {
handlePtoFired(connection)
// 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.
val pickedLossTimer = nextLossDelta != null && nextLossDelta < ptoMillis
if (pickedLossTimer) {
handleLossTimerFired(connection)
} else {
handlePtoFired(connection)
}
}
}
}
/**
* RFC 9002 §6.1.2 timer-driven loss detection. Re-runs
* `detectAndRemoveLost` across each encryption level; any newly
* time-threshold-lost packets dispatch their tokens to the
* pending retransmit queues, and the next drain emits them.
* Cheaper than [handlePtoFired] because no probe budget /
* exponential backoff is needed — the peer ACKs that fix the
* loss state arrive on their normal cadence.
*/
private fun handleLossTimerFired(conn: QuicConnection) {
val nowMs = conn.nowMillis()
runLossDetectionForLevel(conn, conn.initial, EncryptionLevel.INITIAL, nowMs)
runLossDetectionForLevel(conn, conn.handshake, EncryptionLevel.HANDSHAKE, nowMs)
runLossDetectionForLevel(conn, conn.application, EncryptionLevel.APPLICATION, nowMs)
}
private fun runLossDetectionForLevel(
conn: QuicConnection,
state: LevelState,
level: EncryptionLevel,
nowMs: Long,
) {
val largest = state.largestAckedPn ?: return
val result = conn.lossDetection.detectAndRemoveLost(state.sentPackets, largest, nowMs)
for (lostPacket in result.lost) {
conn.onTokensLost(lostPacket.tokens)
}
if (result.lost.isNotEmpty()) {
conn.qlogObserver.onLossDetected(level, result.lost.map { it.packetNumber })
}
state.nextLossTimeMs = result.nextLossTimeMs
}
/**
* Cleanly tear down the driver. Runs on [parentScope] so the caller (which
* may itself live inside the driver's [scope]) isn't cancelled before its
@@ -549,7 +549,7 @@ private fun dispatchFrames(
conn.consecutivePtoCount = 0
}
state.largestAckedPn?.let { largestAckedPn ->
val lost =
val result =
conn.lossDetection.detectAndRemoveLost(
sentPackets = state.sentPackets,
largestAckedPn = largestAckedPn,
@@ -558,12 +558,18 @@ private fun dispatchFrames(
// Step 6: dispatch each lost packet's tokens to the
// matching pending* field. The supersede check (lost
// value still == advertised) lives inside onTokensLost.
for (lostPacket in lost) {
for (lostPacket in result.lost) {
conn.onTokensLost(lostPacket.tokens)
}
if (lost.isNotEmpty()) {
conn.qlogObserver.onLossDetected(level, lost.map { it.packetNumber })
if (result.lost.isNotEmpty()) {
conn.qlogObserver.onLossDetected(level, result.lost.map { it.packetNumber })
}
// RFC 9002 §6.1.2 timer-driven loss detection. Record
// the earliest pending time-threshold deadline so the
// driver's send loop can wake at that instant rather
// than waiting for the next inbound ACK or PTO. Set
// null when no sub-largest packets remain in flight.
state.nextLossTimeMs = result.nextLossTimeMs
}
}
@@ -712,7 +712,16 @@ private fun buildApplicationPacket(
// The combination drops drainOutbound's per-call cost from
// O(N log N) where N=total-streams to roughly O(active) under
// realistic loads.
val active = streamsView.filter { !it.isClosed }
// Allocation hot-path: under realistic load most streams are
// open, so the `filter` allocates an N-sized ArrayList only to
// hand back the same N elements. Quick `any` scan first; only
// pay for the filter list when at least one stream is closed.
val active =
if (streamsView.any { it.isClosed }) {
streamsView.filter { !it.isClosed }
} else {
streamsView
}
val sorted =
when {
active.size <= 1 -> active
@@ -134,18 +134,30 @@ class QuicLossDetection {
* [largestAckedPn], OR
* - it was sent more than [lossDelayMs] ago.
*
* Returns the list of lost packets in arbitrary order. Caller
* dispatches their [SentPacket.tokens] (step 6).
* Returns the list of lost packets PLUS the absolute monotonic
* time at which the next time-threshold loss will fire if any
* surviving in-flight packet predates [largestAckedPn]. The
* caller schedules a timer at that instant so tail-loss recovery
* doesn't have to wait for the PTO. RFC 9002 §6.1.2 (loss-
* detection timer): without this, a quiet link's last few packets
* sit in `sentPackets` until either the PTO fires or fresh ACKs
* arrive. Returning [Result.nextLossTimeMs] = null means there's
* nothing left for the time-threshold to fire on.
*/
fun detectAndRemoveLost(
sentPackets: MutableMap<Long, SentPacket>,
largestAckedPn: Long,
nowMs: Long,
): List<SentPacket> {
if (sentPackets.isEmpty()) return emptyList()
): Result {
if (sentPackets.isEmpty()) return Result.EMPTY
val lossDelay = lossDelayMs()
val lossThresholdSentMs = nowMs - lossDelay
val lost = mutableListOf<SentPacket>()
// Track the EARLIEST sentAtMillis among surviving sub-largest
// packets. The next loss-detection timer fires at
// `earliestSent + lossDelay` — the earliest moment any of
// them crosses the time threshold.
var earliestSentSubLargestMs = Long.MAX_VALUE
val it = sentPackets.entries.iterator()
while (it.hasNext()) {
val (pn, pkt) = it.next()
@@ -155,9 +167,29 @@ class QuicLossDetection {
if (packetThresholdLost || timeThresholdLost) {
lost += pkt
it.remove()
} else if (pkt.sentAtMillis < earliestSentSubLargestMs) {
earliestSentSubLargestMs = pkt.sentAtMillis
}
}
return lost
val nextLossTimeMs =
if (earliestSentSubLargestMs == Long.MAX_VALUE) null else earliestSentSubLargestMs + lossDelay
return Result(lost, nextLossTimeMs)
}
/**
* Outcome of [detectAndRemoveLost]. [lost] is the list of packets
* declared lost on this call (for retransmit dispatch);
* [nextLossTimeMs] is the absolute monotonic time at which the
* next earliest surviving sub-largest packet will cross the
* time threshold, or null if none remain.
*/
data class Result(
val lost: List<SentPacket>,
val nextLossTimeMs: Long?,
) {
companion object {
internal val EMPTY = Result(emptyList(), null)
}
}
/**
@@ -52,6 +52,64 @@ abstract class Aead {
aad: ByteArray,
ciphertext: ByteArray,
): ByteArray?
/**
* Range-based [seal] same semantics as [seal] but reads `aad` and
* `plaintext` from sub-ranges of larger backing arrays. Default impl
* slices and delegates; concrete impls (notably the JCA-backed
* [com.vitorpamplona.quic.crypto.JcaAesGcmAead]) override to skip
* the slice allocations entirely by passing the offsets through to
* `Cipher.updateAAD` / `Cipher.doFinal`.
*
* Saves ~2 ByteArray allocations per outbound packet on the hot
* path (the header `aad` and the payload `plaintext` no longer
* need to be carved out of the in-progress packet buffer).
*/
open fun sealRange(
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
aadOffset: Int,
aadLength: Int,
plaintext: ByteArray,
plaintextOffset: Int,
plaintextLength: Int,
): ByteArray {
val a =
if (aadOffset == 0 && aadLength == aad.size) aad else aad.copyOfRange(aadOffset, aadOffset + aadLength)
val p =
if (plaintextOffset == 0 && plaintextLength == plaintext.size) {
plaintext
} else {
plaintext.copyOfRange(plaintextOffset, plaintextOffset + plaintextLength)
}
return seal(key, nonce, a, p)
}
/**
* Range-based [open] same semantics as [open] but reads `aad` and
* `ciphertext` from sub-ranges. Default impl slices and delegates.
*/
open fun openRange(
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
aadOffset: Int,
aadLength: Int,
ciphertext: ByteArray,
ciphertextOffset: Int,
ciphertextLength: Int,
): ByteArray? {
val a =
if (aadOffset == 0 && aadLength == aad.size) aad else aad.copyOfRange(aadOffset, aadOffset + aadLength)
val c =
if (ciphertextOffset == 0 && ciphertextLength == ciphertext.size) {
ciphertext
} else {
ciphertext.copyOfRange(ciphertextOffset, ciphertextOffset + ciphertextLength)
}
return open(key, nonce, a, c)
}
}
/** AES-128-GCM AEAD via Quartz's AESGCM (which uses JCA underneath on JVM/Android). */
@@ -223,10 +223,21 @@ object LongHeaderPacket {
)
val aadEnd = localPnOffset + pnLen
val aad = packet.copyOfRange(0, aadEnd)
val ciphertext = packet.copyOfRange(aadEnd, packet.size)
val nonce = aeadNonce(iv, fullPn)
val plaintext = aead.open(key, nonce, aad, ciphertext) ?: return null
// Range-based open avoids two ByteArray slice allocations per
// inbound packet — see [ShortHeaderPacket.parseAndDecrypt] for
// rationale.
val plaintext =
aead.openRange(
key = key,
nonce = nonce,
aad = packet,
aadOffset = 0,
aadLength = aadEnd,
ciphertext = packet,
ciphertextOffset = aadEnd,
ciphertextLength = packet.size - aadEnd,
) ?: return null
return ParseResult(
packet =
@@ -182,10 +182,22 @@ object ShortHeaderPacket {
}
val fullPn = PacketNumberSpaceState.decodePacketNumber(largestReceivedInSpace, truncatedPn, pnLen)
val aadEnd = localPnOffset + pnLen
val aad = packet.copyOfRange(0, aadEnd)
val ciphertext = packet.copyOfRange(aadEnd, packet.size)
val nonce = aeadNonce(iv, fullPn)
val plaintext = aead.open(key, nonce, aad, ciphertext) ?: return null
// Range-based open: aad = packet[0..aadEnd), ciphertext = packet[aadEnd..size).
// Saves the two ByteArray slice allocations that the
// whole-array form (`aad = copyOfRange(0, aadEnd)` etc.)
// would do — ~2 KB per inbound packet on the hot path.
val plaintext =
aead.openRange(
key = key,
nonce = nonce,
aad = packet,
aadOffset = 0,
aadLength = aadEnd,
ciphertext = packet,
ciphertextOffset = aadEnd,
ciphertextLength = packet.size - aadEnd,
) ?: return null
return ParseResult(
packet =
ShortHeaderPlaintextPacket(
@@ -54,6 +54,13 @@ class QpackEncoder {
name: String,
value: String,
) {
// RFC 9204 §4.5.4 / §7.1: sensitive headers (credentials and
// session cookies) MUST be marked never-indexed (N=1) so an
// intermediate cache cannot retain them. Indexed-field-line
// form (no literal value) doesn't need this since no value
// appears literally on the wire.
val sensitive = isSensitive(name)
val pairIdx = QpackStaticTable.pairToIndex[name to value]
if (pairIdx != null) {
// Indexed field line, static. Pattern: 1|T(=1)|index(6-bit prefix)
@@ -62,20 +69,38 @@ class QpackEncoder {
}
val nameIdx = QpackStaticTable.nameToIndex[name]
if (nameIdx != null) {
// Literal Field Line With Name Reference, static. Pattern: 0|1|N(=0)|T(=1)|index(4-bit prefix)
QpackInteger.encode(nameIdx.toLong(), 4, 0x50, w)
// Literal Field Line With Name Reference, static. Pattern:
// 0|1|N|T(=1)|index(4-bit prefix)
// N=1 for sensitive headers per RFC 9204 §4.5.4.
val firstByte = if (sensitive) 0x70 else 0x50
QpackInteger.encode(nameIdx.toLong(), 4, firstByte, w)
// Then value: H=0|len(7-bit prefix)
val valueBytes = value.encodeToByteArray()
QpackInteger.encode(valueBytes.size.toLong(), 7, 0x00, w)
w.writeBytes(valueBytes)
return
}
// Literal field line with literal name. Pattern: 0|0|1|N(=0)|H(=0)|len(3-bit prefix)
// Literal field line with literal name. Pattern:
// 0|0|1|N|H(=0)|len(3-bit prefix)
// N=1 for sensitive headers.
val firstByte = if (sensitive) 0x30 else 0x20
val nameBytes = name.encodeToByteArray()
QpackInteger.encode(nameBytes.size.toLong(), 3, 0x20, w)
QpackInteger.encode(nameBytes.size.toLong(), 3, firstByte, w)
w.writeBytes(nameBytes)
val valueBytes = value.encodeToByteArray()
QpackInteger.encode(valueBytes.size.toLong(), 7, 0x00, w)
w.writeBytes(valueBytes)
}
/**
* Headers whose values are credentials or session-bound and MUST
* NOT be cached by an intermediary per RFC 9204 §4.5.4. Names are
* compared after the encoder's lower-case normalization, so we
* test against lower-case forms here.
*/
private fun isSensitive(name: String): Boolean =
when (name) {
"authorization", "proxy-authorization", "cookie", "set-cookie" -> true
else -> false
}
}
@@ -77,6 +77,31 @@ class QuicStream(
* rather than silently dropping bytes. Pre-audit-4 the failed `trySend`
* was discarded, leaving a hole in the stream that the application could
* never know about.
*
* **Single-collector contract.** [consumeAsFlow] cancels the underlying
* [Channel] when its collector terminates (either by exception or
* explicit cancellation). After that point any subsequent
* `trySend` from the parser fails, sets [overflowed] = true, and
* the parser tears the whole connection down with
* `INTERNAL_ERROR: stream consumer overflowed`. So:
* - Application code MUST collect [incoming] **at most once** per
* stream and MUST hold the collect open until the stream
* terminates (FIN, peer RESET_STREAM, or local
* [stopSending] / [resetStream] decision).
* - Cancelling the collector early e.g. wrapping in
* `withTimeout(...)` is equivalent to telling the connection
* to drop. If the application wants to stop receiving without
* killing the connection, call [stopSending] FIRST, then let
* the parser's RESET_STREAM-style teardown drain the channel
* cleanly.
*
* This is a fragile coupling we accept for now because (a) every
* production caller already follows the single-collector pattern,
* and (b) widening the contract would require swapping
* `consumeAsFlow` for a `MutableSharedFlow` with replay/buffer
* semantics, which has its own back-pressure pitfalls. The
* comment is here so a future refactor doesn't accidentally
* loosen the contract.
*/
private val incomingChannel = Channel<ByteArray>(capacity = 64)
val incoming: Flow<ByteArray> get() = incomingChannel.consumeAsFlow()
@@ -358,15 +358,32 @@ class TlsClient(
val pskExt = sh.extensions.firstOrNull { it.type == TlsConstants.EXT_PRE_SHARED_KEY }
if (resumption != null) {
if (pskExt == null) {
// We offered PSK but server picked full-handshake.
// Plumbing for the fallback path (clear early secret,
// re-run binder-less ClientHello transcript) is real
// work; for now hard-fail. In production we'd want
// to handle gracefully — for the runner's resumption
// testcase the server MUST accept or the test fails
// anyway, so this gate isn't load-bearing.
throw QuicCodecException(
"server rejected PSK; full-handshake fallback not implemented",
// We offered PSK but the server picked full-
// handshake. RFC 8446 §4.2.11 lets the server
// do this freely (rate limit, ticket aged out,
// policy mismatch). Recovering in-place
// requires:
// 1. Discarding the early secret derived
// from the resumption PSK.
// 2. Rebuilding the transcript without the
// `pre_shared_key` extension or its
// binder bytes.
// 3. Replaying the post-ClientHello derivation
// with the new transcript.
// Each step is touchy enough that a
// subtly-wrong transcript hash would land us on
// a successful handshake with wrong keys, which
// is harder to debug than a hard failure. We
// surface a typed [PskRejectedException] so
// application-layer reconnect logic can drop
// the cached resumption state and retry from
// scratch — that path is correct by
// construction (fresh ClientHello, no PSK
// history). Production callers wrapping this
// client SHOULD catch [PskRejectedException]
// and retry without [resumption].
throw PskRejectedException(
"server rejected PSK; application must retry without resumption",
)
}
val r = QuicReader(pskExt.data)
@@ -588,6 +605,22 @@ class TlsClient(
}
}
/**
* Thrown when the server returned a ServerHello without a `pre_shared_key`
* extension despite the client offering one in [TlsClient.resumption].
* The handshake cannot proceed in-place because the early secret + transcript
* were already shaped around the PSK; application-layer reconnect logic
* should catch this, drop the cached [TlsResumptionState], and retry the
* handshake from scratch. See the call site in
* [TlsClient.handleHandshakeMessage] for the full rationale.
*
* Distinct subclass of [QuicCodecException] so callers can selectively
* catch the recoverable case without swallowing genuine protocol errors.
*/
class PskRejectedException(
message: String,
) : QuicCodecException(message)
/** Callback interface so the QUIC layer can react to TLS-derived secrets. */
interface TlsSecretsListener {
fun onHandshakeKeysReady(
@@ -24,6 +24,7 @@ import com.vitorpamplona.quic.Varint
import com.vitorpamplona.quic.http3.Http3Frame
import com.vitorpamplona.quic.http3.Http3FrameReader
import com.vitorpamplona.quic.http3.Http3Settings
import com.vitorpamplona.quic.http3.Http3SettingsId
import com.vitorpamplona.quic.http3.Http3StreamType
import com.vitorpamplona.quic.stream.QuicStream
import com.vitorpamplona.quic.stream.StreamId
@@ -288,6 +289,28 @@ class WtPeerStreamDemux(
val frame = reader.next() ?: return
when (frame) {
is Http3Frame.Settings -> {
// draft-ietf-webtrans-http3 §3 + RFC 8441: a server that
// accepts WebTransport MUST advertise both
// ENABLE_WEBTRANSPORT=1 and ENABLE_CONNECT_PROTOCOL=1.
// If either is missing/zero, the WT session can't
// proceed — surface as an H3 protocol error so
// [drainControlStream]'s catch records it on
// [peerH3ProtocolError] and the application closes
// the connection instead of issuing an Extended
// CONNECT the server will reject anyway.
val s = frame.settings.settings
val enableWt = s[Http3SettingsId.ENABLE_WEBTRANSPORT] ?: 0L
val enableConnect = s[Http3SettingsId.ENABLE_CONNECT_PROTOCOL] ?: 0L
if (enableWt != 1L) {
throw com.vitorpamplona.quic.QuicCodecException(
"peer SETTINGS missing ENABLE_WEBTRANSPORT=1 (got $enableWt)",
)
}
if (enableConnect != 1L) {
throw com.vitorpamplona.quic.QuicCodecException(
"peer SETTINGS missing ENABLE_CONNECT_PROTOCOL=1 (got $enableConnect)",
)
}
peerSettings = frame.settings
}
@@ -91,13 +91,13 @@ class RetransmitIntegrationTest {
client.application.largestAckedPn = futurePn
client.application.largestAckedSentTimeMs = 1L
// 4. Run loss detection — msuPn is < futurePn - 3 ⇒ lost.
val lost =
val result =
client.lossDetection.detectAndRemoveLost(
sentPackets = client.application.sentPackets,
largestAckedPn = futurePn,
nowMs = 2L,
)
val lostMsuPacket = lost.firstOrNull { it.packetNumber == msuPn }
val lostMsuPacket = result.lost.firstOrNull { it.packetNumber == msuPn }
assertNotNull(lostMsuPacket, "msuPn=$msuPn must be declared lost (largestAckedPn=$futurePn, threshold=3)")
// 5. Dispatch lost tokens — pendingMaxStreamsUni gets set.
client.onTokensLost(lostMsuPacket.tokens)
@@ -113,10 +113,10 @@ class QuicLossDetectionTest {
// (Pn 10 is the largest-acked itself — not in flight; it was just removed by drain.
// We model "after-drain" by removing 10 from sent before calling detectAndRemoveLost.)
sent.remove(10L)
val lost = ld.detectAndRemoveLost(sent, largestAckedPn = 10L, nowMs = 1L)
val result = ld.detectAndRemoveLost(sent, largestAckedPn = 10L, nowMs = 1L)
// sentAt=0, nowMs=1, lossDelayMs=374 → time threshold cutoff at -373: nothing lost by time threshold
// (sentAt 0 is NOT <= -373). But packet threshold removes pns 0..6.
assertEquals(setOf(0L, 1L, 2L, 3L, 4L, 5L, 6L), lost.map { it.packetNumber }.toSet())
assertEquals(setOf(0L, 1L, 2L, 3L, 4L, 5L, 6L), result.lost.map { it.packetNumber }.toSet())
assertEquals(setOf(7L, 8L, 9L), sent.keys, "pns 7..9 remain in flight; pn 10 was drained earlier")
}
@@ -132,8 +132,8 @@ class QuicLossDetectionTest {
sent[1L] = sentPacket(1L, sentAt = 195L) // recent: 195 + 11 = 206, now=200 ⇒ not lost
// pn 2 is the largest-acked, drained earlier.
val lost = ld.detectAndRemoveLost(sent, largestAckedPn = 2L, nowMs = 200L)
assertEquals(listOf(0L), lost.map { it.packetNumber })
val result = ld.detectAndRemoveLost(sent, largestAckedPn = 2L, nowMs = 200L)
assertEquals(listOf(0L), result.lost.map { it.packetNumber })
assertEquals(setOf(1L), sent.keys)
}
@@ -145,8 +145,8 @@ class QuicLossDetectionTest {
val ld = QuicLossDetection()
val sent = mutableMapOf<Long, SentPacket>()
sent[5L] = sentPacket(5L, sentAt = 0L)
val lost = ld.detectAndRemoveLost(sent, largestAckedPn = 5L, nowMs = 1L)
assertTrue(lost.isEmpty())
val result = ld.detectAndRemoveLost(sent, largestAckedPn = 5L, nowMs = 1L)
assertTrue(result.lost.isEmpty())
assertNotNull(sent[5L])
}
@@ -154,8 +154,9 @@ class QuicLossDetectionTest {
fun emptyMap_returnsEmpty() {
val ld = QuicLossDetection()
val sent = mutableMapOf<Long, SentPacket>()
val lost = ld.detectAndRemoveLost(sent, largestAckedPn = 100L, nowMs = 1L)
assertTrue(lost.isEmpty())
val result = ld.detectAndRemoveLost(sent, largestAckedPn = 100L, nowMs = 1L)
assertTrue(result.lost.isEmpty())
assertEquals(null, result.nextLossTimeMs)
}
@Test
@@ -175,11 +176,16 @@ class QuicLossDetectionTest {
),
)
// PN 0 is below largestAckedPn=10 - threshold(3) = 7, so lost by packet threshold.
val lost = ld.detectAndRemoveLost(sent, largestAckedPn = 10L, nowMs = 1L)
assertEquals(1, lost.size)
assertEquals(2, lost.single().tokens.size)
assertEquals(RecoveryToken.MaxStreamsUni(150L), lost.single().tokens[0])
assertEquals(RecoveryToken.MaxData(5_000L), lost.single().tokens[1])
val result = ld.detectAndRemoveLost(sent, largestAckedPn = 10L, nowMs = 1L)
assertEquals(1, result.lost.size)
assertEquals(
2,
result.lost
.single()
.tokens.size,
)
assertEquals(RecoveryToken.MaxStreamsUni(150L), result.lost.single().tokens[0])
assertEquals(RecoveryToken.MaxData(5_000L), result.lost.single().tokens[1])
assertNull(sent[0L])
}
@@ -192,8 +198,8 @@ class QuicLossDetectionTest {
ld.onRttSample(largestAckedSentTimeMs = 0L, ackDelayMs = 0L, nowMs = 10L)
val sent = mutableMapOf<Long, SentPacket>()
sent[0L] = sentPacket(0L, sentAt = 0L) // old AND below threshold
val lost = ld.detectAndRemoveLost(sent, largestAckedPn = 10L, nowMs = 200L)
assertEquals(1, lost.size)
val result = ld.detectAndRemoveLost(sent, largestAckedPn = 10L, nowMs = 200L)
assertEquals(1, result.lost.size)
assertTrue(sent.isEmpty())
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.quic.webtransport
import com.vitorpamplona.quic.QuicWriter
import com.vitorpamplona.quic.http3.Http3FrameType
import com.vitorpamplona.quic.http3.Http3Settings
import com.vitorpamplona.quic.http3.Http3SettingsId
import com.vitorpamplona.quic.http3.Http3StreamType
import com.vitorpamplona.quic.stream.QuicStream
import kotlinx.coroutines.CoroutineScope
@@ -68,7 +69,19 @@ class WtPeerStreamDemuxTest {
// 2. SETTINGS frame with a single QPACK_MAX_TABLE_CAPACITY=0.
// 3. GOAWAY frame with stream id 12.
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
val settingsFrame = Http3Settings(emptyMap()).encodeFrame()
// Realistic server SETTINGS — both ENABLE_CONNECT_PROTOCOL=1
// and ENABLE_WEBTRANSPORT=1 are required for the demux to
// accept the SETTINGS frame (draft-ietf-webtrans-http3 §3 +
// RFC 8441). Pre-validation tests used emptyMap() as a
// stand-in; with the validation in place we mirror what a
// real WT-capable server sends.
val settingsFrame =
Http3Settings(
mapOf(
Http3SettingsId.ENABLE_CONNECT_PROTOCOL to 1L,
Http3SettingsId.ENABLE_WEBTRANSPORT to 1L,
),
).encodeFrame()
val goawayBody = QuicWriter().also { it.writeVarint(12L) }.toByteArray()
val goawayFrame =
QuicWriter()
@@ -113,7 +126,19 @@ class WtPeerStreamDemuxTest {
demux.process(stream)
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
val settingsFrame = Http3Settings(emptyMap()).encodeFrame()
// Realistic server SETTINGS — both ENABLE_CONNECT_PROTOCOL=1
// and ENABLE_WEBTRANSPORT=1 are required for the demux to
// accept the SETTINGS frame (draft-ietf-webtrans-http3 §3 +
// RFC 8441). Pre-validation tests used emptyMap() as a
// stand-in; with the validation in place we mirror what a
// real WT-capable server sends.
val settingsFrame =
Http3Settings(
mapOf(
Http3SettingsId.ENABLE_CONNECT_PROTOCOL to 1L,
Http3SettingsId.ENABLE_WEBTRANSPORT to 1L,
),
).encodeFrame()
// First GOAWAY = 8.
val ga1Body = QuicWriter().also { it.writeVarint(8L) }.toByteArray()
val ga1 =
@@ -166,7 +191,19 @@ class WtPeerStreamDemuxTest {
demux.process(stream)
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
val settingsFrame = Http3Settings(emptyMap()).encodeFrame()
// Realistic server SETTINGS — both ENABLE_CONNECT_PROTOCOL=1
// and ENABLE_WEBTRANSPORT=1 are required for the demux to
// accept the SETTINGS frame (draft-ietf-webtrans-http3 §3 +
// RFC 8441). Pre-validation tests used emptyMap() as a
// stand-in; with the validation in place we mirror what a
// real WT-capable server sends.
val settingsFrame =
Http3Settings(
mapOf(
Http3SettingsId.ENABLE_CONNECT_PROTOCOL to 1L,
Http3SettingsId.ENABLE_WEBTRANSPORT to 1L,
),
).encodeFrame()
stream.deliverIncoming(typePrefix)
stream.deliverIncoming(settingsFrame)
stream.closeIncoming()
@@ -138,6 +138,74 @@ class JcaAesGcmAead(
}
}
/**
* JCA-native range overload: `Cipher.updateAAD(byte[], offset, len)`
* and `Cipher.doFinal(byte[], inputOffset, inputLen)` accept
* sub-ranges directly, so we skip the two `copyOfRange` calls the
* default impl would perform. Saves ~2 KB allocation per inbound
* packet on the audio-rooms hot path (one for `aad`, one for
* `ciphertext`, both sliced from a packet buffer that the caller
* already owns).
*/
override fun openRange(
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
aadOffset: Int,
aadLength: Int,
ciphertext: ByteArray,
ciphertextOffset: Int,
ciphertextLength: Int,
): ByteArray? =
synchronized(this) {
try {
decryptCipher.init(Cipher.DECRYPT_MODE, keySpec, GCMParameterSpec(128, nonce))
decryptCipher.updateAAD(aad, aadOffset, aadLength)
decryptCipher.doFinal(ciphertext, ciphertextOffset, ciphertextLength)
} catch (_: GeneralSecurityException) {
null
}
}
/**
* JCA-native range overload for [seal]. Same nonce-reuse history +
* fresh-Cipher fallback as the whole-array [seal] path; the only
* difference is the offset+length pass-through to `updateAAD` /
* `doFinal`. Saves the slice allocations on the outbound hot path.
*/
override fun sealRange(
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
aadOffset: Int,
aadLength: Int,
plaintext: ByteArray,
plaintextOffset: Int,
plaintextLength: Int,
): ByteArray =
synchronized(this) {
val reuse = recentEncryptNonces.any { it.contentEquals(nonce) }
if (reuse) {
val fresh = Cipher.getInstance("AES/GCM/NoPadding")
fresh.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(128, nonce))
fresh.updateAAD(aad, aadOffset, aadLength)
fresh.doFinal(plaintext, plaintextOffset, plaintextLength)
} else {
try {
encryptCipher.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(128, nonce))
encryptCipher.updateAAD(aad, aadOffset, aadLength)
val out = encryptCipher.doFinal(plaintext, plaintextOffset, plaintextLength)
rememberEncryptNonce(nonce)
out
} catch (t: Throwable) {
if (recentEncryptNonces.lastOrNull()?.contentEquals(nonce) == true) {
recentEncryptNonces.removeLast()
}
throw t
}
}
}
private companion object {
private const val NONCE_HISTORY_LIMIT = 8
}
@@ -248,9 +248,22 @@ class JdkCertificateValidator(
val prefix = lhost.substring(0, lhost.length - suffix.length)
if (prefix.isEmpty() || '.' in prefix) return false
// RFC 6125 §6.4.3 — disallow wildcards in the public-suffix label.
// Heuristic: require ≥ 2 dots in the suffix (e.g. *.example.com is OK,
// *.com is not). Conservative; doesn't consult the actual PSL but
// matches what most browsers do for non-PSL-aware certs.
//
// KNOWN GAP: this heuristic counts dots in the suffix (≥ 2 → OK)
// and DOES NOT consult the actual public-suffix list. So it
// accepts `*.co.uk`, `*.github.io`, `*.s3.amazonaws.com`, etc.
// — multi-tenant TLDs whose effective TLD is multi-label. A
// CA that mis-issues such a wildcard could impersonate any
// co-tenant. The mitigation is partial: the WebPKI ecosystem
// already requires CAs to consult the PSL when issuing, so a
// rogue cert is unlikely to make it past CT logging — but if
// one does, our validation accepts it.
//
// Full fix requires shipping PSL data; deferred until the cost
// is justified by a higher-risk deployment. Production callers
// should rely on the OS / NetworkSecurityConfig pinning layer
// for sensitive endpoints rather than QUIC's built-in
// hostname check alone.
val suffixDots = suffix.count { it == '.' }
return suffixDots >= 2
}