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.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
/**
* Owns the UDP socket and runs the read + send loops for a [QuicConnection].
@@ -86,6 +87,12 @@ class QuicConnectionDriver(
}
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) {
connection.lock.withLock {
while (true) {
@@ -93,8 +100,22 @@ class QuicConnectionDriver(
socket.send(out)
}
}
// Suspend until the next wakeup — no busy polling.
sendWakeup.receive()
// Suspend until either: a wakeup arrives, or the PTO timer expires.
// 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) {
if (bytes.isEmpty()) return
// Compact + grow.
if (pos > 0) {
// Amortized compaction: only shift bytes down when the consumed
// 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)
pos = 0
}
@@ -292,26 +292,49 @@ object QpackHuffman {
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. */
fun decode(encoded: ByteArray): ByteArray {
val result = ArrayList<Byte>()
val result = ArrayList<Byte>(encoded.size * 2) // rough upper bound
var bitBuf = 0L
var bitsAvailable = 0
var i = 0
while (i < encoded.size || bitsAvailable >= 5) {
// Pull more bits
// Pull more bits.
while (bitsAvailable < 32 && i < encoded.size) {
bitBuf = (bitBuf shl 8) or (encoded[i].toLong() and 0xFF)
bitsAvailable += 8
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
for (sym in 0..255) {
val (code, len) = Pair(table[sym][0], table[sym][1])
if (len > bitsAvailable) continue
val candidate = (bitBuf ushr (bitsAvailable - len)) and ((1L shl len) - 1)
if (candidate.toInt() == code) {
for (len in lengths) {
if (len > bitsAvailable) break
val candidate = ((bitBuf ushr (bitsAvailable - len)) and ((1L shl len) - 1)).toInt()
val sym = byLength[len][candidate]
if (sym != null) {
result.add(sym.toByte())
bitsAvailable -= len
bitBuf = bitBuf and ((1L shl bitsAvailable) - 1)
@@ -71,7 +71,8 @@ class CapsuleReader {
fun push(bytes: ByteArray) {
if (bytes.isEmpty()) return
if (pos > 0) {
// Amortized compaction (same pattern as Http3FrameReader).
if (pos * 2 > buf.size) {
buf = buf.copyOfRange(pos, buf.size)
pos = 0
}
@@ -22,6 +22,8 @@ package com.vitorpamplona.quic.tls
import com.vitorpamplona.quic.QuicCodecException
import java.io.ByteArrayInputStream
import java.net.IDN
import java.net.InetAddress
import java.security.KeyStore
import java.security.Signature
import java.security.cert.CertificateFactory
@@ -133,18 +135,41 @@ class JdkCertificateValidator(
cert: X509Certificate,
host: String,
): Boolean {
// SAN check — RFC 6125. Walk subject alt names and accept any DNS or IP match.
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) {
val type = entry[0] as Int
val value = entry[1].toString()
// GeneralName type 2 = dNSName, type 7 = iPAddress.
if (type == 2 && dnsMatches(value, host)) return true
if (type == 7 && value.equals(host, ignoreCase = true)) return true
if (type == 2 && dnsMatches(idnAscii(value), normalizedHost)) 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
}
private fun idnAscii(name: String): String =
try {
IDN.toASCII(name).lowercase()
} catch (_: Throwable) {
name.lowercase()
}
private fun dnsMatches(
pattern: String,
host: String,
@@ -156,7 +181,13 @@ class JdkCertificateValidator(
val lhost = host.lowercase()
if (!lhost.endsWith(suffix)) return false
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 {