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,
cause: Throwable? = null,
) : RuntimeException(message, cause)
@@ -73,9 +73,18 @@ fun packetProtectionFromSecret(
// which avoids `Cipher.getInstance` per-packet (audit-1, audit-3 finding).
val aead: Aead =
when (cipherSuite) {
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 -> bestAes128GcmAead(keys.key)
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256 -> com.vitorpamplona.quic.crypto.ChaCha20Poly1305Aead
else -> error("unreachable")
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 -> {
bestAes128GcmAead(keys.key)
}
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)
}
@@ -253,6 +253,18 @@ class QuicConnection(
@Volatile
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
* the TLS layer derives `client_early_traffic_secret` (right after
@@ -572,6 +584,22 @@ class QuicConnection(
com.vitorpamplona.quic.connection.recovery
.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
* 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
* to mirror back in the same phase.
*
* RFC 9001 §6.5 says an endpoint MUST NOT initiate a key update
* before the handshake is confirmed (HANDSHAKE_DONE received). The
* caller is responsible for that check; this method just performs
* the rotation. §6.4 also forbids initiating a second update before
* the current one has been confirmed (peer responds in matching
* phase) — same caller contract.
* Spec invariants enforced here (caller no longer responsible):
* - RFC 9001 §6.5: returns false if [handshakeComplete] is not
* yet true. Initiating before handshake confirmation is a
* PROTOCOL_VIOLATION.
* - RFC 9001 §6.4: returns false if a previous rotation is still
* 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
* rotated when keys are updated).
@@ -1864,6 +1894,16 @@ class QuicConnection(
* and server".
*/
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 curRx = appReceiveSecret ?: return false
val curTx = appSendSecret ?: return false
@@ -1907,6 +1947,7 @@ class QuicConnection(
appSendSecret = nextTxSecret
currentReceiveKeyPhase = !currentReceiveKeyPhase
currentSendKeyPhase = !currentSendKeyPhase
keyUpdateInProgress = true
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION)
return true
@@ -67,6 +67,10 @@ import com.vitorpamplona.quic.tls.TlsClient
* under streamsLock so frame-dispatch / stream creation / level state
* 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(
conn: QuicConnection,
datagram: ByteArray,
@@ -436,6 +440,16 @@ private fun feedShortHeaderPacket(
if (rotateOnSuccess != null && parsed.packet.packetNumber > state.pnSpace.largestReceived) {
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)
if (conn.qlogObserver !== com.vitorpamplona.quic.observability.QlogObserver.NoOp) {
conn.qlogObserver.onPacketReceived(
@@ -770,9 +784,17 @@ private fun dispatchFrames(
// RFC 9000 §4.5: the [finalSize] in RESET_STREAM MUST agree
// with any final size implied by previously-received STREAM
// frames AND MUST be ≥ the highest offset already observed.
// A peer that violates this is closed with FINAL_SIZE_ERROR
// — pre-fix we accepted any value silently, letting a buggy
// peer drift our state.
// §4.5 also caps final size at 2^62-1 (the QUIC offset field
// ceiling). A peer that violates any of these is closed with
// 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)
if (target != null) {
val priorFin = target.receive.finOffset
@@ -74,8 +74,6 @@ fun drainOutbound(
conn: QuicConnection,
nowMillis: Long,
): ByteArray? {
val parts = mutableListOf<ByteArray>()
// Closing — emit a CONNECTION_CLOSE at the highest available level. The
// datagram-build paths below MUST satisfy two RFC 9000 constraints we
// got wrong before:
@@ -601,7 +599,12 @@ private fun buildApplicationPacket(
// settled mid-handshake (peer hasn't ACK'd anything yet), so the
// walk is a no-op.
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
// frame contributes a [RecoveryToken] so the [SentPacket] recorded
// 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
// not contribute a token; the packet is still ack-eliciting and
// 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
// 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.
if (use1Rtt) {
state.ackTracker.buildAckFrame(nowMillis, conn.config.ackDelayExponent.toInt())?.let { plainAck ->
// RFC 9000 §13.4.2: an endpoint that USES ECN on outbound
// packets (we set ECT(0) on every datagram via the socket's
// IP_TOS option) MUST report ECN counts in 1-RTT ACK frames so
// the peer can detect path congestion. We don't currently
// read inbound TOS bits — JDK's DatagramChannel doesn't expose
// them without JNI — so the counts are all-zero. The interop
// runner's `ecn` testcase only checks for the field's presence
// (`hasattr(p["quic"], "ack.ect0_count")`); strict peers that
// cross-validate counts would reject this, but aioquic /
// picoquic / quic-go all tolerate it. Initial / Handshake-space
// ACKs stay plain (ecnCounts=null) — the spec allows ECN counts
// there too, but interop implementations don't always handle
// them and we'd gain nothing.
val ackWithEcn =
AckFrame(
largestAcknowledged = plainAck.largestAcknowledged,
ackDelay = plainAck.ackDelay,
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)
// RFC 9000 §13.4.2: an endpoint that USES ECN MUST report
// accurate ECN counts. Pre-fix we hardcoded
// `AckEcnCounts(0, 0, 0)` while still marking outbound
// datagrams with ECT(0) — a strict peer cross-validating
// counts could treat the all-zero report as a
// PROTOCOL_VIOLATION, since we claim to be using ECN but
// never accumulate the counts. We don't read inbound TOS
// (JDK's DatagramChannel doesn't expose it without JNI),
// so honest reporting means: don't claim to track ECN at
// all — emit ACK with `ecnCounts = null`. The peer reads
// that as "this endpoint isn't reporting ECN" and skips
// its own ECN-driven congestion logic for our direction.
// We still mark outbound ECT(0) (other peers' tracking
// benefits from the path-quality signal); the asymmetry
// is allowed by §13.4.
frames += plainAck
tokens += RecoveryToken.Ack(level = EncryptionLevel.APPLICATION, largestAcked = plainAck.largestAcknowledged)
}
}
@@ -725,7 +720,17 @@ private fun buildApplicationPacket(
val sorted =
when {
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 }
}
val rotation = conn.streamRoundRobinStart
@@ -86,6 +86,41 @@ abstract class Aead {
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
* `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.
*/
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()
// 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 ciphertext = aead.seal(key, nonce, headerBytes, paddedPlaintext)
// Concatenate header + ciphertext
val packet = ByteArray(headerBytes.size + ciphertext.size)
val packet = ByteArray(headerBytes.size + paddedPlaintext.size + aead.tagLength)
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.
val sampleStart = pnOffset + 4
@@ -75,12 +75,26 @@ object ShortHeaderPacket {
// Trailing 0x00 bytes decode as PADDING frames (RFC 9000 §19.1).
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 ciphertext = aead.seal(key, nonce, headerBytes, paddedPlaintext)
val packet = ByteArray(headerBytes.size + ciphertext.size)
val packet = ByteArray(headerBytes.size + paddedPlaintext.size + aead.tagLength)
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
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.flow.Flow
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.flow
/**
* 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
* 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.
* **Resilience to collector cancellation.** Pre-fix this exposed
* [incomingChannel] via `consumeAsFlow()`, which cancels the
* underlying [Channel] when its collector terminates. That coupled
* "application stopped collecting" to "parser INTERNAL_ERRORs the
* whole connection on next delivery" — cancelling the collector
* early (e.g. `withTimeout(...)`) was effectively a request to
* drop the connection. Rebuilt as `flow { for (c in
* incomingChannel) emit(c) }`: a fresh emit-only Flow over the
* channel iterator, with no `consume`-style ownership transfer.
* Now collector cancellation just exits the flow without touching
* the channel; the channel stays open, the parser keeps
* delivering, and the application can re-collect later (a fresh
* collector picks up at `channel.receive()` i.e. from the
* current head, not the start).
*
* 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.
* Producer back-pressure unchanged: the channel buffer is still
* 64 chunks, [trySend] still sets [overflowed] on saturation,
* and the parser still closes the connection if the application
* fails to drain. The looser contract just permits the previously-
* disallowed pattern of "stop reading temporarily, then resume".
*
* 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)
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
@@ -128,10 +130,18 @@ class QuicStream(
* writer's appendFlowControlUpdates consumes it to skip streams that
* 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
* (audit-4 perf #9 O(streams) × ~50 drains/sec; significant for audio
* rooms with many WT streams).
*/
@Volatile
internal var receiveDirtyForFlowControl: Boolean = false
/** 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()
/**
* 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 _finSent: Boolean = false
private var _finAcked: Boolean = false
@@ -154,9 +169,7 @@ class SendBuffer(
val readableBytes: Int
get() =
synchronized(this) {
var sum = 0L
for (r in retransmit) sum += r.length
sum += (_nextOffset - nextSendOffset)
val sum = retransmitTotalBytes + (_nextOffset - nextSendOffset)
sum.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
}
@@ -210,16 +223,18 @@ class SendBuffer(
val take = minOf(retransmitHead.length, cap.toLong())
val payload = sliceAt(retransmitHead.offset, take.toInt())
retransmit.removeFirst()
retransmitTotalBytes -= retransmitHead.length
val fin = retransmitHead.fin && take == retransmitHead.length
if (take < retransmitHead.length) {
// Push remainder back at the head, preserving offset.
retransmit.addFirst(
val remainder =
Range(
offset = retransmitHead.offset + take,
length = retransmitHead.length - take,
fin = retransmitHead.fin,
),
)
)
retransmit.addFirst(remainder)
retransmitTotalBytes += remainder.length
}
addToInFlight(Range(retransmitHead.offset, take, fin))
if (fin) _finSent = true
@@ -351,13 +366,29 @@ class SendBuffer(
}
// Move every inflight range to the retransmit queue,
// preserving offset order (inFlight is sorted by offset
// ascending, so addLast preserves sort within retransmit
// for these new entries — though retransmit is a FIFO
// and doesn't strictly require sorted order, takeChunk
// pops front-first regardless).
// ascending). Coalesce adjacent ranges on insert: if the
// previous tail entry's `offset + length` equals the
// current's `offset`, merge them into a single range. The
// 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) {
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()
}
@@ -405,6 +436,7 @@ class SendBuffer(
OverlapAction.RETRANSMIT -> {
retransmit.addLast(r)
retransmitTotalBytes += r.length
}
OverlapAction.DROP -> {} // discard
@@ -482,6 +514,7 @@ class SendBuffer(
fin = coveredFin,
),
)
retransmitTotalBytes += coveredLen
}
OverlapAction.DROP -> {} // discard the covered piece
@@ -361,39 +361,47 @@ class TlsClient(
// 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",
)
// policy mismatch).
//
// Recover in-place WITHOUT a transcript rebuild.
// The on-wire ClientHello carries the
// pre_shared_key extension and binder bytes;
// both client and server hash the FULL
// ClientHello (binders included) into their
// running transcript hash regardless of accept
// / reject (RFC 8446 §4.2.11). So the
// transcript itself stays valid — only the
// [earlySecret] derivation changes:
// * accepted: early_secret = HKDF.extract(0, PSK)
// * rejected: early_secret = HKDF.extract(0, 0)
// [deriveHandshake] then folds early_secret +
// ECDHE shared secret into [handshakeSecret];
// every downstream key follows. We just have
// to overwrite earlySecret here BEFORE
// [deriveHandshake] runs below.
//
// Any 0-RTT packets the application already
// emitted under the PSK-derived
// [clientEarlyTrafficSecret] are lost: the
// 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) {
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. */
interface TlsSecretsListener {
fun onHandshakeKeysReady(
@@ -685,7 +677,23 @@ interface TlsSecretsListener {
* [ticketAgeAdd] and [issuedAtMillis] to compute the obfuscated_ticket_age
* 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. */
val ticket: ByteArray,
/** 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,
/** Negotiated ALPN from the prior connection. 0-RTT must use the same protocol. */
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. */
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 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)
/**
* 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
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.
// 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)
// 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.
// This is intentionally conservative — false positives
// (rejecting a legitimate but unusual wildcard) are recoverable
// by the application using OS-level pinning, while false
// negatives (accepting a rogue wildcard) silently break
// hostname authentication. The full PSL would close the
// remaining gap; until then, this catches the high-volume
// attack surfaces.
val suffixWithoutLeadingDot = suffix.removePrefix(".")
if (suffixWithoutLeadingDot in MULTI_LABEL_PUBLIC_SUFFIXES) return false
val suffixDots = suffix.count { it == '.' }
return suffixDots >= 2
}
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 {
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(null as KeyStore?)