Merge remote-tracking branch 'origin/main' into claude/t16-nestsclient-closure-1zBIc
This commit is contained in:
+13
-2
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+223
-4
@@ -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
|
||||
|
||||
|
||||
+42
-30
@@ -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
|
||||
}
|
||||
|
||||
+79
-7
@@ -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(
|
||||
|
||||
+12
-2
@@ -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)
|
||||
|
||||
+97
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user