mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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:
+23
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user