Merge remote-tracking branch 'origin/main' into claude/t16-nestsclient-closure-1zBIc

This commit is contained in:
Claude
2026-05-07 20:09:04 +00:00
12 changed files with 817 additions and 61 deletions
@@ -21,6 +21,7 @@
package com.vitorpamplona.nestsclient.audio
import kotlinx.coroutines.runBlocking
import org.junit.Assume.assumeTrue
import kotlin.test.Test
/**
@@ -37,8 +38,18 @@ class JvmOpusRoundTripTest {
@Test
fun sine_440_round_trips_through_libopus() {
val capture = SineWaveAudioCapture(freqHz = 440)
val encoder = JvmOpusEncoder()
val decoder = JvmOpusDecoder()
// club.minnced:opus-java doesn't ship natives for darwin-aarch64 (Apple
// Silicon). Skip rather than fail so dev machines without a usable
// native still pass the pre-push hook; Linux x86_64 CI keeps coverage.
val encoder: JvmOpusEncoder
val decoder: JvmOpusDecoder
try {
encoder = JvmOpusEncoder()
decoder = JvmOpusDecoder()
} catch (e: IllegalStateException) {
assumeTrue("Opus natives not available: ${e.message}", false)
return
}
try {
val decoded = mutableListOf<Float>()
runBlocking {
+14
View File
@@ -15,6 +15,20 @@ set -e
case "${ROLE:-client}" in
client)
# Wait for the simulator's readiness port BEFORE first send.
# The sim sets up ns3 + tcpdump capture asynchronously; until
# sim:57832 opens, packets we send are blackholed and never
# show up in the pcap. The longrtt test in particular checks
# for ≥2 ClientHellos in the trace — if our first Initial leaves
# before sim is capturing, only the PTO retransmit lands in the
# pcap and the test fails with "Expected at least 2 ClientHellos".
# aioquic, picoquic, quic-go all gate their client launch on
# this same probe. Skip if the wait helper isn't on PATH so
# off-runner invocations (no sim) keep working.
if [ -x /wait-for-it.sh ]; then
/wait-for-it.sh sim:57832 -s -t 30 || \
echo "(wait-for-it sim:57832 timed out; continuing anyway)" >&2
fi
exec /opt/quic-interop/bin/quic-interop
;;
server)
@@ -180,7 +180,7 @@ fun main() {
// ipv6 — same flow over an IPv6 socket;
// JDK DatagramChannel.connect handles
// the v6 address resolution natively.
"handshake", "chacha20", "handshakeloss",
"handshake", "chacha20",
"transfer", "http3", "multiplexing",
"transferloss", "transfercorruption", "longrtt", "goodput", "crosstraffic",
"retry", "ipv6",
@@ -216,6 +216,26 @@ fun main() {
)
}
// The runner reuses TESTCASE_CLIENT=multiconnect for the
// handshakeloss + handshakecorruption tests (see
// testcases_quic.py:746). Each URL must be fetched on a fresh
// QUIC connection — the testcase verifies via tshark that
// the pcap contains _num_runs (50) distinct handshakes. We
// could not satisfy this through runTransferTest (one
// connection, many GETs); fixing required a separate per-
// URL connection loop.
"multiconnect" -> {
runMulticonnectTest(
requests = requests,
downloadsDir = downloadsDir,
cipherSuites = cipherSuites,
offeredAlpns = offeredAlpns,
initialVersion = initialVersion,
keyLogPath = keyLogPath,
qlogDir = qlogDir,
)
}
else -> {
EXIT_UNSUPPORTED
}
@@ -416,11 +436,46 @@ private fun runTransferTest(
"expected_chunks=${(urls.size + MULTIPLEX_PARALLELISM - 1) / MULTIPLEX_PARALLELISM}",
)
}
urls.chunked(MULTIPLEX_PARALLELISM).forEachIndexed { chunkIdx, chunk ->
// Pace stream creation against peer's MAX_STREAMS_BIDI budget.
// Pre-fix this used a fixed [MULTIPLEX_PARALLELISM]=64
// chunk, which exceeded quic-go's tighter
// initial_max_streams_bidi=100 cap on the second chunk
// (cumulative used=128 > limit=108-ish). Throws
// QuicStreamLimitException, kills the test.
// aioquic + picoquic advertise 128 so we never noticed.
//
// Now: each iteration takes the smaller of MULTIPLEX_PARALLELISM
// and the live budget (peer cap minus what we've already
// consumed). When budget=0, poll briefly waiting for the
// peer's MAX_STREAMS_BIDI bump — the peer extends the
// limit as our completed streams retire from their
// bookkeeping.
val totalUrls = urls.size
var chunkIdx = 0
var remaining = urls
while (remaining.isNotEmpty()) {
val budget =
(conn.peerMaxStreamsBidiSnapshot() - conn.localBidiStreamsUsedSnapshot())
.toInt()
.coerceAtLeast(0)
if (budget == 0) {
// Brief idle while peer's MAX_STREAMS catches up. 50 ms is
// arbitrary but small enough that the matrix budget can
// absorb several rounds, large enough that we don't burn
// CPU spinning. If the test budget runs out before the peer
// bumps the cap, the outer withTimeoutOrNull surfaces it
// as transfer_timeout (clear signal vs a deadlock-looking
// hang).
delay(50)
continue
}
val take = minOf(MULTIPLEX_PARALLELISM, budget, remaining.size)
val chunk = remaining.subList(0, take)
remaining = remaining.subList(take, remaining.size)
val chunkStartMs = nowMs()
if (debug && chunkIdx < 3) {
System.err.println(
"[interop] chunk=$chunkIdx size=${chunk.size} starting prepareRequests",
"[interop] chunk=$chunkIdx size=${chunk.size} budget=$budget starting prepareRequests",
)
}
// Single lock-held batch open + enqueue.
@@ -450,9 +505,11 @@ private fun runTransferTest(
"[interop] chunk=$chunkIdx size=${chunk.size} " +
"enqueue=${enqueuedMs - chunkStartMs}ms " +
"responses=${doneMs - enqueuedMs}ms " +
"cumulative=${doneMs - transferStartMs}ms",
"cumulative=${doneMs - transferStartMs}ms " +
"completed=${totalUrls - remaining.size}/$totalUrls",
)
}
chunkIdx += 1
}
collected
} else {
@@ -487,6 +544,168 @@ private fun runTransferTest(
}
}
/**
* One QUIC connection per URL (handshakeloss / handshakecorruption).
*
* The runner's `multiconnect` testcase generates N small files (typ. 50 × 1 KB)
* and expects N independent handshakes in the pcap. Each iteration creates a
* fresh socket + connection + driver, performs a single HQ-interop GET,
* writes the body to /downloads, and closes. The whole loop runs serially —
* concurrency would only help under a much wider test budget than the runner
* gives us, and serial keeps the pcap easy to read.
*
* Per-iteration qlog files land at `$QLOGDIR/client-N.sqlog` so a failed run
* leaves a trace for the specific connection that failed; SSL-key-log lines
* accumulate in a single file (Wireshark de-dupes by client_random).
*/
private fun runMulticonnectTest(
requests: String,
downloadsDir: File,
cipherSuites: IntArray?,
offeredAlpns: List<Alpn>,
initialVersion: Int,
keyLogPath: String?,
qlogDir: File?,
): Int {
val urls =
requests
.split(Regex("\\s+"))
.filter { it.isNotBlank() }
.map { runCatching { URI(it) }.getOrNull() }
.filter { it != null && it.host != null }
.map { it!! }
if (urls.isEmpty()) {
System.err.println("no parseable URL in REQUESTS")
return EXIT_FAIL
}
downloadsDir.mkdirs()
val keyLogger = keyLogPath?.let { SslKeyLogger(File(it)) }
qlogDir?.mkdirs()
System.err.println("[boot] multiconnect: urls=${urls.size}")
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val outcome =
runBlocking {
for ((idx, url) in urls.withIndex()) {
val host = url.host
val port = url.port.takeIf { it > 0 } ?: 443
val authority = if (port == 443) host else "$host:$port"
val socket =
try {
UdpSocket.connect(host, port)
} catch (t: Throwable) {
return@runBlocking "udp_failed[$idx]: ${t.message ?: t::class.simpleName}"
}
val qlogWriter =
qlogDir?.let { dir ->
QlogWriter(file = File(dir, "client-$idx.sqlog"), odcidHex = "client$idx")
}
val conn =
QuicConnection(
serverName = host,
config =
QuicConnectionConfig(
// Same window sizing as runTransferTest;
// multiconnect's per-conn payload is tiny but
// matching keeps RTT-stall behavior identical
// across testcases for easier triangulation.
initialMaxData = 32L * 1024 * 1024,
initialMaxStreamDataBidiLocal = 32L * 1024 * 1024,
initialMaxStreamDataBidiRemote = 32L * 1024 * 1024,
initialMaxStreamDataUni = 32L * 1024 * 1024,
),
tlsCertificateValidator = PermissiveCertificateValidator(),
alpnList = offeredAlpns.map { it.wireBytes },
initialVersion = initialVersion,
cipherSuites =
cipherSuites
?: intArrayOf(
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256,
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256,
),
extraSecretsListener = keyLogger?.listener,
qlogObserver = qlogWriter ?: com.vitorpamplona.quic.observability.QlogObserver.NoOp,
)
val driver = QuicConnectionDriver(conn, socket, scope)
driver.start()
val handshake =
withTimeoutOrNull(HANDSHAKE_TIMEOUT_SEC * 1_000L) {
runCatching { conn.awaitHandshake() }
}
if (handshake == null || handshake.isFailure) {
runCatching { driver.close() }
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
runCatching { qlogWriter?.close() }
return@runBlocking "handshake_failed[$idx]"
}
val negotiated =
conn.tls.negotiatedAlpn
?.decodeToString()
.orEmpty()
val client: GetClient =
when (negotiated) {
"h3" -> {
Http3GetClient(conn, driver).also { it.init(scope) }
}
"hq-interop" -> {
HqInteropGetClient(conn, driver)
}
else -> {
System.err.println("[multiconnect:$idx] unrecognized ALPN '$negotiated'; defaulting to hq-interop")
HqInteropGetClient(conn, driver)
}
}
val resp =
withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) {
client.get(authority, url.path)
}
if (resp == null) {
runCatching { driver.close() }
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
runCatching { qlogWriter?.close() }
return@runBlocking "transfer_timeout[$idx]"
}
if (resp.status != 200) {
System.err.println("[multiconnect:$idx] GET ${url.path} → status ${resp.status}")
runCatching { driver.close() }
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
runCatching { qlogWriter?.close() }
return@runBlocking "request_failed[$idx]"
}
val name = url.path.substringAfterLast('/').ifBlank { "index" }
File(downloadsDir, name).writeBytes(resp.body)
runCatching { driver.close() }
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
runCatching { qlogWriter?.close() }
// Tiny breather so the just-closed driver / socket release
// their resources before the next iteration grabs a new
// ephemeral UDP port. Without this, the kernel occasionally
// hands the same port back before the OS ARP cache has
// settled and the sim drops the first Initial.
delay(50)
}
"ok"
}
scope.cancel()
return if (outcome == "ok") {
EXIT_OK
} else {
System.err.println("multiconnect $outcome")
EXIT_FAIL
}
}
/**
* Writes [NSS Key Log Format](https://firefox-source-docs.mozilla.org/security/nss/legacy/key_log_format/index.html)
* lines so Wireshark can decrypt the sim's captured pcap.
@@ -69,6 +69,17 @@ class QlogWriter(
private val lock = ReentrantLock()
private val startMillis: Long = nowMillis()
/** Last wall-clock millis we flushed at. Periodic flushing avoids the
* "qlog truncates at exactly the BufferedWriter buffer size when the
* JVM is hard-killed at runner timeout" trap that hides the test's
* late behavior. The interval is generous enough to keep the macOS
* Docker filesystem virtualization overhead off the hot send path
* (per-event flush there was multi-ms and broke handshakes — see
* commit 99a1a91de). 250 ms keeps us within ~one cwnd of the
* failure under loss while still being far cheaper than per-event. */
@Volatile
private var lastFlushMillis: Long = startMillis
init {
// qlog 0.3 JSON-SEQ header. qvis tolerates both `qlog_format`
// values "JSON-SEQ" and "NDJSON"; we use JSON-SEQ to match the
@@ -301,16 +312,22 @@ class QlogWriter(
try {
writer.write(line)
writer.write("\n")
// Deliberately NOT flushing per event: this method runs
// inside conn.lock.withLock { drainOutbound(...) } on the
// hot send path. On macOS Docker Desktop the filesystem
// is virtualized and per-event flush is multi-millisecond.
// Every flush stalls the connection lock, blocks the
// receive loop, and the connection silently dies
// mid-handshake. close() does the only flush we need
// for normal completion. A hard-killed JVM loses recent
// events but that's an acceptable trade — the alternative
// is the connection never completing in the first place.
// Periodic flush so a runner-timeout SIGKILL doesn't leave
// the last seconds of the trace stranded in the JVM buffer.
// Pre-fix this method never flushed (relied on close()), and
// a 60 s test ended at exactly 32768 bytes = 4 × 8 KB
// BufferedWriter blocks — masking 50 s of late-connection
// behavior and turning every interop debug session into
// "did the connection wedge or did the qlog?". Per-event
// flush was the prior shape and was multi-ms on macOS
// Docker virtualized filesystems (commit 99a1a91de). 250 ms
// is the compromise: cheap enough to not stall the send
// path, fine-grained enough to capture per-PTO behavior.
val now = nowMillis()
if (now - lastFlushMillis >= FLUSH_INTERVAL_MILLIS) {
writer.flush()
lastFlushMillis = now
}
} catch (_: java.io.IOException) {
// Stream closed under us. Latch closed so subsequent emits
// skip the lock and return immediately.
@@ -320,6 +337,8 @@ class QlogWriter(
}
companion object {
private const val FLUSH_INTERVAL_MILLIS: Long = 250L
private val DEFAULT_MAPPER: ObjectMapper = jacksonObjectMapper()
private fun packetTypeFor(level: EncryptionLevel): String =
@@ -166,6 +166,56 @@ class QuicConnection(
val handshake = LevelState()
val application = LevelState()
/**
* RFC 9001 §6 1-RTT key update — application-level state.
*
* The TLS handshake hands us the initial 1-RTT secrets via
* [onApplicationKeysReady]. From there, EITHER side can roll forward
* to the next-phase secret by computing
* `next = HKDF-Expand-Label(current, "quic ku", "", Hash.length)` —
* QUIC signals the rotation in the per-packet `KEY_PHASE` bit.
*
* - [appReceiveSecret] / [appSendSecret] hold the LIVE secrets in use
* (the keys derived from these are what [application.receiveProtection]
* / [application.sendProtection] hold).
* - [currentReceiveKeyPhase] / [currentSendKeyPhase] track which phase
* those secrets correspond to (false = phase 0, true = phase 1, then
* flipping). The wire bit must match the live keys' phase.
* - [previousReceiveProtection] holds the keys for the PRIOR phase so
* we can decrypt reordered packets that arrive after we've already
* rotated forward (RFC 9001 §6.1: "The recipient SHOULD retain old
* keys for some time after unprotecting a packet sent using the new
* keys"). Cleared on the next rotation.
*
* Initial-/Handshake-level packets carry long headers and are not
* subject to key update — these fields apply only to APPLICATION.
*
* Why we initiate the rotation in lockstep with the peer rather than
* driving it ourselves: when a peer initiates a key update, RFC 9001
* §6.1 requires us to respond with packets in the new phase so they
* can confirm the rotation took effect. We don't proactively initiate
* key updates — there's no safety benefit at our connection scale and
* not initiating means we never have to track per-packet usage limits
* (RFC 9001 §6.6).
*/
@Volatile
internal var appCipherSuite: Int = 0
@Volatile
internal var appReceiveSecret: ByteArray? = null
@Volatile
internal var appSendSecret: ByteArray? = null
@Volatile
internal var currentReceiveKeyPhase: Boolean = false
@Volatile
internal var currentSendKeyPhase: Boolean = false
@Volatile
internal var previousReceiveProtection: PacketProtection? = null
@Volatile
var handshakeComplete: Boolean = false
private set
@@ -429,6 +479,15 @@ class QuicConnection(
) {
application.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret)
application.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret)
// Stash the live secrets + cipher suite so we can derive
// next-phase keys via HKDF-Expand-Label("quic ku") on demand
// when the peer initiates a key update (RFC 9001 §6). Only
// application-level keys are subject to key update —
// Initial / Handshake levels are short-lived and never see
// the KEY_PHASE bit (long headers don't carry it).
appCipherSuite = cipherSuite
appReceiveSecret = serverSecret.copyOf()
appSendSecret = clientSecret.copyOf()
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION)
}
@@ -919,6 +978,26 @@ class QuicConnection(
/** Snapshot of peer-granted uni cap. */
fun peerMaxStreamsUniSnapshot(): Long = peerMaxStreamsUni
/**
* Number of client-initiated bidi streams we've allocated so far —
* the "consumed" side of the [peerMaxStreamsBidiSnapshot] budget.
* Increments on each [openBidiStreamLocked] call and never decreases
* (RFC 9000 §4.6: the limit is on cumulative IDs, not concurrent
* count).
*
* Multiplexing callers use this with [peerMaxStreamsBidiSnapshot] to
* compute the AVAILABLE budget at any moment (`max - used`) so they
* can pace stream creation against peer's MAX_STREAMS_BIDI bumps
* instead of throwing [QuicStreamLimitException] on the cap-tightest
* peer.
*
* Read without [streamsLock] — the field is mutated under
* `streamsLock`, but callers using this for back-pressure decisions
* tolerate a slightly-stale read (worst case: open one fewer stream
* than possible this round, get one more next round).
*/
fun localBidiStreamsUsedSnapshot(): Long = nextLocalBidiIndex
/**
* Coherent point-in-time snapshot of the connection's flow-control
* accounting. Acquires [lock] internally so the fields are read
@@ -1228,6 +1307,180 @@ class QuicConnection(
levelState(level).cryptoSend.requeueAllInflight()
}
/**
* RFC 9002 §6.2.4 PTO probe — STREAM-data analogue of
* [requeueAllInflightCrypto]. Walks every open stream and moves each
* stream's sent-but-not-yet-ACK'd byte ranges back to its retransmit
* queue, so the next [com.vitorpamplona.quic.connection.drainOutbound]
* re-emits the same bytes (at the same offsets, with FIN preserved
* per range).
*
* Why this exists: loss detection ([com.vitorpamplona.quic.connection.recovery.QuicLossDetection.detectAndRemoveLost])
* gates on `pn < largestAckedPn`, which means it never fires when
* the peer hasn't ACK'd ANYTHING in the application space. That
* happens whenever every 1-RTT packet we send is dropped or
* corrupted en route — the peer never sees them, never ACKs, and
* `largestAckedPn` stays null forever. A bare PING from PTO doesn't
* help either: if the PING itself is lost, the peer doesn't see it
* either. Re-queuing the data on every PTO ensures that whenever
* one of our PROBE packets does land, the peer immediately receives
* the application data we'd been trying to send — not an empty PING
* that would need a follow-up RTT to retransmit.
*
* Discovered via `handshakecorruption` against aioquic at 30%
* bit-flip rate: client opens HTTP/3 control + QPACK + GET streams
* in 1-RTT pn=0, gets corrupted, server never decrypts, never ACKs.
* Pre-fix the streams were never retransmitted, the GET stalled,
* the multiconnect iteration timed out at 60 s.
*
* Idempotent: a second consecutive call is a no-op because the first
* call drained `inFlight` empty. Best-effort streams (used by
* audio-rooms, where Opus tolerates gaps) drop their inflight
* ranges instead of re-queueing — see
* [com.vitorpamplona.quic.stream.SendBuffer.requeueAllInflight].
*
* Caller should hold [streamsLock] while iterating; each per-stream
* `requeueAllInflight` is internally `synchronized` on its
* SendBuffer so the actual byte-range moves are race-free even
* under concurrent `takeChunk` from the writer.
*/
internal fun requeueAllInflightStreamData() {
for (stream in streamsList) {
stream.send.requeueAllInflight()
}
}
/**
* RFC 9001 §6.1 key update — rotate application-level keys forward by
* one phase. Called from [com.vitorpamplona.quic.connection.feedShortHeaderPacket]
* once it has positively decrypted a packet whose `KEY_PHASE` bit
* differs from [currentReceiveKeyPhase] using freshly-derived keys.
*
* next_secret = HKDF-Expand-Label(current_secret, "quic ku", "", Hash.length)
* next_key = HKDF-Expand-Label(next_secret, "quic key", "", aead.key_length)
* next_iv = HKDF-Expand-Label(next_secret, "quic iv", "", iv.length)
*
* Header-protection key is NOT updated (RFC 9001 §6.1: "The QUIC header
* is protected using the same packet protection key as the packet
* payload, but the header_protection key is not updated when keys are
* updated").
*
* Caller has already validated that the new-phase keys decrypt the
* triggering packet — we install them as live, demote the prior keys
* to [previousReceiveProtection] for the reorder window, and update
* the send side in lockstep so our next outbound packet carries the
* matching `KEY_PHASE` bit.
*
* Returns the [PacketProtection] the caller should retry-decrypt with
* (the new receive-side keys); null if app keys aren't installed yet
* (handshake hasn't completed) or the cipher suite is unsupported.
*
* Idempotent guard: if [currentReceiveKeyPhase] already matches
* [newPhase] (concurrent path raced us to the rotation), this returns
* the current keys unchanged. Should never happen given the parser is
* single-threaded, but cheap insurance.
*/
internal fun deriveNextPhaseReceiveKeys(): com.vitorpamplona.quic.connection.PacketProtection? {
val current = appReceiveSecret ?: return null
val cs = appCipherSuite.takeIf { it != 0 } ?: return null
// RFC 9001 §6.1 — secret rotation label.
val nextSecret =
com.vitorpamplona.quic.crypto.HKDF
.expandLabel(current, "quic ku", ByteArray(0), current.size)
// Reuse the existing builder; it derives key + iv + hp from a secret,
// and the spec just discards the new HP key (we keep the old one).
val nextProtection =
com.vitorpamplona.quic.connection.packetProtectionFromSecret(
cipherSuite = cs,
secret = nextSecret,
)
// Keep the OLD HP key — RFC 9001 §6.1 forbids rotating it.
val live = application.receiveProtection ?: return null
val rebound =
com.vitorpamplona.quic.connection.PacketProtection(
aead = nextProtection.aead,
key = nextProtection.key,
iv = nextProtection.iv,
hp = live.hp,
hpKey = live.hpKey,
)
// Rotation is committed by the caller after AEAD success — return
// the new keys without mutating state. The caller calls
// [commitKeyUpdate] on success.
return rebound
}
/**
* Commit a successful 1-RTT key rotation. Called by the parser after
* [deriveNextPhaseReceiveKeys] returned keys that decrypted the
* triggering packet. Side effects:
* - Demote the live receive keys to [previousReceiveProtection]
* (kept for the reorder window — packets sent before the peer
* rotated are still tagged with the old KEY_PHASE).
* - Install the next-phase receive keys as live.
* - Flip [currentReceiveKeyPhase].
* - Roll the send-side secret + keys forward in lockstep so our next
* outbound packet carries the matching KEY_PHASE bit. Per RFC 9001
* §6.1 the peer uses our matching-phase response to confirm the
* rotation took effect.
* - Replace the stashed secret with the next-phase secret so a SECOND
* rotation derives off the right base.
*
* The send-side rotation is unconditional — we always echo the peer's
* phase rather than running independent rotation schedules. This is
* spec-compliant (the peer just observes our phase; there's no
* requirement to drive our own rotation independently) and avoids the
* extra plumbing needed to enforce RFC 9001 §6.6 packet-count limits.
*/
internal fun commitKeyUpdate(newReceive: com.vitorpamplona.quic.connection.PacketProtection) {
val live = application.receiveProtection ?: return
previousReceiveProtection = live
application.receiveProtection = newReceive
// Re-derive the receive secret so the NEXT rotation hashes off the
// right base. The receive keys were derived from
// HKDF-Expand-Label(current_secret, "quic ku", ...), so the new
// current_secret is the same expansion.
val cs = appCipherSuite
val curRx = appReceiveSecret
if (curRx != null && cs != 0) {
appReceiveSecret =
com.vitorpamplona.quic.crypto.HKDF
.expandLabel(curRx, "quic ku", ByteArray(0), curRx.size)
}
currentReceiveKeyPhase = !currentReceiveKeyPhase
// Send side: roll forward in lockstep so our next outbound packet
// carries the matching KEY_PHASE bit. Peer's loss-recovery uses our
// matching-phase response as the "rotation confirmed" signal.
val curTx = appSendSecret
if (curTx != null && cs != 0) {
val nextSendSecret =
com.vitorpamplona.quic.crypto.HKDF
.expandLabel(curTx, "quic ku", ByteArray(0), curTx.size)
appSendSecret = nextSendSecret
val freshSend =
com.vitorpamplona.quic.connection.packetProtectionFromSecret(
cipherSuite = cs,
secret = nextSendSecret,
)
val liveSend = application.sendProtection
if (liveSend != null) {
// Reuse old HP key for send too (HP is not rotated).
application.sendProtection =
com.vitorpamplona.quic.connection.PacketProtection(
aead = freshSend.aead,
key = freshSend.key,
iv = freshSend.iv,
hp = liveSend.hp,
hpKey = liveSend.hpKey,
)
currentSendKeyPhase = !currentSendKeyPhase
}
}
qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION)
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
}
/** Caller must hold [lock]. Snapshot of streams for the driver's send loop. */
internal fun streamsLocked(): Map<Long, QuicStream> = streams
@@ -227,18 +227,32 @@ class QuicConnectionDriver(
}
/**
* Spec-correct response to a PTO timer firing (RFC 9002 §6.2.4). Pre-1-RTT
* the probe packet MUST be ack-eliciting at the encryption level with
* unacknowledged data, and SHOULD retransmit the lost data rather than
* emit a bare PING — so we requeue ALL inflight CRYPTO bytes at the
* highest active pre-application level (Initial or Handshake), and the
* next [drainOutbound] emits a CRYPTO frame at the original offset.
* Spec-correct response to a PTO timer firing (RFC 9002 §6.2.4). The probe
* packet MUST be ack-eliciting at the encryption level with unacknowledged
* data, and SHOULD retransmit the lost data rather than emit a bare PING —
* so we requeue inflight CRYPTO bytes at every active pre-application
* level (Initial AND Handshake), and the next [drainOutbound] emits a
* CRYPTO frame at the original offset. `requeueAllInflight` is a no-op
* when nothing is inflight, so calling this for already-ACKed or
* already-discarded levels is harmless.
*
* `pendingPing` stays set as a fallback. `collectHandshakeLevelFrames`
* suppresses the PING when CRYPTO is in the same frame list, so we
* don't waste a frame on top of the retransmit. Post-1-RTT we keep
* the bare-PING behavior — STREAM loss detection drives retransmit
* from the ACK that the PING elicits.
* don't waste a frame on top of the retransmit.
*
* Why every active pre-application level, not just the highest: there's a
* window between 1-RTT keys becoming installed (server's Finished arrives,
* client derives application keys) and the handshake being confirmed
* (server's HANDSHAKE_DONE arrives). In that window our own Finished is
* still in flight at Handshake level, and the application-space loss
* detection that ACK-only PINGs rely on doesn't cover it. If our Finished
* is dropped, the server keeps retransmitting handshake CRYPTO forever
* trying to elicit our missing ACK-eliciting handshake-level packet,
* never confirms the handshake, never sends HANDSHAKE_DONE. Surfaced by
* `handshakeloss` against aioquic at 30% drop rate (multiconnect iter 12
* stuck at t=52s with zero handshake_done events; pre-fix
* `handlePtoFired` gated the requeue on `application.sendProtection ==
* null` and skipped Handshake CRYPTO retransmit once 1-RTT keys existed).
*
* Why aioquic interop demands this: aioquic strictly rejects pre-
* handshake Initials that contain no CRYPTO frame
@@ -263,29 +277,27 @@ class QuicConnectionDriver(
* `requeueAllInflight` operates on the buffer reference we captured
* (or the fresh one — both are valid) and is at worst a no-op.
*/
internal fun handlePtoFired(conn: QuicConnection) {
internal suspend fun handlePtoFired(conn: QuicConnection) {
conn.pendingPing = true
if (conn.application.sendProtection == null) {
val level = highestPreApplicationLevel(conn)
if (level != null) {
conn.requeueAllInflightCrypto(level)
if (conn.handshake.sendProtection != null && !conn.handshake.keysDiscarded) {
conn.requeueAllInflightCrypto(EncryptionLevel.HANDSHAKE)
}
if (conn.initial.sendProtection != null && !conn.initial.keysDiscarded) {
conn.requeueAllInflightCrypto(EncryptionLevel.INITIAL)
}
// Once 1-RTT keys are installed, PTO must also retransmit application
// data — STREAM bytes that were sent but never ACK'd. Without this,
// a single corrupted/lost 1-RTT packet (especially the first one
// carrying our HTTP/3 init streams + the GET request) is unrecoverable
// because loss detection only runs after the peer ACKs something
// and we have nothing else for the peer to ACK. Iterating streamsList
// requires streamsLock — `openBidiStream` and friends mutate it under
// the same lock, so unlocked iteration races with stream creation.
if (conn.application.sendProtection != null) {
conn.streamsLock.withLock {
conn.requeueAllInflightStreamData()
conn.requeueAllInflightCrypto(EncryptionLevel.APPLICATION)
}
}
conn.consecutivePtoCount = (conn.consecutivePtoCount + 1).coerceAtMost(6)
}
/**
* Highest encryption level for which `conn` currently holds send keys
* AND hasn't yet discarded them, given that 1-RTT keys are NOT
* installed. Returns null when the level state has been completely
* cleared (e.g. CLOSED after a CONNECTION_CLOSE was sent). Mirrors the
* private helper in [com.vitorpamplona.quic.connection.QuicConnectionWriter]
* — kept in lockstep so the driver's PTO branch and the writer's PING
* placement target the same level.
*/
private fun highestPreApplicationLevel(conn: QuicConnection): EncryptionLevel? =
when {
conn.handshake.sendProtection != null -> EncryptionLevel.HANDSHAKE
conn.initial.sendProtection != null && !conn.initial.keysDiscarded -> EncryptionLevel.INITIAL
else -> null
}
@@ -262,24 +262,88 @@ private fun feedShortHeaderPacket(
nowMillis: Long,
) {
val state = conn.levelState(EncryptionLevel.APPLICATION)
val proto = state.receiveProtection
if (proto == null) {
val live = state.receiveProtection
if (live == null) {
conn.qlogObserver.onPacketDropped(
"no application receive keys",
datagram.size - offset,
)
return
}
// RFC 9001 §6 — pick the right keys BEFORE running AEAD by peeking at
// the protected first byte's key-phase bit. Three cases:
// 1. Wire phase == current phase → use current (live) keys.
// 2. Wire phase != current phase but matches the previously-current
// phase (we already rotated past it) → use the retained
// [previousReceiveProtection] for reordered packets.
// 3. Wire phase != current phase and != previous → peer has rotated
// to the next phase; derive next-phase keys, attempt AEAD, on
// success commit the rotation.
// Without this dance, every post-rotation packet AEAD-fails silently
// (qlog drops) and the connection wedges (no ACKs to peer, peer falls
// into PTO mode → throughput collapse). Surfaced by quic-go
// transferloss interop, which initiates a key update around server
// pn=100 by default.
val peek =
ShortHeaderPacket.peekKeyPhase(
bytes = datagram,
offset = offset,
dcidLen = conn.sourceConnectionId.length,
hp = live.hp,
hpKey = live.hpKey,
)
if (peek == null) {
conn.qlogObserver.onPacketDropped(
"AEAD auth failed or header parse failed at level APPLICATION",
datagram.size - offset,
)
return
}
val keysToUse: PacketProtection
val rotateOnSuccess: PacketProtection?
when {
peek.keyPhase == conn.currentReceiveKeyPhase -> {
keysToUse = live
rotateOnSuccess = null
}
// Reordered packet from before our last rotation — try the
// retained previous keys. Reordering window is small but real
// for paths with non-trivial RTT.
conn.previousReceiveProtection != null -> {
keysToUse = conn.previousReceiveProtection!!
rotateOnSuccess = null
}
// Peer just rotated. Derive next-phase keys and prepare to commit
// if AEAD succeeds. Failure path is the same as a corrupted /
// unauthenticated packet — silent drop.
else -> {
val nextPhase = conn.deriveNextPhaseReceiveKeys()
if (nextPhase == null) {
conn.qlogObserver.onPacketDropped(
"AEAD auth failed or header parse failed at level APPLICATION",
datagram.size - offset,
)
return
}
keysToUse = nextPhase
rotateOnSuccess = nextPhase
}
}
val parsed =
ShortHeaderPacket.parseAndDecrypt(
bytes = datagram,
offset = offset,
dcidLen = conn.sourceConnectionId.length,
aead = proto.aead,
key = proto.key,
iv = proto.iv,
hp = proto.hp,
hpKey = proto.hpKey,
aead = keysToUse.aead,
key = keysToUse.key,
iv = keysToUse.iv,
hp = keysToUse.hp,
hpKey = keysToUse.hpKey,
largestReceivedInSpace = state.pnSpace.largestReceived,
)
if (parsed == null) {
@@ -289,6 +353,14 @@ private fun feedShortHeaderPacket(
)
return
}
// AEAD succeeded with the candidate next-phase keys → commit the
// rotation. The commit installs them as live, demotes the prior keys
// to [previousReceiveProtection], and rolls the send side forward so
// the next outbound carries the matching KEY_PHASE bit (peer uses
// that to confirm the rotation completed).
if (rotateOnSuccess != null) {
conn.commitKeyUpdate(rotateOnSuccess)
}
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
if (conn.qlogObserver !== com.vitorpamplona.quic.observability.QlogObserver.NoOp) {
conn.qlogObserver.onPacketReceived(
@@ -304,7 +304,12 @@ private fun buildBestLevelPacket(
val pn = app.pnSpace.allocateOutbound()
val built =
ShortHeaderPacket.build(
ShortHeaderPlaintextPacket(conn.destinationConnectionId, pn, payload),
ShortHeaderPlaintextPacket(
conn.destinationConnectionId,
pn,
payload,
keyPhase = conn.currentSendKeyPhase,
),
proto.aead,
proto.key,
proto.iv,
@@ -736,7 +741,12 @@ private fun buildApplicationPacket(
val sizeBytes =
runCatching {
ShortHeaderPacket.build(
ShortHeaderPlaintextPacket(conn.destinationConnectionId, pn, payload),
ShortHeaderPlaintextPacket(
conn.destinationConnectionId,
pn,
payload,
keyPhase = conn.currentSendKeyPhase,
),
proto.aead,
proto.key,
proto.iv,
@@ -90,6 +90,49 @@ object ShortHeaderPacket {
return packet
}
/**
* Header-protection unmask only — peek the first byte's key-phase bit
* before committing to a particular AEAD key set. Returns null if the
* datagram is too short for a HP sample.
*
* RFC 9001 §6 (key update): the receiver MUST decide which key-phase
* keys to use BEFORE running AEAD. The key-phase bit is part of the
* header-protected first byte, so the only honest way to choose the
* right keys is to unmask the first byte first. This helper does that
* cheaply (one HP-block call), letting the caller pick current vs
* next-phase keys before paying for the AEAD.
*
* The result also reports the unmasked PN length so the caller can
* stop early on an obviously bogus header rather than allocating
* buffers for the doomed AEAD attempt.
*/
fun peekKeyPhase(
bytes: ByteArray,
offset: Int,
dcidLen: Int,
hp: HeaderProtection,
hpKey: ByteArray,
): Peek? {
if (offset >= bytes.size) return null
val first = bytes[offset].toInt() and 0xFF
if ((first and 0x80) != 0) return null
val pnOffset = offset + 1 + dcidLen
val sampleStart = pnOffset + 4
if (sampleStart + 16 > bytes.size) return null
val sample = bytes.copyOfRange(sampleStart, sampleStart + 16)
val mask = hp.mask(hpKey, sample)
val unprotectedFirst = first xor (mask[0].toInt() and 0x1F)
return Peek(
keyPhase = (unprotectedFirst and 0x04) != 0,
pnLen = (unprotectedFirst and 0x03) + 1,
)
}
data class Peek(
val keyPhase: Boolean,
val pnLen: Int,
)
/** Strip HP + decrypt a short-header packet. The DCID length must be known from connection state. */
fun parseAndDecrypt(
bytes: ByteArray,
@@ -154,6 +154,7 @@ class TlsClient(
serverName = serverName,
x25519PublicKey = keyPair!!.publicKey,
quicTransportParams = transportParameters,
alpns = offeredAlpns,
random = random,
cipherSuites = cipherSuites,
)
@@ -83,14 +83,22 @@ class TlsClientHello(
* - signature_algorithms covering ECDSA / RSA-PSS / Ed25519
* - key_share with the caller's X25519 public
* - psk_key_exchange_modes = [ psk_dhe_ke ]
* - ALPN = [ h3 ]
* - ALPN = caller-supplied list (default `[ h3 ]`)
* - quic_transport_parameters = (caller-supplied opaque bytes)
*
* Caller must pass the FULL list of ALPNs to offer. Earlier shape took an
* `additionalAlpn` parameter and forced `h3` first — that silently dropped
* any caller-supplied list (e.g. `[hq-interop, h3]` for the interop
* runner's hq-interop testcases) because the production call site never
* threaded the list through. quic-go enforces strictly with TLS alert
* 120 (`no_application_protocol`, CRYPTO_ERROR 376) when the offered
* ALPNs don't include one its server is configured for.
*/
fun buildQuicClientHello(
serverName: String,
x25519PublicKey: ByteArray,
quicTransportParams: ByteArray,
additionalAlpn: List<ByteArray> = emptyList(),
alpns: List<ByteArray> = listOf(TlsConstants.ALPN_H3),
random: ByteArray = RandomInstance.bytes(32),
cipherSuites: IntArray =
intArrayOf(
@@ -98,9 +106,6 @@ fun buildQuicClientHello(
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256,
),
): TlsClientHello {
val alpn = mutableListOf<ByteArray>()
alpn += TlsConstants.ALPN_H3
alpn += additionalAlpn
val exts =
listOf(
TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName)),
@@ -109,7 +114,7 @@ fun buildQuicClientHello(
TlsExtension(TlsConstants.EXT_SIGNATURE_ALGORITHMS, encodeSignatureAlgorithms()),
TlsExtension(TlsConstants.EXT_KEY_SHARE, encodeKeyShareClientX25519(x25519PublicKey)),
TlsExtension(TlsConstants.EXT_PSK_KEY_EXCHANGE_MODES, encodePskKeyExchangeModesDhe()),
TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpn)),
TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpns)),
TlsExtension(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS, quicTransportParams),
)
return TlsClientHello(random = random, cipherSuites = cipherSuites, extensions = exts)
@@ -174,6 +174,103 @@ class ShortPayloadHeaderProtectionTest {
}
}
@Test
fun peek_key_phase_returns_phase_bit_without_aead() {
// Build a phase-1 packet, peek without running AEAD, expect the
// peek to surface keyPhase=true even though we never had the
// AEAD keys to actually decrypt the body. This is the gating
// operation in feedShortHeaderPacket — pick keys based on the
// peek before attempting AEAD.
val plain =
ShortHeaderPlaintextPacket(
dcid = dcid,
packetNumber = 0L,
payload = byteArrayOf(0x01, 0x02, 0x03, 0x04),
keyPhase = true,
)
val wire =
ShortHeaderPacket.build(
plain = plain,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestAckedInSpace = -1L,
)
val peek =
ShortHeaderPacket.peekKeyPhase(
bytes = wire,
offset = 0,
dcidLen = dcid.length,
hp = hp,
hpKey = proto.clientHp,
)
assertNotNull(peek)
assertEquals(true, peek.keyPhase)
}
@Test
fun peek_key_phase_returns_null_for_long_header() {
// Long-header form bit set → peek must reject.
val longHeader = ByteArray(64) { 0 }
longHeader[0] = 0xC0.toByte() // form=1, fixed=1
val peek =
ShortHeaderPacket.peekKeyPhase(
bytes = longHeader,
offset = 0,
dcidLen = dcid.length,
hp = hp,
hpKey = proto.clientHp,
)
assertEquals(null, peek)
}
@Test
fun two_byte_pn_round_trips_when_largest_acked_is_far_behind() {
// Reproduces the quic-go transferloss interop drop: server sends pn=100
// with pnLen=2 (because their `num_unacked = pn - largest_acked` exceeds
// 128 from their sender-side bookkeeping), client's largestReceived is
// 99 from the contiguous burst it just acked. If our HP unmask + PN
// decode mishandles 2-byte PNs, AEAD auth fails and we silently drop
// every packet from here on. The connection wedges in a one-packet-
// per-PTO loop because we stop generating ACKs.
val payload = byteArrayOf(0x01, 0x02, 0x03, 0x04)
val plain =
ShortHeaderPlaintextPacket(
dcid = dcid,
packetNumber = 100L,
payload = payload,
)
// largestAckedInSpace = -1 forces num_unacked = 101, which exceeds
// the 128-byte threshold and selects pnLen=2 in the builder.
val wire =
ShortHeaderPacket.build(
plain = plain,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestAckedInSpace = -1L,
)
val parsed =
ShortHeaderPacket.parseAndDecrypt(
bytes = wire,
offset = 0,
dcidLen = dcid.length,
aead = Aes128Gcm,
key = proto.clientKey,
iv = proto.clientIv,
hp = hp,
hpKey = proto.clientHp,
largestReceivedInSpace = 99L,
)
assertNotNull(parsed, "2-byte pn=100 with largestReceived=99 should decrypt")
assertEquals(100L, parsed.packet.packetNumber)
assertContentEquals(payload, parsed.packet.payload)
}
@Test
fun payload_at_or_above_threshold_is_unchanged() {
// pnLen=1, payload=4: already satisfies pnLen+payload >= 4. The