diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt index 7d0c3bf65f..579c2583b2 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt @@ -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 + } } } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameReader.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameReader.kt index 053de4e9fa..b7689d9f8f 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameReader.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameReader.kt @@ -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 } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackHuffman.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackHuffman.kt index 4f1ff452b4..58687b26a6 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackHuffman.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/qpack/QpackHuffman.kt @@ -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> = 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> { + val out = Array(31) { HashMap() } + 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() + val result = ArrayList(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) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtCapsule.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtCapsule.kt index e956961d6d..9a9ba9259e 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtCapsule.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtCapsule.kt @@ -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 } diff --git a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/tls/JdkCertificateValidator.kt b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/tls/JdkCertificateValidator.kt index e38a6bb26b..00e8e7504e 100644 --- a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/tls/JdkCertificateValidator.kt +++ b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/tls/JdkCertificateValidator.kt @@ -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 {