Merge pull request #2806 from vitorpamplona/claude/quic-followups-round10-13

QUIC: audit closeout — TLS PSK fallback, AEAD allocation, PSL subset, ECN
This commit is contained in:
Vitor Pamplona
2026-05-08 21:36:33 -04:00
committed by GitHub
16 changed files with 895 additions and 155 deletions
@@ -287,7 +287,7 @@ class QuicReader(
} }
} }
open class QuicCodecException( class QuicCodecException(
message: String, message: String,
cause: Throwable? = null, cause: Throwable? = null,
) : RuntimeException(message, cause) ) : RuntimeException(message, cause)
@@ -73,9 +73,18 @@ fun packetProtectionFromSecret(
// which avoids `Cipher.getInstance` per-packet (audit-1, audit-3 finding). // which avoids `Cipher.getInstance` per-packet (audit-1, audit-3 finding).
val aead: Aead = val aead: Aead =
when (cipherSuite) { when (cipherSuite) {
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 -> bestAes128GcmAead(keys.key) TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 -> {
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256 -> com.vitorpamplona.quic.crypto.ChaCha20Poly1305Aead bestAes128GcmAead(keys.key)
else -> error("unreachable") }
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256 -> {
com.vitorpamplona.quic.crypto
.bestChaCha20Poly1305Aead(keys.key)
}
else -> {
error("unreachable")
}
} }
return PacketProtection(aead, keys.key, keys.iv, hp, keys.hp) return PacketProtection(aead, keys.key, keys.iv, hp, keys.hp)
} }
@@ -253,6 +253,18 @@ class QuicConnection(
@Volatile @Volatile
internal var previousReceiveProtection: PacketProtection? = null internal var previousReceiveProtection: PacketProtection? = null
/**
* RFC 9001 §6.4: an endpoint MUST NOT initiate a subsequent key update
* unless the previous one has been confirmed by the peer. We treat
* "confirmed" as having received any application-level packet whose
* decrypt succeeded under the post-rotation (current) live keys —
* meaning the peer has rolled forward in lockstep. Set true by
* [initiateKeyUpdate], cleared by [QuicConnectionParser] on the
* first short-header packet that AEAD-passes after the rotation.
*/
@Volatile
internal var keyUpdateInProgress: Boolean = false
/** /**
* RFC 9001 §4.10 — 0-RTT send-side packet protection. Installed when * RFC 9001 §4.10 — 0-RTT send-side packet protection. Installed when
* the TLS layer derives `client_early_traffic_secret` (right after * the TLS layer derives `client_early_traffic_secret` (right after
@@ -572,6 +584,22 @@ class QuicConnection(
com.vitorpamplona.quic.connection.recovery com.vitorpamplona.quic.connection.recovery
.QuicLossDetection() .QuicLossDetection()
/**
* Reusable scratch lists for [QuicConnectionWriter.buildApplicationPacket].
* The function clears them at entry, fills them, encodes the
* packet, snapshots the [scratchAppTokens] via `.toList()` for
* the [SentPacket] record, and returns. The next drain (single-
* threaded by [streamsLock]) then re-clears and re-uses.
*
* Skipped for [collectHandshakeLevelFrames]: that function returns
* a [com.vitorpamplona.quic.connection.HandshakeLevelContents]
* wrapping the lists, which the caller holds across two
* `buildLongHeaderFromFrames` calls (natural-size + padded
* rebuild). Reusing those would corrupt the rebuild path.
*/
internal val scratchAppFrames: MutableList<com.vitorpamplona.quic.frame.Frame> = mutableListOf()
internal val scratchAppTokens: MutableList<com.vitorpamplona.quic.connection.recovery.RecoveryToken> = mutableListOf()
/** /**
* RFC 9002 §6.2 Probe Timeout signalling. When the driver loop's * RFC 9002 §6.2 Probe Timeout signalling. When the driver loop's
* PTO timer fires (no ack-eliciting packet has been ACK'd in * PTO timer fires (no ack-eliciting packet has been ACK'd in
@@ -1845,12 +1873,14 @@ class QuicConnection(
* outbound packet carries `KEY_PHASE = 1` and the peer is expected * outbound packet carries `KEY_PHASE = 1` and the peer is expected
* to mirror back in the same phase. * to mirror back in the same phase.
* *
* RFC 9001 §6.5 says an endpoint MUST NOT initiate a key update * Spec invariants enforced here (caller no longer responsible):
* before the handshake is confirmed (HANDSHAKE_DONE received). The * - RFC 9001 §6.5: returns false if [handshakeComplete] is not
* caller is responsible for that check; this method just performs * yet true. Initiating before handshake confirmation is a
* the rotation. §6.4 also forbids initiating a second update before * PROTOCOL_VIOLATION.
* the current one has been confirmed (peer responds in matching * - RFC 9001 §6.4: returns false if a previous rotation is still
* phase) — same caller contract. * in flight ([keyUpdateInProgress]). The parser clears that
* flag on the first inbound packet that AEAD-decrypts under
* the post-rotation keys, signalling the peer rolled forward.
* *
* Header-protection key is unchanged (RFC 9001 §6.1: HP key is NOT * Header-protection key is unchanged (RFC 9001 §6.1: HP key is NOT
* rotated when keys are updated). * rotated when keys are updated).
@@ -1864,6 +1894,16 @@ class QuicConnection(
* and server". * and server".
*/ */
fun initiateKeyUpdate(): Boolean { fun initiateKeyUpdate(): Boolean {
// RFC 9001 §6.5: handshake MUST be confirmed before initiating
// a key update. We use [handshakeComplete] as the proxy —
// application keys are installed and the peer's HANDSHAKE_DONE
// has been processed.
if (!handshakeComplete) return false
// RFC 9001 §6.4: MUST NOT initiate a subsequent rotation until
// the previous one is confirmed. The parser clears this flag
// when it observes an inbound packet that AEAD-decrypts under
// the post-rotation live keys.
if (keyUpdateInProgress) return false
val cs = appCipherSuite.takeIf { it != 0 } ?: return false val cs = appCipherSuite.takeIf { it != 0 } ?: return false
val curRx = appReceiveSecret ?: return false val curRx = appReceiveSecret ?: return false
val curTx = appSendSecret ?: return false val curTx = appSendSecret ?: return false
@@ -1907,6 +1947,7 @@ class QuicConnection(
appSendSecret = nextTxSecret appSendSecret = nextTxSecret
currentReceiveKeyPhase = !currentReceiveKeyPhase currentReceiveKeyPhase = !currentReceiveKeyPhase
currentSendKeyPhase = !currentSendKeyPhase currentSendKeyPhase = !currentSendKeyPhase
keyUpdateInProgress = true
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION) qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION) qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION)
return true return true
@@ -67,6 +67,10 @@ import com.vitorpamplona.quic.tls.TlsClient
* under streamsLock so frame-dispatch / stream creation / level state * under streamsLock so frame-dispatch / stream creation / level state
* remains a single critical section. * remains a single critical section.
*/ */
/** RFC 9000 §16: maximum varint value, also the per-stream offset ceiling. */
private const val MAX_QUIC_OFFSET: Long = (1L shl 62) - 1L
fun feedDatagram( fun feedDatagram(
conn: QuicConnection, conn: QuicConnection,
datagram: ByteArray, datagram: ByteArray,
@@ -436,6 +440,16 @@ private fun feedShortHeaderPacket(
if (rotateOnSuccess != null && parsed.packet.packetNumber > state.pnSpace.largestReceived) { if (rotateOnSuccess != null && parsed.packet.packetNumber > state.pnSpace.largestReceived) {
conn.commitKeyUpdate(rotateOnSuccess) conn.commitKeyUpdate(rotateOnSuccess)
} }
// RFC 9001 §6.4: clear the in-flight-rotation gate once an inbound
// packet decrypts under the live (post-rotation) keys. That confirms
// the peer has rolled forward in lockstep, so it's safe to permit
// a subsequent [QuicConnection.initiateKeyUpdate]. The `rotateOnSuccess
// == null` branch above is the live-keys path (peer's key_phase bit
// matched our [currentReceiveKeyPhase]); reaching that with a
// successful parse is the confirmation signal.
if (rotateOnSuccess == null && conn.keyUpdateInProgress) {
conn.keyUpdateInProgress = false
}
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis) state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
if (conn.qlogObserver !== com.vitorpamplona.quic.observability.QlogObserver.NoOp) { if (conn.qlogObserver !== com.vitorpamplona.quic.observability.QlogObserver.NoOp) {
conn.qlogObserver.onPacketReceived( conn.qlogObserver.onPacketReceived(
@@ -770,9 +784,17 @@ private fun dispatchFrames(
// RFC 9000 §4.5: the [finalSize] in RESET_STREAM MUST agree // RFC 9000 §4.5: the [finalSize] in RESET_STREAM MUST agree
// with any final size implied by previously-received STREAM // with any final size implied by previously-received STREAM
// frames AND MUST be ≥ the highest offset already observed. // frames AND MUST be ≥ the highest offset already observed.
// A peer that violates this is closed with FINAL_SIZE_ERROR // §4.5 also caps final size at 2^62-1 (the QUIC offset field
// — pre-fix we accepted any value silently, letting a buggy // ceiling). A peer that violates any of these is closed with
// peer drift our state. // FINAL_SIZE_ERROR — pre-fix we accepted any value silently,
// letting a buggy peer drift our state.
if (frame.finalSize < 0L || frame.finalSize > MAX_QUIC_OFFSET) {
conn.markClosedExternally(
"FINAL_SIZE_ERROR: stream ${frame.streamId} RESET_STREAM finalSize " +
"${frame.finalSize} outside [0, 2^62-1]",
)
return
}
val target = conn.streamByIdLocked(frame.streamId) val target = conn.streamByIdLocked(frame.streamId)
if (target != null) { if (target != null) {
val priorFin = target.receive.finOffset val priorFin = target.receive.finOffset
@@ -74,8 +74,6 @@ fun drainOutbound(
conn: QuicConnection, conn: QuicConnection,
nowMillis: Long, nowMillis: Long,
): ByteArray? { ): ByteArray? {
val parts = mutableListOf<ByteArray>()
// Closing — emit a CONNECTION_CLOSE at the highest available level. The // Closing — emit a CONNECTION_CLOSE at the highest available level. The
// datagram-build paths below MUST satisfy two RFC 9000 constraints we // datagram-build paths below MUST satisfy two RFC 9000 constraints we
// got wrong before: // got wrong before:
@@ -601,7 +599,12 @@ private fun buildApplicationPacket(
// settled mid-handshake (peer hasn't ACK'd anything yet), so the // settled mid-handshake (peer hasn't ACK'd anything yet), so the
// walk is a no-op. // walk is a no-op.
conn.retireFullyDoneStreamsLocked() conn.retireFullyDoneStreamsLocked()
val frames = mutableListOf<Frame>() // Reuse per-connection scratch lists (cleared at entry) instead of
// allocating fresh `mutableListOf<Frame>` / `mutableListOf<RecoveryToken>`
// every drain — single-writer by [streamsLock], so the next drain's
// clear() runs after this drain has already snapshotted the tokens
// via `.toList()` into the SentPacket record.
val frames = conn.scratchAppFrames.also { it.clear() }
// Tokens collected in lock-step with [frames]: each retransmittable // Tokens collected in lock-step with [frames]: each retransmittable
// frame contributes a [RecoveryToken] so the [SentPacket] recorded // frame contributes a [RecoveryToken] so the [SentPacket] recorded
// at the bottom of this function can drive RFC 9002 loss // at the bottom of this function can drive RFC 9002 loss
@@ -610,7 +613,7 @@ private fun buildApplicationPacket(
// are not retransmittable (DatagramFrame, StreamFrame for now) do // are not retransmittable (DatagramFrame, StreamFrame for now) do
// not contribute a token; the packet is still ack-eliciting and // not contribute a token; the packet is still ack-eliciting and
// tracked for loss-detection timing. // tracked for loss-detection timing.
val tokens = mutableListOf<RecoveryToken>() val tokens = conn.scratchAppTokens.also { it.clear() }
// RFC 9000 §17.2.3 — 0-RTT packets MUST NOT contain ACK frames. The // RFC 9000 §17.2.3 — 0-RTT packets MUST NOT contain ACK frames. The
// server cannot ACK 0-RTT-level packets because it has no way to // server cannot ACK 0-RTT-level packets because it has no way to
@@ -619,31 +622,23 @@ private fun buildApplicationPacket(
// Skip ACK building when we're about to emit a 0-RTT packet. // Skip ACK building when we're about to emit a 0-RTT packet.
if (use1Rtt) { if (use1Rtt) {
state.ackTracker.buildAckFrame(nowMillis, conn.config.ackDelayExponent.toInt())?.let { plainAck -> state.ackTracker.buildAckFrame(nowMillis, conn.config.ackDelayExponent.toInt())?.let { plainAck ->
// RFC 9000 §13.4.2: an endpoint that USES ECN on outbound // RFC 9000 §13.4.2: an endpoint that USES ECN MUST report
// packets (we set ECT(0) on every datagram via the socket's // accurate ECN counts. Pre-fix we hardcoded
// IP_TOS option) MUST report ECN counts in 1-RTT ACK frames so // `AckEcnCounts(0, 0, 0)` while still marking outbound
// the peer can detect path congestion. We don't currently // datagrams with ECT(0) — a strict peer cross-validating
// read inbound TOS bits — JDK's DatagramChannel doesn't expose // counts could treat the all-zero report as a
// them without JNI — so the counts are all-zero. The interop // PROTOCOL_VIOLATION, since we claim to be using ECN but
// runner's `ecn` testcase only checks for the field's presence // never accumulate the counts. We don't read inbound TOS
// (`hasattr(p["quic"], "ack.ect0_count")`); strict peers that // (JDK's DatagramChannel doesn't expose it without JNI),
// cross-validate counts would reject this, but aioquic / // so honest reporting means: don't claim to track ECN at
// picoquic / quic-go all tolerate it. Initial / Handshake-space // all — emit ACK with `ecnCounts = null`. The peer reads
// ACKs stay plain (ecnCounts=null) — the spec allows ECN counts // that as "this endpoint isn't reporting ECN" and skips
// there too, but interop implementations don't always handle // its own ECN-driven congestion logic for our direction.
// them and we'd gain nothing. // We still mark outbound ECT(0) (other peers' tracking
val ackWithEcn = // benefits from the path-quality signal); the asymmetry
AckFrame( // is allowed by §13.4.
largestAcknowledged = plainAck.largestAcknowledged, frames += plainAck
ackDelay = plainAck.ackDelay, tokens += RecoveryToken.Ack(level = EncryptionLevel.APPLICATION, largestAcked = plainAck.largestAcknowledged)
firstAckRange = plainAck.firstAckRange,
additionalRanges = plainAck.additionalRanges,
ecnCounts =
com.vitorpamplona.quic.frame
.AckEcnCounts(0L, 0L, 0L),
)
frames += ackWithEcn
tokens += RecoveryToken.Ack(level = EncryptionLevel.APPLICATION, largestAcked = ackWithEcn.largestAcknowledged)
} }
} }
@@ -725,7 +720,17 @@ private fun buildApplicationPacket(
val sorted = val sorted =
when { when {
active.size <= 1 -> active active.size <= 1 -> active
active.all { it.priority == 0 } -> active
// Single-pass uniform-priority check: if EVERY stream
// shares the same priority (whether default 0 or
// anything else), the sort is a no-op and we keep
// insertion order for round-robin fairness. Pre-fix
// we only short-circuited on `priority == 0`, so a
// homogeneous `priority == 7` workload paid for an
// O(N log N) sort despite the result being
// observationally identical to insertion order.
active.all { it.priority == active[0].priority } -> active
else -> active.sortedByDescending { it.priority } else -> active.sortedByDescending { it.priority }
} }
val rotation = conn.streamRoundRobinStart val rotation = conn.streamRoundRobinStart
@@ -86,6 +86,41 @@ abstract class Aead {
return seal(key, nonce, a, p) return seal(key, nonce, a, p)
} }
/**
* Range + in-place [seal]: read `aad` and `plaintext` from sub-ranges
* and write ciphertext+tag DIRECTLY into [output] at [outputOffset].
* Returns the number of bytes written (== plaintextLength + tagLength).
*
* Saves another ByteArray allocation per outbound packet vs.
* [sealRange] — the build path can pre-allocate one final packet
* buffer and have the ciphertext land in-place rather than copying
* a fresh `seal()` result. Combined with the AAD+plaintext range
* inputs already handled by [sealRange], a complete outbound packet
* goes from ~4 allocations (headerBytes / paddedPlaintext /
* ciphertext / final concat) down to ~2 (the final packet buffer
* and the AEAD provider's internal scratch).
*
* Default impl falls back to [sealRange] + copy; the JCA-backed
* [com.vitorpamplona.quic.crypto.JcaAesGcmAead] overrides to use
* `Cipher.doFinal(input, inOff, inLen, output, outOff)`.
*/
open fun sealInto(
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
aadOffset: Int,
aadLength: Int,
plaintext: ByteArray,
plaintextOffset: Int,
plaintextLength: Int,
output: ByteArray,
outputOffset: Int,
): Int {
val ct = sealRange(key, nonce, aad, aadOffset, aadLength, plaintext, plaintextOffset, plaintextLength)
ct.copyInto(output, outputOffset)
return ct.size
}
/** /**
* Range-based [open] — same semantics as [open] but reads `aad` and * Range-based [open] — same semantics as [open] but reads `aad` and
* `ciphertext` from sub-ranges. Default impl slices and delegates. * `ciphertext` from sub-ranges. Default impl slices and delegates.
@@ -34,3 +34,13 @@ expect val PlatformChaCha20Block: ChaCha20BlockEncrypt
* stateless singleton) correct, just not the fast path. * stateless singleton) correct, just not the fast path.
*/ */
expect fun bestAes128GcmAead(key: ByteArray): Aead expect fun bestAes128GcmAead(key: ByteArray): Aead
/**
* Build the platform's preferred ChaCha20-Poly1305 AEAD for a fixed [key].
* JVM 11+ / Android API 28+ provide a JCA `ChaCha20-Poly1305` cipher
* that supports range-based `Cipher.doFinal(input, off, len, output, off)`,
* unlocking the same allocation-elision wins as [bestAes128GcmAead].
* Older platforms fall back to the pure-Kotlin [ChaCha20Poly1305Aead]
* singleton correct, just slower and without range overloads.
*/
expect fun bestChaCha20Poly1305Aead(key: ByteArray): Aead
@@ -109,14 +109,28 @@ object LongHeaderPacket {
} }
val headerBytes = w.toByteArray() val headerBytes = w.toByteArray()
// Encrypt padded payload // Pre-allocate the final packet buffer in one shot and have the
// AEAD seal write ciphertext+tag directly into it. Pre-fix this
// path allocated `headerBytes`, `paddedPlaintext`, the
// `aead.seal` return ByteArray, and the final concat buffer —
// four ByteArrays per outbound packet. The single-buffer +
// [Aead.sealInto] form below collapses the seal output and
// concat into the same allocation.
val nonce = aeadNonce(iv, plain.packetNumber) val nonce = aeadNonce(iv, plain.packetNumber)
val ciphertext = aead.seal(key, nonce, headerBytes, paddedPlaintext) val packet = ByteArray(headerBytes.size + paddedPlaintext.size + aead.tagLength)
// Concatenate header + ciphertext
val packet = ByteArray(headerBytes.size + ciphertext.size)
headerBytes.copyInto(packet, 0) headerBytes.copyInto(packet, 0)
ciphertext.copyInto(packet, headerBytes.size) aead.sealInto(
key = key,
nonce = nonce,
aad = packet,
aadOffset = 0,
aadLength = headerBytes.size,
plaintext = paddedPlaintext,
plaintextOffset = 0,
plaintextLength = paddedPlaintext.size,
output = packet,
outputOffset = headerBytes.size,
)
// Apply header protection. Sample is 16 bytes starting 4 bytes after pnOffset. // Apply header protection. Sample is 16 bytes starting 4 bytes after pnOffset.
val sampleStart = pnOffset + 4 val sampleStart = pnOffset + 4
@@ -75,12 +75,26 @@ object ShortHeaderPacket {
// Trailing 0x00 bytes decode as PADDING frames (RFC 9000 §19.1). // Trailing 0x00 bytes decode as PADDING frames (RFC 9000 §19.1).
val paddedPlaintext = padForHeaderProtectionSample(plain.payload, pnLen) val paddedPlaintext = padForHeaderProtectionSample(plain.payload, pnLen)
// Same single-buffer + sealInto pattern as LongHeaderPacket.build:
// pre-allocate the final packet and have the AEAD write
// ciphertext+tag directly into it instead of allocating a fresh
// `seal()` return + a concat buffer. Saves 2 ByteArrays per
// outbound short-header packet.
val nonce = aeadNonce(iv, plain.packetNumber) val nonce = aeadNonce(iv, plain.packetNumber)
val ciphertext = aead.seal(key, nonce, headerBytes, paddedPlaintext) val packet = ByteArray(headerBytes.size + paddedPlaintext.size + aead.tagLength)
val packet = ByteArray(headerBytes.size + ciphertext.size)
headerBytes.copyInto(packet, 0) headerBytes.copyInto(packet, 0)
ciphertext.copyInto(packet, headerBytes.size) aead.sealInto(
key = key,
nonce = nonce,
aad = packet,
aadOffset = 0,
aadLength = headerBytes.size,
plaintext = paddedPlaintext,
plaintextOffset = 0,
plaintextLength = paddedPlaintext.size,
output = packet,
outputOffset = headerBytes.size,
)
val sampleStart = pnOffset + 4 val sampleStart = pnOffset + 4
require(sampleStart + 16 <= packet.size) { "packet too short for HP sample" } require(sampleStart + 16 <= packet.size) { "packet too short for HP sample" }
@@ -22,7 +22,7 @@ package com.vitorpamplona.quic.stream
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.flow
/** /**
* One QUIC stream (bidirectional or unidirectional). Application code * One QUIC stream (bidirectional or unidirectional). Application code
@@ -78,33 +78,35 @@ class QuicStream(
* was discarded, leaving a hole in the stream that the application could * was discarded, leaving a hole in the stream that the application could
* never know about. * never know about.
* *
* **Single-collector contract.** [consumeAsFlow] cancels the underlying * **Resilience to collector cancellation.** Pre-fix this exposed
* [Channel] when its collector terminates (either by exception or * [incomingChannel] via `consumeAsFlow()`, which cancels the
* explicit cancellation). After that point any subsequent * underlying [Channel] when its collector terminates. That coupled
* `trySend` from the parser fails, sets [overflowed] = true, and * "application stopped collecting" to "parser INTERNAL_ERRORs the
* the parser tears the whole connection down with * whole connection on next delivery" — cancelling the collector
* `INTERNAL_ERROR: stream consumer overflowed`. So: * early (e.g. `withTimeout(...)`) was effectively a request to
* - Application code MUST collect [incoming] **at most once** per * drop the connection. Rebuilt as `flow { for (c in
* stream and MUST hold the collect open until the stream * incomingChannel) emit(c) }`: a fresh emit-only Flow over the
* terminates (FIN, peer RESET_STREAM, or local * channel iterator, with no `consume`-style ownership transfer.
* [stopSending] / [resetStream] decision). * Now collector cancellation just exits the flow without touching
* - Cancelling the collector early e.g. wrapping in * the channel; the channel stays open, the parser keeps
* `withTimeout(...)` is equivalent to telling the connection * delivering, and the application can re-collect later (a fresh
* to drop. If the application wants to stop receiving without * collector picks up at `channel.receive()` i.e. from the
* killing the connection, call [stopSending] FIRST, then let * current head, not the start).
* the parser's RESET_STREAM-style teardown drain the channel
* cleanly.
* *
* This is a fragile coupling we accept for now because (a) every * Producer back-pressure unchanged: the channel buffer is still
* production caller already follows the single-collector pattern, * 64 chunks, [trySend] still sets [overflowed] on saturation,
* and (b) widening the contract would require swapping * and the parser still closes the connection if the application
* `consumeAsFlow` for a `MutableSharedFlow` with replay/buffer * fails to drain. The looser contract just permits the previously-
* semantics, which has its own back-pressure pitfalls. The * disallowed pattern of "stop reading temporarily, then resume".
* comment is here so a future refactor doesn't accidentally *
* loosen the contract. * Concurrent collectors are still NOT supported two simultaneous
* collects would race the channel iterator and each chunk goes to
* exactly one of them non-deterministically. Sequential collects
* (one finishes / cancels, then another starts) are the new
* supported pattern.
*/ */
private val incomingChannel = Channel<ByteArray>(capacity = 64) private val incomingChannel = Channel<ByteArray>(capacity = 64)
val incoming: Flow<ByteArray> get() = incomingChannel.consumeAsFlow() val incoming: Flow<ByteArray> = flow { for (chunk in incomingChannel) emit(chunk) }
/** /**
* True once a [deliverIncoming] call failed because the channel was * True once a [deliverIncoming] call failed because the channel was
@@ -128,10 +130,18 @@ class QuicStream(
* writer's appendFlowControlUpdates consumes it to skip streams that * writer's appendFlowControlUpdates consumes it to skip streams that
* haven't received any new bytes since the last MAX_STREAM_DATA emission. * haven't received any new bytes since the last MAX_STREAM_DATA emission.
* *
* `@Volatile` because the parser writes it from the read loop and the
* writer reads it from the send loop without holding the same lock.
* Without volatile the writer could miss the parser's update for an
* unbounded time on JVM (the field is read in a hot drain loop where
* the JIT might cache it), suppressing MAX_STREAM_DATA emissions
* until something else triggered a fresh load.
*
* Pre-fix the writer iterated EVERY open stream on every drain * Pre-fix the writer iterated EVERY open stream on every drain
* (audit-4 perf #9 O(streams) × ~50 drains/sec; significant for audio * (audit-4 perf #9 O(streams) × ~50 drains/sec; significant for audio
* rooms with many WT streams). * rooms with many WT streams).
*/ */
@Volatile
internal var receiveDirtyForFlowControl: Boolean = false internal var receiveDirtyForFlowControl: Boolean = false
/** True once we've FIN'd our write side and the peer FIN'd theirs. */ /** True once we've FIN'd our write side and the peer FIN'd theirs. */
@@ -136,6 +136,21 @@ class SendBuffer(
*/ */
private val retransmit: ArrayDeque<Range> = ArrayDeque() private val retransmit: ArrayDeque<Range> = ArrayDeque()
/**
* Cached `sum(retransmit[].length)` so [readableBytes] can return
* in O(1) instead of walking the deque on every call. The writer's
* pre-flight "anything to send?" check runs per-stream per-drain
* (~50 drains/sec × N streams), so an O(R) per-call cost on lossy
* paths with deep retransmit queues was meaningful CPU.
*
* Updated in lockstep with every [retransmit] add/remove path:
* retransmit.addLast / addFirst / removeFirst, and
* [removeOverlap]'s mid-walk add/remove during ACK processing.
* The invariant `retransmitTotalBytes == retransmit.sumOf { it.length }`
* holds whenever no [SendBuffer] method is mid-mutation.
*/
private var retransmitTotalBytes: Long = 0L
private var _finPending: Boolean = false private var _finPending: Boolean = false
private var _finSent: Boolean = false private var _finSent: Boolean = false
private var _finAcked: Boolean = false private var _finAcked: Boolean = false
@@ -154,9 +169,7 @@ class SendBuffer(
val readableBytes: Int val readableBytes: Int
get() = get() =
synchronized(this) { synchronized(this) {
var sum = 0L val sum = retransmitTotalBytes + (_nextOffset - nextSendOffset)
for (r in retransmit) sum += r.length
sum += (_nextOffset - nextSendOffset)
sum.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() sum.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
} }
@@ -210,16 +223,18 @@ class SendBuffer(
val take = minOf(retransmitHead.length, cap.toLong()) val take = minOf(retransmitHead.length, cap.toLong())
val payload = sliceAt(retransmitHead.offset, take.toInt()) val payload = sliceAt(retransmitHead.offset, take.toInt())
retransmit.removeFirst() retransmit.removeFirst()
retransmitTotalBytes -= retransmitHead.length
val fin = retransmitHead.fin && take == retransmitHead.length val fin = retransmitHead.fin && take == retransmitHead.length
if (take < retransmitHead.length) { if (take < retransmitHead.length) {
// Push remainder back at the head, preserving offset. // Push remainder back at the head, preserving offset.
retransmit.addFirst( val remainder =
Range( Range(
offset = retransmitHead.offset + take, offset = retransmitHead.offset + take,
length = retransmitHead.length - take, length = retransmitHead.length - take,
fin = retransmitHead.fin, fin = retransmitHead.fin,
), )
) retransmit.addFirst(remainder)
retransmitTotalBytes += remainder.length
} }
addToInFlight(Range(retransmitHead.offset, take, fin)) addToInFlight(Range(retransmitHead.offset, take, fin))
if (fin) _finSent = true if (fin) _finSent = true
@@ -351,13 +366,29 @@ class SendBuffer(
} }
// Move every inflight range to the retransmit queue, // Move every inflight range to the retransmit queue,
// preserving offset order (inFlight is sorted by offset // preserving offset order (inFlight is sorted by offset
// ascending, so addLast preserves sort within retransmit // ascending). Coalesce adjacent ranges on insert: if the
// for these new entries — though retransmit is a FIFO // previous tail entry's `offset + length` equals the
// and doesn't strictly require sorted order, takeChunk // current's `offset`, merge them into a single range. The
// pops front-first regardless). // PTO probe path otherwise emits one tiny STREAM frame per
// original-packet boundary instead of replaying the
// contiguous bytes as a single chunk, fragmenting the
// probe across N small frames + N AEAD seals + N writes
// when one frame would do. Coalescing across the FIN bit
// is gated — only merge when the previous tail had no
// FIN, otherwise the FIN's implicit final-size invariant
// could shift.
for (r in inFlight) { for (r in inFlight) {
if (r.fin && !_finAcked) _finSent = false if (r.fin && !_finAcked) _finSent = false
retransmit.addLast(r) val tail = retransmit.lastOrNull()
if (tail != null && !tail.fin && tail.offset + tail.length == r.offset) {
// Merge: extend the tail to cover [tail.offset, r.endOffset).
val merged = Range(tail.offset, tail.length + r.length, r.fin)
retransmit.removeLast()
retransmit.addLast(merged)
} else {
retransmit.addLast(r)
}
retransmitTotalBytes += r.length
} }
inFlight.clear() inFlight.clear()
} }
@@ -405,6 +436,7 @@ class SendBuffer(
OverlapAction.RETRANSMIT -> { OverlapAction.RETRANSMIT -> {
retransmit.addLast(r) retransmit.addLast(r)
retransmitTotalBytes += r.length
} }
OverlapAction.DROP -> {} // discard OverlapAction.DROP -> {} // discard
@@ -482,6 +514,7 @@ class SendBuffer(
fin = coveredFin, fin = coveredFin,
), ),
) )
retransmitTotalBytes += coveredLen
} }
OverlapAction.DROP -> {} // discard the covered piece OverlapAction.DROP -> {} // discard the covered piece
@@ -361,39 +361,47 @@ class TlsClient(
// We offered PSK but the server picked full- // We offered PSK but the server picked full-
// handshake. RFC 8446 §4.2.11 lets the server // handshake. RFC 8446 §4.2.11 lets the server
// do this freely (rate limit, ticket aged out, // do this freely (rate limit, ticket aged out,
// policy mismatch). Recovering in-place // policy mismatch).
// requires: //
// 1. Discarding the early secret derived // Recover in-place WITHOUT a transcript rebuild.
// from the resumption PSK. // The on-wire ClientHello carries the
// 2. Rebuilding the transcript without the // pre_shared_key extension and binder bytes;
// `pre_shared_key` extension or its // both client and server hash the FULL
// binder bytes. // ClientHello (binders included) into their
// 3. Replaying the post-ClientHello derivation // running transcript hash regardless of accept
// with the new transcript. // / reject (RFC 8446 §4.2.11). So the
// Each step is touchy enough that a // transcript itself stays valid — only the
// subtly-wrong transcript hash would land us on // [earlySecret] derivation changes:
// a successful handshake with wrong keys, which // * accepted: early_secret = HKDF.extract(0, PSK)
// is harder to debug than a hard failure. We // * rejected: early_secret = HKDF.extract(0, 0)
// surface a typed [PskRejectedException] so // [deriveHandshake] then folds early_secret +
// application-layer reconnect logic can drop // ECDHE shared secret into [handshakeSecret];
// the cached resumption state and retry from // every downstream key follows. We just have
// scratch — that path is correct by // to overwrite earlySecret here BEFORE
// construction (fresh ClientHello, no PSK // [deriveHandshake] runs below.
// history). Production callers wrapping this //
// client SHOULD catch [PskRejectedException] // Any 0-RTT packets the application already
// and retry without [resumption]. // emitted under the PSK-derived
throw PskRejectedException( // [clientEarlyTrafficSecret] are lost: the
"server rejected PSK; application must retry without resumption", // server can't decrypt them, and the EE will
) // arrive without the early_data extension
// (earlyDataAccepted = false). That's the
// RFC's expected behaviour and the
// application-level retry layer is responsible
// for replaying any 0-RTT-bound payload over
// 1-RTT. The handshake itself proceeds cleanly.
keySchedule.deriveEarly()
pskAccepted = false
} else {
val r = QuicReader(pskExt.data)
val selectedIdentity = r.readUint16()
if (selectedIdentity != 0) {
throw QuicCodecException(
"server selected PSK identity $selectedIdentity but we only offered 0",
)
}
pskAccepted = true
} }
val r = QuicReader(pskExt.data)
val selectedIdentity = r.readUint16()
if (selectedIdentity != 0) {
throw QuicCodecException(
"server selected PSK identity $selectedIdentity but we only offered 0",
)
}
pskAccepted = true
} else if (pskExt != null) { } else if (pskExt != null) {
throw QuicCodecException("server picked PSK we never offered") throw QuicCodecException("server picked PSK we never offered")
} }
@@ -605,22 +613,6 @@ 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. */ /** Callback interface so the QUIC layer can react to TLS-derived secrets. */
interface TlsSecretsListener { interface TlsSecretsListener {
fun onHandshakeKeysReady( fun onHandshakeKeysReady(
@@ -685,7 +677,23 @@ interface TlsSecretsListener {
* [ticketAgeAdd] and [issuedAtMillis] to compute the obfuscated_ticket_age * [ticketAgeAdd] and [issuedAtMillis] to compute the obfuscated_ticket_age
* the server expects. * the server expects.
*/ */
data class TlsResumptionState( /**
* Cached state from a prior TLS handshake that lets the client offer
* RFC 8446 PSK-resumption + RFC 9001 §4.6 0-RTT on the next connection.
*
* Plain `class` (not `data class`) intentionally:
* - The `data class`-generated `equals` / `hashCode` use reference
* equality on [ByteArray] fields, so two states with byte-identical
* PSKs would compare unequal surprising and almost never useful.
* - The auto-generated `toString` would dump `psk` / `ticket` /
* `peerTransportParameters` byte contents into any log, stack trace,
* or debugger that touches the object. Both are sensitive.
*
* We override [equals] / [hashCode] with [contentEquals] semantics on
* the byte fields (so callers can deduplicate cached states by content)
* and [toString] to redact the secret payload.
*/
class TlsResumptionState(
/** Opaque ticket bytes echoed verbatim as the PSK identity on the next connection. */ /** Opaque ticket bytes echoed verbatim as the PSK identity on the next connection. */
val ticket: ByteArray, val ticket: ByteArray,
/** PSK derived from `resumption_master_secret` + `ticket_nonce` per RFC 8446 §4.6.1. */ /** PSK derived from `resumption_master_secret` + `ticket_nonce` per RFC 8446 §4.6.1. */
@@ -724,7 +732,51 @@ data class TlsResumptionState(
val peerTransportParameters: ByteArray? = null, val peerTransportParameters: ByteArray? = null,
/** Negotiated ALPN from the prior connection. 0-RTT must use the same protocol. */ /** Negotiated ALPN from the prior connection. 0-RTT must use the same protocol. */
val negotiatedAlpn: ByteArray? = null, val negotiatedAlpn: ByteArray? = null,
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is TlsResumptionState) return false
return ticket.contentEquals(other.ticket) &&
psk.contentEquals(other.psk) &&
cipherSuite == other.cipherSuite &&
ticketAgeAdd == other.ticketAgeAdd &&
ticketLifetimeSec == other.ticketLifetimeSec &&
issuedAtMillis == other.issuedAtMillis &&
maxEarlyDataSize == other.maxEarlyDataSize &&
(peerTransportParameters?.contentEquals(other.peerTransportParameters) ?: (other.peerTransportParameters == null)) &&
(negotiatedAlpn?.contentEquals(other.negotiatedAlpn) ?: (other.negotiatedAlpn == null))
}
override fun hashCode(): Int {
var h = ticket.contentHashCode()
h = 31 * h + psk.contentHashCode()
h = 31 * h + cipherSuite
h = 31 * h + ticketAgeAdd.hashCode()
h = 31 * h + ticketLifetimeSec.hashCode()
h = 31 * h + issuedAtMillis.hashCode()
h = 31 * h + maxEarlyDataSize.hashCode()
h = 31 * h + (peerTransportParameters?.contentHashCode() ?: 0)
h = 31 * h + (negotiatedAlpn?.contentHashCode() ?: 0)
return h
}
/**
* Redacted [toString]: never include `ticket`, `psk`, or
* `peerTransportParameters` byte contents those leak into logs
* and stack traces. Sizes are fine to expose.
*/
override fun toString(): String =
"TlsResumptionState(" +
"ticket=<${ticket.size} bytes>, " +
"psk=<${psk.size} bytes redacted>, " +
"cipherSuite=0x${cipherSuite.toString(16)}, " +
"ticketAgeAdd=$ticketAgeAdd, " +
"ticketLifetimeSec=$ticketLifetimeSec, " +
"issuedAtMillis=$issuedAtMillis, " +
"maxEarlyDataSize=$maxEarlyDataSize, " +
"peerTransportParameters=${peerTransportParameters?.let { "<${it.size} bytes>" }}, " +
"negotiatedAlpn=${negotiatedAlpn?.decodeToString()})"
}
/** Pluggable certificate validator. Decoupled so we can stub it in tests. */ /** Pluggable certificate validator. Decoupled so we can stub it in tests. */
interface CertificateValidator { interface CertificateValidator {
@@ -206,6 +206,56 @@ class JcaAesGcmAead(
} }
} }
/**
* In-place range [seal]. Same semantics as the parent's [sealInto],
* but takes the JCA's native `Cipher.doFinal(input, inOff, inLen,
* output, outOff)` path so ciphertext+tag land directly in
* [output] with no intermediate allocation. Caller is expected to
* have sized `output` large enough to hold
* `plaintextLength + tagLength` bytes starting at `outputOffset`.
*/
override fun sealInto(
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
aadOffset: Int,
aadLength: Int,
plaintext: ByteArray,
plaintextOffset: Int,
plaintextLength: Int,
output: ByteArray,
outputOffset: Int,
): Int =
synchronized(this) {
val reuse = recentEncryptNonces.any { it.contentEquals(nonce) }
val cipher: Cipher
if (reuse) {
cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(128, nonce))
} else {
cipher = encryptCipher
try {
cipher.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(128, nonce))
} catch (t: Throwable) {
if (recentEncryptNonces.lastOrNull()?.contentEquals(nonce) == true) {
recentEncryptNonces.removeLast()
}
throw t
}
}
try {
cipher.updateAAD(aad, aadOffset, aadLength)
val written = cipher.doFinal(plaintext, plaintextOffset, plaintextLength, output, outputOffset)
if (!reuse) rememberEncryptNonce(nonce)
written
} catch (t: Throwable) {
if (!reuse && recentEncryptNonces.lastOrNull()?.contentEquals(nonce) == true) {
recentEncryptNonces.removeLast()
}
throw t
}
}
private companion object { private companion object {
private const val NONCE_HISTORY_LIMIT = 8 private const val NONCE_HISTORY_LIMIT = 8
} }
@@ -0,0 +1,210 @@
/*
* 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.crypto
import java.security.GeneralSecurityException
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* ChaCha20-Poly1305 AEAD via the JCA `ChaCha20-Poly1305` cipher
* (Java 11+ / Android API 28+). Mirrors [JcaAesGcmAead]'s shape: caches
* the [Cipher] + [SecretKeySpec] across calls and exposes the range
* overloads ([sealRange] / [openRange] / [sealInto]) that pass
* offsets straight to the JCA cipher's `doFinal(input, inOff, inLen,
* output, outOff)` form, eliminating the per-packet slice
* allocations the pure-Kotlin [ChaCha20Poly1305Aead] requires.
*
* Single-thread per direction (one PacketProtection per side, one
* direction per side). Synchronization on a private monitor is
* defence-in-depth a future caller (test harness, key-update path)
* sharing the instance across coroutines would otherwise corrupt
* the cached `Cipher` state silently.
*/
class JcaChaCha20Poly1305Aead(
key: ByteArray,
) : Aead() {
override val keyLength = 32
override val nonceLength = 12
override val tagLength = 16
private val keySpec = SecretKeySpec(key, "ChaCha20")
private val encryptCipher: Cipher = Cipher.getInstance("ChaCha20-Poly1305")
private val decryptCipher: Cipher = Cipher.getInstance("ChaCha20-Poly1305")
/**
* Last-N nonces successfully consumed by [seal] / [sealRange] /
* [sealInto]. JCA's ChaCha20-Poly1305 (like AES-GCM) refuses to
* encrypt under a (key, nonce) pair already used by THIS cipher
* instance even when the reuse is legitimate (Initial-padding
* rebuild). When we detect a reuse against this history we fall
* back to a fresh `Cipher.getInstance` to avoid fighting the
* provider's safety check.
*/
private val recentEncryptNonces = ArrayDeque<ByteArray>()
override fun seal(
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
plaintext: ByteArray,
): ByteArray =
synchronized(this) {
sealCommon(nonce, aad, 0, aad.size, plaintext, 0, plaintext.size, output = null, outputOffset = 0)
.first
}
override fun sealRange(
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
aadOffset: Int,
aadLength: Int,
plaintext: ByteArray,
plaintextOffset: Int,
plaintextLength: Int,
): ByteArray =
synchronized(this) {
sealCommon(nonce, aad, aadOffset, aadLength, plaintext, plaintextOffset, plaintextLength, output = null, outputOffset = 0)
.first
}
override fun sealInto(
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
aadOffset: Int,
aadLength: Int,
plaintext: ByteArray,
plaintextOffset: Int,
plaintextLength: Int,
output: ByteArray,
outputOffset: Int,
): Int =
synchronized(this) {
sealCommon(nonce, aad, aadOffset, aadLength, plaintext, plaintextOffset, plaintextLength, output, outputOffset)
.second
}
/**
* Shared seal path for all three entry points. Returns
* `(freshOrEmpty, bytesWritten)`:
* - When [output] is null we allocate the result internally,
* return it as `freshOrEmpty`, and `bytesWritten` is its size.
* - When [output] is non-null we write into it at [outputOffset],
* return an empty array as `freshOrEmpty` (caller ignores), and
* `bytesWritten` is the number of bytes written.
*/
private fun sealCommon(
nonce: ByteArray,
aad: ByteArray,
aadOffset: Int,
aadLength: Int,
plaintext: ByteArray,
plaintextOffset: Int,
plaintextLength: Int,
output: ByteArray?,
outputOffset: Int,
): Pair<ByteArray, Int> {
val reuse = recentEncryptNonces.any { it.contentEquals(nonce) }
val cipher: Cipher
if (reuse) {
cipher = Cipher.getInstance("ChaCha20-Poly1305")
cipher.init(Cipher.ENCRYPT_MODE, keySpec, IvParameterSpec(nonce))
} else {
cipher = encryptCipher
try {
cipher.init(Cipher.ENCRYPT_MODE, keySpec, IvParameterSpec(nonce))
} catch (t: Throwable) {
if (recentEncryptNonces.lastOrNull()?.contentEquals(nonce) == true) {
recentEncryptNonces.removeLast()
}
throw t
}
}
try {
cipher.updateAAD(aad, aadOffset, aadLength)
return if (output != null) {
val written = cipher.doFinal(plaintext, plaintextOffset, plaintextLength, output, outputOffset)
if (!reuse) rememberEncryptNonce(nonce)
EMPTY_BYTES to written
} else {
val out = cipher.doFinal(plaintext, plaintextOffset, plaintextLength)
if (!reuse) rememberEncryptNonce(nonce)
out to out.size
}
} catch (t: Throwable) {
if (!reuse && recentEncryptNonces.lastOrNull()?.contentEquals(nonce) == true) {
recentEncryptNonces.removeLast()
}
throw t
}
}
private fun rememberEncryptNonce(nonce: ByteArray) {
recentEncryptNonces.addLast(nonce)
while (recentEncryptNonces.size > NONCE_HISTORY_LIMIT) {
recentEncryptNonces.removeFirst()
}
}
override fun open(
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
ciphertext: ByteArray,
): ByteArray? =
synchronized(this) {
try {
decryptCipher.init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(nonce))
decryptCipher.updateAAD(aad)
decryptCipher.doFinal(ciphertext)
} catch (_: GeneralSecurityException) {
null
}
}
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, IvParameterSpec(nonce))
decryptCipher.updateAAD(aad, aadOffset, aadLength)
decryptCipher.doFinal(ciphertext, ciphertextOffset, ciphertextLength)
} catch (_: GeneralSecurityException) {
null
}
}
private companion object {
private const val NONCE_HISTORY_LIMIT = 8
private val EMPTY_BYTES = ByteArray(0)
}
}
@@ -45,3 +45,24 @@ actual val PlatformChaCha20Block: ChaCha20BlockEncrypt =
} }
actual fun bestAes128GcmAead(key: ByteArray): Aead = JcaAesGcmAead(key) actual fun bestAes128GcmAead(key: ByteArray): Aead = JcaAesGcmAead(key)
/**
* Try the JCA `ChaCha20-Poly1305` cipher first (Java 11+ /
* Android API 28+) gives us range-overload + offset-based doFinal,
* which lets [com.vitorpamplona.quic.packet.LongHeaderPacket.build]
* and [com.vitorpamplona.quic.packet.ShortHeaderPacket.build] write
* ciphertext+tag in-place without intermediate allocations on the
* outbound hot path.
*
* Falls back to the pure-Kotlin [ChaCha20Poly1305Aead] singleton if
* the JCA provider doesn't ship the algorithm older Android
* versions, headless containers without the standard provider set,
* GraalVM native-image unsubsetted, etc. The fallback is correct
* (same algorithm) but slower and skips the range overloads.
*/
actual fun bestChaCha20Poly1305Aead(key: ByteArray): Aead =
try {
JcaChaCha20Poly1305Aead(key)
} catch (_: java.security.NoSuchAlgorithmException) {
ChaCha20Poly1305Aead
}
@@ -247,28 +247,242 @@ class JdkCertificateValidator(
if (!lhost.endsWith(suffix)) return false if (!lhost.endsWith(suffix)) return false
val prefix = lhost.substring(0, lhost.length - suffix.length) val prefix = lhost.substring(0, lhost.length - suffix.length)
if (prefix.isEmpty() || '.' in prefix) return false if (prefix.isEmpty() || '.' in prefix) return false
// RFC 6125 §6.4.3 — disallow wildcards in the public-suffix label. // RFC 6125 §6.4.3 — disallow wildcards in the public-suffix
// label. The full Mozilla PSL is ~9000 entries; we ship a
// hand-picked subset covering the common multi-label
// effective-TLDs that come up in the wild for cert
// mis-issuance scenarios (multi-tenant ccTLDs, hosting
// platforms). Two layers of defence:
// 1. The dot-count heuristic (≥ 2 dots in the suffix)
// catches the obvious `*.com` / `*.net` patterns.
// 2. The denylist below catches `*.co.uk`,
// `*.s3.amazonaws.com`, `*.github.io`, etc. — the
// multi-label tldsets where the dot-count alone would
// let a rogue wildcard impersonate an entire tenant
// pool.
// //
// KNOWN GAP: this heuristic counts dots in the suffix (≥ 2 → OK) // This is intentionally conservative — false positives
// and DOES NOT consult the actual public-suffix list. So it // (rejecting a legitimate but unusual wildcard) are recoverable
// accepts `*.co.uk`, `*.github.io`, `*.s3.amazonaws.com`, etc. // by the application using OS-level pinning, while false
// — multi-tenant TLDs whose effective TLD is multi-label. A // negatives (accepting a rogue wildcard) silently break
// CA that mis-issues such a wildcard could impersonate any // hostname authentication. The full PSL would close the
// co-tenant. The mitigation is partial: the WebPKI ecosystem // remaining gap; until then, this catches the high-volume
// already requires CAs to consult the PSL when issuing, so a // attack surfaces.
// rogue cert is unlikely to make it past CT logging — but if val suffixWithoutLeadingDot = suffix.removePrefix(".")
// one does, our validation accepts it. if (suffixWithoutLeadingDot in MULTI_LABEL_PUBLIC_SUFFIXES) return false
//
// 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 == '.' } val suffixDots = suffix.count { it == '.' }
return suffixDots >= 2 return suffixDots >= 2
} }
companion object { companion object {
/**
* Hand-picked subset of the Mozilla Public Suffix List covering
* common multi-label effective-TLDs. Wildcards spanning these
* suffixes (e.g. `*.co.uk`, `*.s3.amazonaws.com`) MUST be
* rejected per RFC 6125 §6.4.3 a rogue cert that captured
* such a wildcard would impersonate every co-tenant.
*
* Strict subset of the full PSL we don't ship the ~9000-entry
* data file; entries here cover the high-frequency attack
* surfaces (multi-tenant ccTLDs, major hosting platforms). The
* full PSL would close the remaining gap; for now, callers of
* sensitive endpoints should still layer OS-level pinning.
*
* Sources:
* - Top multi-label ccTLDs from publicsuffix.org/list/
* (uk / au / nz / jp / kr / br / mx / in / ar / il / etc.)
* - Major hosting platforms whose tenant subdomains all share
* one cert root (s3.amazonaws.com, github.io, herokuapp.com,
* vercel.app, netlify.app, web.app, blogspot.com,
* appspot.com, pages.dev, workers.dev, etc.)
*/
private val MULTI_LABEL_PUBLIC_SUFFIXES: Set<String> =
setOf(
// UK
"co.uk",
"org.uk",
"ac.uk",
"gov.uk",
"ltd.uk",
"plc.uk",
"me.uk",
"net.uk",
"sch.uk",
"nhs.uk",
"police.uk",
// AU
"com.au",
"net.au",
"org.au",
"edu.au",
"gov.au",
"asn.au",
"id.au",
// NZ
"co.nz",
"net.nz",
"org.nz",
"ac.nz",
"govt.nz",
"school.nz",
// JP
"co.jp",
"ne.jp",
"or.jp",
"ac.jp",
"ad.jp",
"ed.jp",
"go.jp",
"gr.jp",
"lg.jp",
// KR
"co.kr",
"ne.kr",
"or.kr",
"re.kr",
"ac.kr",
"go.kr",
"mil.kr",
"sc.kr",
// BR
"com.br",
"net.br",
"org.br",
"edu.br",
"gov.br",
"mil.br",
// MX
"com.mx",
"net.mx",
"org.mx",
"edu.mx",
"gob.mx",
// IN
"co.in",
"net.in",
"org.in",
"edu.in",
"gov.in",
"ac.in",
"res.in",
// AR
"com.ar",
"net.ar",
"org.ar",
"edu.ar",
"gov.ar",
"gob.ar",
"mil.ar",
// IL
"co.il",
"net.il",
"org.il",
"ac.il",
"gov.il",
"muni.il",
"k12.il",
// ZA
"co.za",
"net.za",
"org.za",
"ac.za",
"gov.za",
"edu.za",
"law.za",
// CN
"com.cn",
"net.cn",
"org.cn",
"edu.cn",
"gov.cn",
"ac.cn",
"mil.cn",
// TR
"com.tr",
"net.tr",
"org.tr",
"edu.tr",
"gov.tr",
"biz.tr",
"info.tr",
// RU
"com.ru",
"net.ru",
"org.ru",
"pp.ru",
"msk.ru",
"spb.ru",
// PL
"com.pl",
"net.pl",
"org.pl",
"edu.pl",
"gov.pl",
"mil.pl",
// ES
"com.es",
"nom.es",
"org.es",
"gob.es",
"edu.es",
// HK / SG / TW / MY
"com.hk",
"net.hk",
"org.hk",
"edu.hk",
"gov.hk",
"idv.hk",
"com.sg",
"net.sg",
"org.sg",
"edu.sg",
"gov.sg",
"per.sg",
"com.tw",
"net.tw",
"org.tw",
"edu.tw",
"gov.tw",
"idv.tw",
"com.my",
"net.my",
"org.my",
"edu.my",
"gov.my",
"mil.my",
// Major hosting platforms — all tenants share root cert paths.
"github.io",
"github.com",
"s3.amazonaws.com",
"compute.amazonaws.com",
"blogspot.com",
"blogspot.co.uk",
"blogspot.de",
"blogspot.fr",
"appspot.com",
"googleapis.com",
"googleusercontent.com",
"herokuapp.com",
"herokussl.com",
"vercel.app",
"now.sh",
"netlify.app",
"netlify.com",
"web.app",
"firebaseapp.com",
"pages.dev",
"workers.dev",
"azurewebsites.net",
"cloudapp.net",
"trafficmanager.net",
"fastly.net",
"fastlylb.net",
"cloudfront.net",
"ngrok.io",
"ngrok.app",
"execute-api.us-east-1.amazonaws.com",
)
private fun defaultTrustManager(): X509TrustManager { private fun defaultTrustManager(): X509TrustManager {
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(null as KeyStore?) tmf.init(null as KeyStore?)