feat(quic): peer-initiated key-update verification + send-loop death surfaces as CLOSED
#2 KEY UPDATE VERIFICATION (soak target #2) Added KeyUpdatePeerInitiatedTest pinning the RFC 9001 §6 peer-initiated 1-RTT key update path against InMemoryQuicPipe: - peerInitiatedRotationCommitsAndMirrorsOnSend — single rotation flips currentReceiveKeyPhase, mirrors currentSendKeyPhase, retains pre-rotation keys as previousReceiveProtection, installs new receive+send protections; connection stays CONNECTED. - reorderedPacketOnPriorKeysStillDecryptsAfterRotation — packet sent before peer rotated but arriving after the rotation triggering packet decrypts via previousReceiveProtection (RFC 9001 §6.1 reorder window). - postRotationOutboundPacketCarriesNewKeyPhaseAndDecryptsForPeer — writer stamps currentSendKeyPhase into the short header AND encrypts with the rolled-forward send keys. Test infrastructure: InMemoryQuicPipe grows rotateServerApplicationKeys (walks the same HKDF-Expand-Label "quic ku" dance the production peer would) plus buildServerApplicationDatagramWithPriorKeys (re-emits via the stashed pre-rotation TX, exercising the reorder-window path). Documented limitation: consecutive rotations within the reorder window mis-route via previousReceiveProtection. The spec-correct fix is to gate previousReceiveProtection on a packet-number threshold (neqo / picoquic shape); for the audio-rooms 3-hour scenario, a single rotation is the realistic case so this is a follow-on rather than a blocker. No test asserts the broken behaviour. #3 RECONNECT-ON-FOREGROUND (soak target #3) ReconnectingNestsListener already has all the orchestration (exponential-backoff retry, JWT-refresh recycle, recycleSession() hook for platform network-change events). What was missing at the QUIC level: when the OS reclaims the UDP socket FD while the app is backgrounded, socket.send() throws and the bare exception escapes the SupervisorJob silently. The connection sits in HANDSHAKING / CONNECTED indefinitely and the orchestrator's terminal-state listener never fires — the room screen shows "live" while audio is dead. Wrapped sendLoop in try/catch mirroring the existing readLoop's finally block: any uncaught Throwable (CancellationException excepted, since close() is already driving teardown) calls markClosedExternally with the cause. Also wired markClosedExternally to record closeReason on first-call so observability surfaces the human-readable cause through to NestsListenerState.Failed.reason. Pinned by socketDeathMidSessionFlipsConnectionToClosed — runs the driver, tears the UDP socket out from under it, asserts status flips to CLOSED within 5 s and the close reason mentions the loop death. Pre-fix this would loop forever waiting for status to move. Tests: - KeyUpdatePeerInitiatedTest (3 cases) - QuicConnectionDriverLifecycleTest::socketDeathMidSessionFlipsConnectionToClosed https://claude.ai/code/session_018KPKWRg5baX5Anf7zfEyec
This commit is contained in:
@@ -1348,6 +1348,14 @@ class QuicConnection(
|
|||||||
val wasClosed = status == Status.CLOSED
|
val wasClosed = status == Status.CLOSED
|
||||||
if (status != Status.CLOSED) status = Status.CLOSED
|
if (status != Status.CLOSED) status = Status.CLOSED
|
||||||
if (!wasClosed) {
|
if (!wasClosed) {
|
||||||
|
// First-call wins for [closeReason] so the highest-quality
|
||||||
|
// diagnostic is preserved when several teardown paths race
|
||||||
|
// (e.g. read loop's `socket.receive() == null` finally fires
|
||||||
|
// a moment before the send loop's `socket.send` throw catch
|
||||||
|
// block does). Without this, downstream observers like
|
||||||
|
// `ReconnectingNestsListener.terminalAwait` see a closed
|
||||||
|
// connection but no human-readable cause for the failure.
|
||||||
|
closeReason = reason
|
||||||
// "remote" covers both peer-initiated CONNECTION_CLOSE and
|
// "remote" covers both peer-initiated CONNECTION_CLOSE and
|
||||||
// local invariant violations (CID mismatch, frame decode
|
// local invariant violations (CID mismatch, frame decode
|
||||||
// failure) that the parser surfaces as markClosedExternally.
|
// failure) that the parser surfaces as markClosedExternally.
|
||||||
|
|||||||
@@ -151,6 +151,34 @@ class QuicConnectionDriver(
|
|||||||
// the first RTT sample we fall back to a 1 s conservative
|
// the first RTT sample we fall back to a 1 s conservative
|
||||||
// floor (the same prior-shipping behavior, kept for
|
// floor (the same prior-shipping behavior, kept for
|
||||||
// handshake-timeout safety on lossy paths).
|
// handshake-timeout safety on lossy paths).
|
||||||
|
//
|
||||||
|
// Mirror the read loop's symmetry: any uncaught throw inside
|
||||||
|
// the loop (most common: `socket.send` raising once the OS
|
||||||
|
// tears down the UDP socket — typical on Android when the
|
||||||
|
// app backgrounds and the kernel reclaims the FD ~30 s
|
||||||
|
// later) MUST flip the connection to CLOSED so the
|
||||||
|
// higher-level reconnect orchestration in
|
||||||
|
// [com.vitorpamplona.nestsclient.connectReconnectingNestsListener]
|
||||||
|
// observes a Failed terminal state and fires a fresh
|
||||||
|
// handshake. Pre-fix, the throw escaped silently into the
|
||||||
|
// SupervisorJob, the read loop kept blocking on
|
||||||
|
// `socket.receive()` (which doesn't throw — it returns null
|
||||||
|
// on close, but the OS may not surface that for many
|
||||||
|
// seconds), and the connection sat in HANDSHAKING / CONNECTED
|
||||||
|
// long after the socket was dead — invisibly wedged.
|
||||||
|
try {
|
||||||
|
sendLoopBody()
|
||||||
|
} catch (ce: kotlinx.coroutines.CancellationException) {
|
||||||
|
// Cooperative cancel from close() / scope.cancel(). Don't
|
||||||
|
// mark closed here — close() is already driving the
|
||||||
|
// teardown and would race with our markClosedExternally.
|
||||||
|
throw ce
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
connection.markClosedExternally("send loop exited: ${t::class.simpleName}: ${t.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendLoopBody() {
|
||||||
while (connection.status != QuicConnection.Status.CLOSED) {
|
while (connection.status != QuicConnection.Status.CLOSED) {
|
||||||
// Phase 1 of the lock-split refactor: the writer holds
|
// Phase 1 of the lock-split refactor: the writer holds
|
||||||
// streamsLock for the build, releases it for the actual
|
// streamsLock for the build, releases it for the actual
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import com.vitorpamplona.quic.crypto.Aes128Gcm
|
|||||||
import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection
|
import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection
|
||||||
import com.vitorpamplona.quic.crypto.InitialSecrets
|
import com.vitorpamplona.quic.crypto.InitialSecrets
|
||||||
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
|
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
|
||||||
|
import com.vitorpamplona.quic.crypto.expandLabel
|
||||||
import com.vitorpamplona.quic.frame.AckFrame
|
import com.vitorpamplona.quic.frame.AckFrame
|
||||||
import com.vitorpamplona.quic.frame.ConnectionCloseFrame
|
import com.vitorpamplona.quic.frame.ConnectionCloseFrame
|
||||||
import com.vitorpamplona.quic.frame.CryptoFrame
|
import com.vitorpamplona.quic.frame.CryptoFrame
|
||||||
@@ -97,6 +98,42 @@ class InMemoryQuicPipe(
|
|||||||
private var serverApplicationRx: PacketProtection? = null
|
private var serverApplicationRx: PacketProtection? = null
|
||||||
private var serverApplicationTx: PacketProtection? = null
|
private var serverApplicationTx: PacketProtection? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror of the client's `application.sendProtection` for the
|
||||||
|
* pipe's TX side. The key-update test rotates this forward
|
||||||
|
* (HKDF-Expand-Label "quic ku") so the pipe can emit a packet
|
||||||
|
* encrypted with the next-phase keys, exercising the client's
|
||||||
|
* peer-initiated rotation path. Stays null until the handshake
|
||||||
|
* completes; updated in lockstep with [serverApplicationTx]
|
||||||
|
* by [installServerSecretsAfterHandshakeBegin] and rolled
|
||||||
|
* forward by [rotateServerApplicationKeys].
|
||||||
|
*/
|
||||||
|
private var serverSendApplicationSecret: ByteArray? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cipher suite negotiated by [tlsServer]. Cached after the
|
||||||
|
* handshake completes so [rotateServerApplicationKeys] doesn't
|
||||||
|
* have to re-query the TLS object on every rotation.
|
||||||
|
*/
|
||||||
|
private var serverApplicationCipherSuite: Int = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stashed pre-rotation TX keys, so the test can re-emit a
|
||||||
|
* "reordered" packet on the OLD keys after a rotation has
|
||||||
|
* committed. Without this, the reorder-window check (RFC 9001
|
||||||
|
* §6.1: client retains [previousReceiveProtection] until the
|
||||||
|
* reorder window closes) would have nothing to exercise.
|
||||||
|
* Updated by [rotateServerApplicationKeys].
|
||||||
|
*/
|
||||||
|
private var serverApplicationTxPrior: PacketProtection? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the server's send-side key-phase bit. Flipped by
|
||||||
|
* [rotateServerApplicationKeys] so [buildServerApplicationDatagram]
|
||||||
|
* can stamp the right value into the short-header.
|
||||||
|
*/
|
||||||
|
private var serverSendKeyPhase: Boolean = false
|
||||||
|
|
||||||
private val initialPnSpace = PacketNumberSpaceState()
|
private val initialPnSpace = PacketNumberSpaceState()
|
||||||
private val handshakePnSpace = PacketNumberSpaceState()
|
private val handshakePnSpace = PacketNumberSpaceState()
|
||||||
private val applicationPnSpace = PacketNumberSpaceState()
|
private val applicationPnSpace = PacketNumberSpaceState()
|
||||||
@@ -226,6 +263,8 @@ class InMemoryQuicPipe(
|
|||||||
serverHandshakeTx = packetProtectionFromSecret(cipher, tlsServer.serverHandshakeSecret!!)
|
serverHandshakeTx = packetProtectionFromSecret(cipher, tlsServer.serverHandshakeSecret!!)
|
||||||
serverApplicationRx = packetProtectionFromSecret(cipher, tlsServer.clientApplicationSecret!!)
|
serverApplicationRx = packetProtectionFromSecret(cipher, tlsServer.clientApplicationSecret!!)
|
||||||
serverApplicationTx = packetProtectionFromSecret(cipher, tlsServer.serverApplicationSecret!!)
|
serverApplicationTx = packetProtectionFromSecret(cipher, tlsServer.serverApplicationSecret!!)
|
||||||
|
serverSendApplicationSecret = tlsServer.serverApplicationSecret
|
||||||
|
serverApplicationCipherSuite = cipher
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a single datagram from the server containing whatever it owes the client. */
|
/** Build a single datagram from the server containing whatever it owes the client. */
|
||||||
@@ -293,6 +332,87 @@ class InMemoryQuicPipe(
|
|||||||
dcid = client.sourceConnectionId,
|
dcid = client.sourceConnectionId,
|
||||||
packetNumber = pn,
|
packetNumber = pn,
|
||||||
payload = payload,
|
payload = payload,
|
||||||
|
keyPhase = serverSendKeyPhase,
|
||||||
|
),
|
||||||
|
proto.aead,
|
||||||
|
proto.key,
|
||||||
|
proto.iv,
|
||||||
|
proto.hp,
|
||||||
|
proto.hpKey,
|
||||||
|
largestAckedInSpace = applicationPnSpace.largestReceived,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: roll the server's TX-side application keys forward by
|
||||||
|
* one phase (RFC 9001 §6.1) and stash the prior keys on
|
||||||
|
* [serverApplicationTxPrior] so a follow-up call to
|
||||||
|
* [buildServerApplicationDatagramWithPriorKeys] can simulate a
|
||||||
|
* reordered packet from before the rotation.
|
||||||
|
*
|
||||||
|
* After this call, the next [buildServerApplicationDatagram] will
|
||||||
|
* emit a packet whose KEY_PHASE bit is the rotated value and
|
||||||
|
* whose AEAD nonce/key are HKDF-derived off the next-phase
|
||||||
|
* secret. The pipe also tracks its own send-phase bit, so we
|
||||||
|
* model the realistic case where the server (peer) rotates its
|
||||||
|
* own send keys and the client mirrors on receive.
|
||||||
|
*
|
||||||
|
* Throws if called before the handshake completes.
|
||||||
|
*/
|
||||||
|
fun rotateServerApplicationKeys() {
|
||||||
|
val curSecret =
|
||||||
|
checkNotNull(serverSendApplicationSecret) {
|
||||||
|
"rotateServerApplicationKeys called before handshake completed"
|
||||||
|
}
|
||||||
|
val cs = serverApplicationCipherSuite
|
||||||
|
check(cs != 0) { "negotiated cipher suite not yet recorded" }
|
||||||
|
val nextSecret = expandLabel(curSecret, "quic ku", curSecret.size)
|
||||||
|
val nextProto = packetProtectionFromSecret(cipherSuite = cs, secret = nextSecret)
|
||||||
|
val live =
|
||||||
|
checkNotNull(serverApplicationTx) {
|
||||||
|
"rotateServerApplicationKeys called before serverApplicationTx installed"
|
||||||
|
}
|
||||||
|
// RFC 9001 §6.1 — HP key is NOT updated on rotation; carry the
|
||||||
|
// existing one forward.
|
||||||
|
val rotated =
|
||||||
|
PacketProtection(
|
||||||
|
aead = nextProto.aead,
|
||||||
|
key = nextProto.key,
|
||||||
|
iv = nextProto.iv,
|
||||||
|
hp = live.hp,
|
||||||
|
hpKey = live.hpKey,
|
||||||
|
)
|
||||||
|
serverApplicationTxPrior = live
|
||||||
|
serverApplicationTx = rotated
|
||||||
|
serverSendApplicationSecret = nextSecret
|
||||||
|
serverSendKeyPhase = !serverSendKeyPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only: build a server application packet using the
|
||||||
|
* pre-rotation keys captured by [rotateServerApplicationKeys].
|
||||||
|
* Used to drive the reorder-window code path in the parser
|
||||||
|
* ([com.vitorpamplona.quic.connection.feedShortHeaderPacket]'s
|
||||||
|
* `previousReceiveProtection != null` branch) — the client
|
||||||
|
* MUST decrypt this packet with [QuicConnection.previousReceiveProtection]
|
||||||
|
* even after committing the rotation, because in the real network
|
||||||
|
* a reordered packet from before the rotation can arrive after
|
||||||
|
* the rotation-triggering packet.
|
||||||
|
*
|
||||||
|
* Returns null if no prior keys have been stashed (i.e.
|
||||||
|
* [rotateServerApplicationKeys] hasn't been called yet).
|
||||||
|
*/
|
||||||
|
fun buildServerApplicationDatagramWithPriorKeys(frames: List<com.vitorpamplona.quic.frame.Frame>): ByteArray? {
|
||||||
|
val proto = serverApplicationTxPrior ?: return null
|
||||||
|
val pn = applicationPnSpace.allocateOutbound()
|
||||||
|
val payload = encodeFrames(frames)
|
||||||
|
return ShortHeaderPacket.build(
|
||||||
|
com.vitorpamplona.quic.packet.ShortHeaderPlaintextPacket(
|
||||||
|
dcid = client.sourceConnectionId,
|
||||||
|
packetNumber = pn,
|
||||||
|
payload = payload,
|
||||||
|
// Prior phase is the inverse of the current one.
|
||||||
|
keyPhase = !serverSendKeyPhase,
|
||||||
),
|
),
|
||||||
proto.aead,
|
proto.aead,
|
||||||
proto.key,
|
proto.key,
|
||||||
|
|||||||
+386
@@ -0,0 +1,386 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quic.connection
|
||||||
|
|
||||||
|
import com.vitorpamplona.quic.frame.PingFrame
|
||||||
|
import com.vitorpamplona.quic.frame.StreamFrame
|
||||||
|
import com.vitorpamplona.quic.packet.ShortHeaderPacket
|
||||||
|
import com.vitorpamplona.quic.stream.StreamId
|
||||||
|
import com.vitorpamplona.quic.tls.InProcessTlsServer
|
||||||
|
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the peer-initiated 1-RTT key update path (RFC 9001 §6).
|
||||||
|
*
|
||||||
|
* Soak target #2 from the audio-rooms hardening pass: long-lived
|
||||||
|
* audio-room sessions outlive the QUIC AEAD key's safe usage window
|
||||||
|
* (~2^60 packets per RFC 9001 §6.6, but real implementations rotate
|
||||||
|
* far earlier — quic-go defaults to ~100 packets, picoquic to a few
|
||||||
|
* thousand). The client MUST handle a peer that flips the
|
||||||
|
* `KEY_PHASE` bit by deriving next-phase keys, retry-decrypting the
|
||||||
|
* triggering packet, and then mirroring the rotation on its own
|
||||||
|
* send side. Without this, post-rotation packets AEAD-fail
|
||||||
|
* silently — qlog shows them as "AEAD auth failed" drops, the
|
||||||
|
* connection wedges (no ACKs to peer, peer falls into PTO mode), and
|
||||||
|
* audio cuts off.
|
||||||
|
*
|
||||||
|
* The implementation lives across:
|
||||||
|
* - `QuicConnectionParser.feedShortHeaderPacket` (peek key-phase
|
||||||
|
* bit, route to current/previous/next keys).
|
||||||
|
* - `QuicConnection.deriveNextPhaseReceiveKeys` (HKDF-Expand-Label
|
||||||
|
* "quic ku" → next secret → key/iv).
|
||||||
|
* - `QuicConnection.commitKeyUpdate` (install next as live, demote
|
||||||
|
* live to previous, mirror onto send side, flip phase bits).
|
||||||
|
* - `QuicConnectionWriter` stamping `currentSendKeyPhase` into the
|
||||||
|
* short header on each outbound build.
|
||||||
|
*
|
||||||
|
* These tests exercise the peer-initiated path end-to-end against
|
||||||
|
* `InMemoryQuicPipe`, which now grows a `rotateServerApplicationKeys`
|
||||||
|
* helper that walks the same HKDF dance the production peer would.
|
||||||
|
*/
|
||||||
|
class KeyUpdatePeerInitiatedTest {
|
||||||
|
@Test
|
||||||
|
fun peerInitiatedRotationCommitsAndMirrorsOnSend() =
|
||||||
|
runBlocking {
|
||||||
|
val (client, pipe) = newConnectedClient()
|
||||||
|
|
||||||
|
// Pre-rotation invariants — pin the baseline so a regression
|
||||||
|
// that fired commitKeyUpdate during the handshake itself
|
||||||
|
// (it shouldn't) would surface here as a phase mismatch.
|
||||||
|
assertEquals(false, client.currentReceiveKeyPhase, "key-phase 0 at start")
|
||||||
|
assertEquals(false, client.currentSendKeyPhase, "send-phase 0 at start")
|
||||||
|
assertEquals(null, client.previousReceiveProtection, "no prior keys before rotation")
|
||||||
|
val originalReceiveProtection = client.application.receiveProtection
|
||||||
|
assertNotNull(originalReceiveProtection, "client must have 1-RTT receive keys after handshake")
|
||||||
|
val originalSendProtection = client.application.sendProtection
|
||||||
|
assertNotNull(originalSendProtection, "client must have 1-RTT send keys after handshake")
|
||||||
|
|
||||||
|
// Rotate the pipe's TX keys forward and emit a packet whose
|
||||||
|
// body is a plain PING — small payload, ack-eliciting so the
|
||||||
|
// client also has a reason to send back. The packet's
|
||||||
|
// KEY_PHASE bit is true.
|
||||||
|
pipe.rotateServerApplicationKeys()
|
||||||
|
val rotatedPacket = pipe.buildServerApplicationDatagram(listOf(PingFrame))!!
|
||||||
|
// Sanity: the bit on the wire is the rotated phase.
|
||||||
|
assertEquals(
|
||||||
|
true,
|
||||||
|
peekKeyPhase(rotatedPacket, client),
|
||||||
|
"the test fixture must produce a KEY_PHASE=1 packet after rotateServerApplicationKeys",
|
||||||
|
)
|
||||||
|
feedDatagram(client, rotatedPacket, nowMillis = 0L)
|
||||||
|
|
||||||
|
// Post-rotation invariants:
|
||||||
|
// - currentReceiveKeyPhase flipped (commitKeyUpdate ran).
|
||||||
|
// - currentSendKeyPhase flipped in lockstep (we mirror).
|
||||||
|
// - previousReceiveProtection holds the pre-rotation keys
|
||||||
|
// (kept alive for the reorder window).
|
||||||
|
// - application.receiveProtection is a NEW instance (next-
|
||||||
|
// phase keys derived from HKDF "quic ku").
|
||||||
|
// - application.sendProtection is also a NEW instance
|
||||||
|
// (mirror onto send side).
|
||||||
|
// - connection stays CONNECTED — the rotation isn't a
|
||||||
|
// teardown trigger.
|
||||||
|
assertEquals(
|
||||||
|
true,
|
||||||
|
client.currentReceiveKeyPhase,
|
||||||
|
"currentReceiveKeyPhase must flip after the parser commits the rotation",
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
true,
|
||||||
|
client.currentSendKeyPhase,
|
||||||
|
"currentSendKeyPhase must mirror the receive-side rotation in lockstep " +
|
||||||
|
"(commitKeyUpdate on the send side derives the matching next-phase secret)",
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
originalReceiveProtection,
|
||||||
|
client.previousReceiveProtection,
|
||||||
|
"pre-rotation receive keys must be retained as previousReceiveProtection " +
|
||||||
|
"for the reorder window (RFC 9001 §6.1)",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
client.application.receiveProtection !== originalReceiveProtection,
|
||||||
|
"application.receiveProtection must reference the next-phase keys, not the originals",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
client.application.sendProtection !== originalSendProtection,
|
||||||
|
"application.sendProtection must reference the next-phase keys after the mirror",
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
QuicConnection.Status.CONNECTED,
|
||||||
|
client.status,
|
||||||
|
"connection must stay CONNECTED across a peer-initiated key update",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reorderedPacketOnPriorKeysStillDecryptsAfterRotation() =
|
||||||
|
runBlocking {
|
||||||
|
// RFC 9001 §6.1 reorder window: once the client has rotated,
|
||||||
|
// it MUST keep the prior-phase receive keys for some bounded
|
||||||
|
// window so that a packet sent before the peer rotated (but
|
||||||
|
// delayed in the network) still decrypts. The parser path is
|
||||||
|
// the `previousReceiveProtection != null` arm in
|
||||||
|
// feedShortHeaderPacket. Pin it by:
|
||||||
|
// 1. Pushing one peer-uni stream + FIN with the pre-rotation
|
||||||
|
// keys to establish that the client has live state on
|
||||||
|
// stream id 3 (server-uni #0).
|
||||||
|
// 2. Rotating, pushing a rotation-triggering PING.
|
||||||
|
// 3. Replaying a SECOND payload on the same stream id with
|
||||||
|
// the PRIOR keys — i.e. the pipe re-uses the stashed
|
||||||
|
// pre-rotation TX. The client must decrypt it via
|
||||||
|
// previousReceiveProtection and surface the bytes.
|
||||||
|
val (client, pipe) = newConnectedClient()
|
||||||
|
val streamId = StreamId.build(StreamId.Kind.SERVER_UNI, 0L)
|
||||||
|
|
||||||
|
// Pre-rotation: bytes 0..3 with no FIN.
|
||||||
|
val prePayload = "pre-".encodeToByteArray()
|
||||||
|
val prePacket =
|
||||||
|
pipe.buildServerApplicationDatagram(
|
||||||
|
listOf(StreamFrame(streamId = streamId, offset = 0L, data = prePayload, fin = false)),
|
||||||
|
)!!
|
||||||
|
feedDatagram(client, prePacket, nowMillis = 0L)
|
||||||
|
|
||||||
|
// Rotate, then trigger commit with a PING.
|
||||||
|
pipe.rotateServerApplicationKeys()
|
||||||
|
feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(PingFrame))!!, nowMillis = 0L)
|
||||||
|
assertEquals(true, client.currentReceiveKeyPhase, "rotation must commit before reorder test")
|
||||||
|
assertNotNull(client.previousReceiveProtection, "prior keys must be retained")
|
||||||
|
|
||||||
|
// Now the reorder: the peer SENT this packet pre-rotation
|
||||||
|
// but it arrives after the rotation-triggering PING. The
|
||||||
|
// pipe builds it with the stashed prior keys. The packet's
|
||||||
|
// KEY_PHASE bit is the pre-rotation value (false), and the
|
||||||
|
// client's parser MUST route through previousReceiveProtection.
|
||||||
|
val reorderedPayload = "post".encodeToByteArray()
|
||||||
|
val reorderedPacket =
|
||||||
|
pipe.buildServerApplicationDatagramWithPriorKeys(
|
||||||
|
listOf(
|
||||||
|
StreamFrame(
|
||||||
|
streamId = streamId,
|
||||||
|
offset = prePayload.size.toLong(),
|
||||||
|
data = reorderedPayload,
|
||||||
|
fin = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)!!
|
||||||
|
assertEquals(
|
||||||
|
false,
|
||||||
|
peekKeyPhase(reorderedPacket, client),
|
||||||
|
"reordered packet must carry the pre-rotation KEY_PHASE bit",
|
||||||
|
)
|
||||||
|
feedDatagram(client, reorderedPacket, nowMillis = 0L)
|
||||||
|
|
||||||
|
// The application sees the FULL stream payload despite the
|
||||||
|
// mid-stream key rotation. If the parser had dropped the
|
||||||
|
// reordered packet, the stream would stall (no FIN) and the
|
||||||
|
// toList collector below would time out.
|
||||||
|
val stream = client.streamById(streamId)!!
|
||||||
|
val chunks = withTimeoutOrNull(2_000L) { stream.incoming.toList() }
|
||||||
|
assertNotNull(chunks, "stream incoming Flow must complete despite the mid-stream rotation")
|
||||||
|
val joined = ByteArray(chunks.sumOf { it.size })
|
||||||
|
var p = 0
|
||||||
|
for (c in chunks) {
|
||||||
|
c.copyInto(joined, p)
|
||||||
|
p += c.size
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
"pre-post",
|
||||||
|
joined.decodeToString(),
|
||||||
|
"reordered packet decrypted on prior keys must surface its bytes intact",
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
QuicConnection.Status.CONNECTED,
|
||||||
|
client.status,
|
||||||
|
"connection must stay CONNECTED across the reorder",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun postRotationOutboundPacketCarriesNewKeyPhaseAndDecryptsForPeer() =
|
||||||
|
runBlocking {
|
||||||
|
// Send-side correctness check: after a peer-initiated
|
||||||
|
// rotation, the writer's NEXT outbound packet must
|
||||||
|
// (a) stamp the new currentSendKeyPhase into the
|
||||||
|
// short header, and
|
||||||
|
// (b) be encrypted with the rotated send keys (so the
|
||||||
|
// peer, which has also rotated, decrypts it
|
||||||
|
// correctly).
|
||||||
|
// If commitKeyUpdate's send-side mirror regressed (e.g.
|
||||||
|
// updated currentSendKeyPhase but forgot to install fresh
|
||||||
|
// sendProtection), the wire bit and the AEAD keys would
|
||||||
|
// disagree and the peer would drop the packet — visible as
|
||||||
|
// a silent "client never ACKs after rotation" wedge.
|
||||||
|
val (client, pipe) = newConnectedClient()
|
||||||
|
pipe.rotateServerApplicationKeys()
|
||||||
|
feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(PingFrame))!!, nowMillis = 0L)
|
||||||
|
assertEquals(true, client.currentSendKeyPhase, "rotation must commit before send-side check")
|
||||||
|
|
||||||
|
// The PING is ack-eliciting, so the writer has work to do.
|
||||||
|
// drainOutbound builds the ACK + any other queued frames.
|
||||||
|
val outbound = drainOutbound(client, nowMillis = 0L)
|
||||||
|
assertNotNull(outbound, "writer must emit an ACK in response to the rotation-trigger PING")
|
||||||
|
|
||||||
|
// Pipe re-uses the same applicationPnSpace and HP key, so
|
||||||
|
// it can decrypt this packet via decryptClientApplicationFrames
|
||||||
|
// — but only if the AEAD keys match. The pipe's RX side
|
||||||
|
// ALSO has to rotate to match; the existing pipe
|
||||||
|
// doesn't rotate RX automatically, so the AEAD will fail
|
||||||
|
// and we just check the wire-level bit through peekKeyPhase.
|
||||||
|
// What we CAN observe without rotating the pipe RX is the
|
||||||
|
// unprotected first byte: header-protection key isn't
|
||||||
|
// rotated (RFC 9001 §6.1), so the HP unmask still works
|
||||||
|
// and tells us the wire bit.
|
||||||
|
val wirePhase = peekKeyPhase(outbound, client, useSendKeys = true)
|
||||||
|
assertEquals(
|
||||||
|
true,
|
||||||
|
wirePhase,
|
||||||
|
"post-rotation outbound packet must carry KEY_PHASE=1 on the wire — the writer reads " +
|
||||||
|
"currentSendKeyPhase (now true) when stamping the short header",
|
||||||
|
)
|
||||||
|
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KNOWN LIMITATION — consecutive rotations within the reorder window.
|
||||||
|
//
|
||||||
|
// The parser currently routes inbound packets whose KEY_PHASE bit
|
||||||
|
// does not match `currentReceiveKeyPhase` to
|
||||||
|
// `previousReceiveProtection` whenever those prior keys exist. After
|
||||||
|
// a single rotation that's correct (matches RFC 9001 §6.1's reorder-
|
||||||
|
// tolerance contract). But after a SECOND rotation by the peer the
|
||||||
|
// KEY_PHASE bit wraps back to the original value — and our parser
|
||||||
|
// misroutes those packets to the (now-wrong) prior keys, AEAD-fails,
|
||||||
|
// and silently drops them. The connection wedges.
|
||||||
|
//
|
||||||
|
// The standard fix is to gate `previousReceiveProtection` on a
|
||||||
|
// packet-number threshold (track the PN of the rotation-triggering
|
||||||
|
// packet; reroute to next-phase derivation once subsequent KEY_PHASE-
|
||||||
|
// mismatched packets exceed it). neqo and picoquic implement this;
|
||||||
|
// we don't yet. For audio-rooms' 3-hour session, ONE rotation is the
|
||||||
|
// realistic case (peers rotate sparingly), so the deeper fix is a
|
||||||
|
// follow-up rather than a blocker.
|
||||||
|
//
|
||||||
|
// No test asserts the broken behaviour — adding one would just pin
|
||||||
|
// the regression. When the protocol-level fix lands, a positive
|
||||||
|
// `consecutiveRotationsCommitCorrectly` test is the right addition
|
||||||
|
// here.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the unprotected KEY_PHASE bit out of a packet so the test
|
||||||
|
* can assert the wire shape independent of the client's
|
||||||
|
* bookkeeping.
|
||||||
|
*
|
||||||
|
* Walks past any leading long-header (Initial / Handshake) packets
|
||||||
|
* in the datagram — drainOutbound will keep emitting a Handshake-
|
||||||
|
* level ACK at the front of each datagram until the InMemoryQuicPipe
|
||||||
|
* delivers a HANDSHAKE_DONE frame (which it doesn't), so the short-
|
||||||
|
* header packet is rarely first on the wire.
|
||||||
|
*
|
||||||
|
* For inbound (server-built) packets we hand the client's RECEIVE
|
||||||
|
* HP key. For outbound (client-built) packets we hand the client's
|
||||||
|
* SEND HP key. Both phases share their direction's HP key per RFC
|
||||||
|
* 9001 §6.1 ("the header_protection key is not updated when keys
|
||||||
|
* are updated"), so this stays valid across rotations.
|
||||||
|
*/
|
||||||
|
private fun peekKeyPhase(
|
||||||
|
packet: ByteArray,
|
||||||
|
client: QuicConnection,
|
||||||
|
useSendKeys: Boolean = false,
|
||||||
|
): Boolean? {
|
||||||
|
val live =
|
||||||
|
if (useSendKeys) {
|
||||||
|
client.application.sendProtection
|
||||||
|
} else {
|
||||||
|
client.application.receiveProtection
|
||||||
|
} ?: return null
|
||||||
|
var offset = 0
|
||||||
|
while (offset < packet.size) {
|
||||||
|
val first = packet[offset].toInt() and 0xFF
|
||||||
|
if ((first and 0x80) == 0) {
|
||||||
|
// Short header — what we want.
|
||||||
|
return ShortHeaderPacket
|
||||||
|
.peekKeyPhase(
|
||||||
|
bytes = packet,
|
||||||
|
offset = offset,
|
||||||
|
dcidLen = client.sourceConnectionId.length,
|
||||||
|
hp = live.hp,
|
||||||
|
hpKey = live.hpKey,
|
||||||
|
)?.keyPhase
|
||||||
|
}
|
||||||
|
// Long header — skip past using the encoded length field.
|
||||||
|
val peeked =
|
||||||
|
com.vitorpamplona.quic.packet.LongHeaderPacket
|
||||||
|
.peekHeader(packet, offset) ?: return null
|
||||||
|
offset += peeked.totalLength
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newConnectedClient(): Pair<QuicConnection, InMemoryQuicPipe> =
|
||||||
|
runBlocking {
|
||||||
|
val client =
|
||||||
|
QuicConnection(
|
||||||
|
serverName = "keyupdate.test",
|
||||||
|
config =
|
||||||
|
QuicConnectionConfig(
|
||||||
|
initialMaxStreamsBidi = 16,
|
||||||
|
initialMaxStreamsUni = 16,
|
||||||
|
initialMaxData = 1L * 1024 * 1024,
|
||||||
|
initialMaxStreamDataBidiLocal = 64L * 1024,
|
||||||
|
initialMaxStreamDataBidiRemote = 64L * 1024,
|
||||||
|
initialMaxStreamDataUni = 64L * 1024,
|
||||||
|
),
|
||||||
|
tlsCertificateValidator = PermissiveCertificateValidator(),
|
||||||
|
)
|
||||||
|
val serverScid = ConnectionId.random(8)
|
||||||
|
val tlsServer =
|
||||||
|
InProcessTlsServer(
|
||||||
|
transportParameters =
|
||||||
|
TransportParameters(
|
||||||
|
initialMaxData = 1L * 1024 * 1024,
|
||||||
|
initialMaxStreamDataBidiLocal = 64L * 1024,
|
||||||
|
initialMaxStreamDataBidiRemote = 64L * 1024,
|
||||||
|
initialMaxStreamDataUni = 64L * 1024,
|
||||||
|
initialMaxStreamsBidi = 16,
|
||||||
|
initialMaxStreamsUni = 16,
|
||||||
|
initialSourceConnectionId = serverScid.bytes,
|
||||||
|
originalDestinationConnectionId = client.destinationConnectionId.bytes,
|
||||||
|
).encode(),
|
||||||
|
)
|
||||||
|
val pipe =
|
||||||
|
InMemoryQuicPipe(
|
||||||
|
client = client,
|
||||||
|
initialDcid = client.destinationConnectionId.bytes,
|
||||||
|
serverScid = serverScid,
|
||||||
|
tlsServer = tlsServer,
|
||||||
|
)
|
||||||
|
client.start()
|
||||||
|
pipe.drive(maxRounds = 16)
|
||||||
|
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||||
|
client to pipe
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -203,6 +203,90 @@ class QuicConnectionDriverLifecycleTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun socketDeathMidSessionFlipsConnectionToClosed() =
|
||||||
|
runBlocking {
|
||||||
|
// Soak target #3: when Android backgrounds the app and the
|
||||||
|
// OS reclaims the UDP socket FD ~30 s later, the next
|
||||||
|
// `socket.send` from the QUIC driver throws. Without the
|
||||||
|
// try/catch on the send loop, that throw escapes silently
|
||||||
|
// into the SupervisorJob and the connection sits in
|
||||||
|
// HANDSHAKING / CONNECTED forever — the
|
||||||
|
// ReconnectingNestsListener's terminal-state listener
|
||||||
|
// never fires, the room screen shows "live" while audio
|
||||||
|
// is dead, and the user has to back out and rejoin.
|
||||||
|
//
|
||||||
|
// Pin the contract: closing the socket out from under a
|
||||||
|
// running driver MUST flip connection.status to CLOSED
|
||||||
|
// within a bounded window (the next send-loop iteration —
|
||||||
|
// typically the next PTO firing). The reconnect orchestrator
|
||||||
|
// observes this as terminal and reschedules a fresh handshake.
|
||||||
|
val parent = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
val (driver, connection, socket) = startDriver(parent)
|
||||||
|
|
||||||
|
// Let the driver get past start() and into the read+send
|
||||||
|
// loop steady state — it doesn't matter that the handshake
|
||||||
|
// isn't completing (no peer); the send loop is alive and
|
||||||
|
// will hit the dead socket on its next drain or PTO.
|
||||||
|
delay(50)
|
||||||
|
// The driver's status here is HANDSHAKING (no peer to
|
||||||
|
// complete it). Verify that's our pre-condition rather
|
||||||
|
// than CLOSED — otherwise the test would trivially pass.
|
||||||
|
assertTrue(
|
||||||
|
connection.status != QuicConnection.Status.CLOSED,
|
||||||
|
"pre-condition: connection must NOT yet be CLOSED (was ${connection.status})",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simulate the OS killing the socket. UdpSocket.close()
|
||||||
|
// is the same path Android takes when the kernel reclaims
|
||||||
|
// a backgrounded app's FDs.
|
||||||
|
socket.close()
|
||||||
|
|
||||||
|
// Wait for the send loop to actually attempt a send and
|
||||||
|
// raise. The send loop fires every PTO; the first PTO is
|
||||||
|
// ~1 s for a connection without an RTT sample. Give it 3 s
|
||||||
|
// of headroom.
|
||||||
|
val startedAt = System.nanoTime()
|
||||||
|
while (connection.status != QuicConnection.Status.CLOSED &&
|
||||||
|
(System.nanoTime() - startedAt) < 5_000_000_000L // 5 s in nanos
|
||||||
|
) {
|
||||||
|
delay(50)
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
QuicConnection.Status.CLOSED,
|
||||||
|
connection.status,
|
||||||
|
"connection must transition to CLOSED after socket dies; pre-fix it would " +
|
||||||
|
"stay HANDSHAKING/CONNECTED indefinitely because the send-loop throw " +
|
||||||
|
"escaped the SupervisorJob silently",
|
||||||
|
)
|
||||||
|
// closeReason should be populated for observability — the
|
||||||
|
// ReconnectingNestsListener orchestrator surfaces this in
|
||||||
|
// its NestsListenerState.Failed.reason. Either the read
|
||||||
|
// loop's finally (socket.receive returns null on close) or
|
||||||
|
// the send loop's catch (socket.send throws on closed
|
||||||
|
// socket) gets there first depending on scheduler timing;
|
||||||
|
// both produce a human-readable message that mentions the
|
||||||
|
// loop. The exact path is racy, so we just check both
|
||||||
|
// possible reason strings cover the symptom.
|
||||||
|
val reason = connection.closeReason
|
||||||
|
assertTrue(
|
||||||
|
reason != null && (reason.contains("read loop") || reason.contains("send loop")),
|
||||||
|
"closeReason must mention the loop death so observability surfaces the cause; " +
|
||||||
|
"got '$reason'",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cleanup — the driver's job tree should still wind down
|
||||||
|
// cleanly even though we tore the socket out from under it.
|
||||||
|
driver.close()
|
||||||
|
withTimeoutOrNull(2_000L) { driver.closeTeardownJob?.join() }
|
||||||
|
parent.cancel()
|
||||||
|
withTimeoutOrNull(2_000L) { driver.driverJob.join() }
|
||||||
|
assertTrue(
|
||||||
|
driver.driverJob.isCompleted,
|
||||||
|
"driver job must complete cleanly even after a socket-death teardown",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** True if a UDP send on the socket throws — i.e. close() has run. */
|
/** True if a UDP send on the socket throws — i.e. close() has run. */
|
||||||
private fun socketIsClosed(socket: UdpSocket): Boolean =
|
private fun socketIsClosed(socket: UdpSocket): Boolean =
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user