fix(quic): PTO timer + IDN/IPv6 hostname + Huffman lookup table + amortized compaction

Closes the remaining items from the round-2 audit.

Driver send-loop PTO timer:
  Loop suspends on `withTimeoutOrNull(ptoMillis) { sendWakeup.receive() }`
  instead of unconditional receive. PTO doubles on each consecutive timeout
  up to 60s; resets to 1s on any wakeup.
  Without this, a single lost ClientHello with no inbound traffic to drive
  wakeups left the connection wedged forever. The PTO wake now gives the
  writer a chance to re-emit on retransmission.

JdkCertificateValidator hostname matching:
  - IDN.toASCII normalization on both the cert SAN and the input host so
    a SAN of `xn--bcher-kva.de` matches an input of `bücher.de`.
  - IPv6 literals compared via InetAddress.getByName().hostAddress so
    `::1` and `0:0:0:0:0:0:0:1` compare equal.
  - Wildcard public-suffix-label rejection: requires the suffix to contain
    at least 2 dots, so `*.com` is rejected even if a misbehaving CA
    issued such a cert.

QpackHuffman.decode O(N×256) → O(N×L) where L is the number of distinct
code lengths (≤ 26):
  Previously the inner loop scanned all 256 symbols per output byte. Now
  it walks length buckets in ascending order and does a HashMap lookup at
  each. For ASCII text the hot path matches at length 5-8.

Http3FrameReader.push + CapsuleReader.push amortized compaction:
  Was O(N) memcpy on every push → O(N²) over the lifetime of a long-lived
  stream. Now compacts only when consumed prefix is at least half the
  buffer, giving amortized O(1) per byte.

All :quic:jvmTest + :nestsClient:jvmTest pass.

End of round-2 audit fixes. The branch now passes:
  - Every RFC 9001 Appendix A test vector bit-for-bit
  - The full in-memory client+server pipe handshake
  - Hostile-input fuzzing of decodeFrames
  - Coalesced-packets ACK regression
  - Multi-cipher-suite (AES-GCM + ChaCha20-Poly1305) handshake
  - Negative-path TLS rejection (session_id_echo, version, group, missing
    extensions)

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 22:52:19 +00:00
parent 8f5280c9c3
commit bd192fc649
5 changed files with 95 additions and 17 deletions
@@ -29,6 +29,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
/** /**
* Owns the UDP socket and runs the read + send loops for a [QuicConnection]. * Owns the UDP socket and runs the read + send loops for a [QuicConnection].
@@ -86,6 +87,12 @@ class QuicConnectionDriver(
} }
private suspend fun sendLoop() { private suspend fun sendLoop() {
// PTO budget: how long the loop will sleep before waking itself to
// check for retransmission opportunities. RFC 9002 §6.2 — initial
// PTO is roughly 3 × (smoothed RTT + max_ack_delay). We don't track
// RTT yet, so use a conservative fixed value that doubles on each
// consecutive timeout (Exponential backoff caps after ~6 timeouts).
var ptoMillis = 1_000L
while (connection.status != QuicConnection.Status.CLOSED) { while (connection.status != QuicConnection.Status.CLOSED) {
connection.lock.withLock { connection.lock.withLock {
while (true) { while (true) {
@@ -93,8 +100,22 @@ class QuicConnectionDriver(
socket.send(out) socket.send(out)
} }
} }
// Suspend until the next wakeup — no busy polling. // Suspend until either: a wakeup arrives, or the PTO timer expires.
sendWakeup.receive() // The PTO wake ensures a single lost ClientHello doesn't wedge
// the connection forever — eventually the loop wakes, the writer
// re-emits Initial CRYPTO that's still in the send buffer (since
// we don't free it until ACK), and the handshake retries.
val woke =
withTimeoutOrNull(ptoMillis) {
sendWakeup.receive()
Unit
}
ptoMillis =
if (woke == null) {
(ptoMillis * 2).coerceAtMost(60_000L)
} else {
1_000L
}
} }
} }
@@ -43,8 +43,10 @@ class Http3FrameReader {
fun push(bytes: ByteArray) { fun push(bytes: ByteArray) {
if (bytes.isEmpty()) return if (bytes.isEmpty()) return
// Compact + grow. // Amortized compaction: only shift bytes down when the consumed
if (pos > 0) { // prefix is at least half the buffer. Otherwise we'd do O(N) memcpy
// on every chunk → O(N²) total over a long stream.
if (pos * 2 > buf.size) {
buf = buf.copyOfRange(pos, buf.size) buf = buf.copyOfRange(pos, buf.size)
pos = 0 pos = 0
} }
@@ -292,26 +292,49 @@ object QpackHuffman {
intArrayOf(0x3fffffff, 30), intArrayOf(0x3fffffff, 30),
) )
/**
* Lookup tables grouped by code length. `byLength[L]` is a HashMap from
* `code` (an Int up to 30 bits) to `symbol` (0..255) for all symbols
* whose Huffman code is exactly L bits long. Lengths used in the table
* range from 5 to 30. We omit the EOS (length 30, code 0x3FFFFFFF) since
* it must never appear in valid input.
*/
private val byLength: Array<HashMap<Int, Int>> = buildLookupByLength()
/** Sorted ascending list of distinct code lengths actually used by the table. */
private val lengths: IntArray = byLength.indices.filter { byLength[it].isNotEmpty() }.toIntArray()
private fun buildLookupByLength(): Array<HashMap<Int, Int>> {
val out = Array(31) { HashMap<Int, Int>() }
for (sym in 0..255) {
val code = table[sym][0]
val len = table[sym][1]
out[len][code] = sym
}
return out
}
/** Decode a Huffman-encoded byte sequence into a UTF-8 string. */ /** Decode a Huffman-encoded byte sequence into a UTF-8 string. */
fun decode(encoded: ByteArray): ByteArray { fun decode(encoded: ByteArray): ByteArray {
val result = ArrayList<Byte>() val result = ArrayList<Byte>(encoded.size * 2) // rough upper bound
var bitBuf = 0L var bitBuf = 0L
var bitsAvailable = 0 var bitsAvailable = 0
var i = 0 var i = 0
while (i < encoded.size || bitsAvailable >= 5) { while (i < encoded.size || bitsAvailable >= 5) {
// Pull more bits // Pull more bits.
while (bitsAvailable < 32 && i < encoded.size) { while (bitsAvailable < 32 && i < encoded.size) {
bitBuf = (bitBuf shl 8) or (encoded[i].toLong() and 0xFF) bitBuf = (bitBuf shl 8) or (encoded[i].toLong() and 0xFF)
bitsAvailable += 8 bitsAvailable += 8
i++ i++
} }
// Try matching the longest code we can // Try matching at each used code length, ascending. The first
// match wins (Huffman is a prefix code, so this is unambiguous).
var matched = false var matched = false
for (sym in 0..255) { for (len in lengths) {
val (code, len) = Pair(table[sym][0], table[sym][1]) if (len > bitsAvailable) break
if (len > bitsAvailable) continue val candidate = ((bitBuf ushr (bitsAvailable - len)) and ((1L shl len) - 1)).toInt()
val candidate = (bitBuf ushr (bitsAvailable - len)) and ((1L shl len) - 1) val sym = byLength[len][candidate]
if (candidate.toInt() == code) { if (sym != null) {
result.add(sym.toByte()) result.add(sym.toByte())
bitsAvailable -= len bitsAvailable -= len
bitBuf = bitBuf and ((1L shl bitsAvailable) - 1) bitBuf = bitBuf and ((1L shl bitsAvailable) - 1)
@@ -71,7 +71,8 @@ class CapsuleReader {
fun push(bytes: ByteArray) { fun push(bytes: ByteArray) {
if (bytes.isEmpty()) return if (bytes.isEmpty()) return
if (pos > 0) { // Amortized compaction (same pattern as Http3FrameReader).
if (pos * 2 > buf.size) {
buf = buf.copyOfRange(pos, buf.size) buf = buf.copyOfRange(pos, buf.size)
pos = 0 pos = 0
} }
@@ -22,6 +22,8 @@ package com.vitorpamplona.quic.tls
import com.vitorpamplona.quic.QuicCodecException import com.vitorpamplona.quic.QuicCodecException
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.net.IDN
import java.net.InetAddress
import java.security.KeyStore import java.security.KeyStore
import java.security.Signature import java.security.Signature
import java.security.cert.CertificateFactory import java.security.cert.CertificateFactory
@@ -133,18 +135,41 @@ class JdkCertificateValidator(
cert: X509Certificate, cert: X509Certificate,
host: String, host: String,
): Boolean { ): Boolean {
// SAN check — RFC 6125. Walk subject alt names and accept any DNS or IP match.
val sans = cert.subjectAlternativeNames ?: return false val sans = cert.subjectAlternativeNames ?: return false
// Normalize host once: IDN → ASCII for DNS comparison, parsed-and-
// re-stringified for IP literals so v6 forms compare equal.
val normalizedHost = idnAscii(host)
val hostAsIp =
try {
InetAddress.getByName(host).hostAddress
} catch (_: Throwable) {
null
}
for (entry in sans) { for (entry in sans) {
val type = entry[0] as Int val type = entry[0] as Int
val value = entry[1].toString() val value = entry[1].toString()
// GeneralName type 2 = dNSName, type 7 = iPAddress. // GeneralName type 2 = dNSName, type 7 = iPAddress.
if (type == 2 && dnsMatches(value, host)) return true if (type == 2 && dnsMatches(idnAscii(value), normalizedHost)) return true
if (type == 7 && value.equals(host, ignoreCase = true)) return true if (type == 7 && hostAsIp != null) {
val sanIp =
try {
InetAddress.getByName(value).hostAddress
} catch (_: Throwable) {
null
}
if (sanIp != null && sanIp.equals(hostAsIp, ignoreCase = true)) return true
}
} }
return false return false
} }
private fun idnAscii(name: String): String =
try {
IDN.toASCII(name).lowercase()
} catch (_: Throwable) {
name.lowercase()
}
private fun dnsMatches( private fun dnsMatches(
pattern: String, pattern: String,
host: String, host: String,
@@ -156,7 +181,13 @@ class JdkCertificateValidator(
val lhost = host.lowercase() val lhost = host.lowercase()
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)
return prefix.isNotEmpty() && '.' !in prefix if (prefix.isEmpty() || '.' in prefix) return false
// RFC 6125 §6.4.3 — disallow wildcards in the public-suffix label.
// Heuristic: require ≥ 2 dots in the suffix (e.g. *.example.com is OK,
// *.com is not). Conservative; doesn't consult the actual PSL but
// matches what most browsers do for non-PSL-aware certs.
val suffixDots = suffix.count { it == '.' }
return suffixDots >= 2
} }
companion object { companion object {