fix(quic): lifecycle hangs + TLS hardening from second-pass audit

Six fixes from the round-2 audit, all of which would either hang the
client on failure modes or weaken security against a misbehaving server.

QuicConnection / awaitHandshake hang fixes:
  - QuicConnectionClosedException + markClosedExternally(reason).
  - close() now also calls signalHandshakeFailed if !handshakeComplete.
  - QuicConnectionParser inbound CONNECTION_CLOSE → markClosedExternally
    (was: just flip status, leaving awaiters hanging forever).
  - QuicConnectionDriver.readLoop has a finally-block that calls
    markClosedExternally + wakeup so when the socket closes mid-handshake
    or the server closes uncleanly, awaitHandshake() throws instead of
    suspending forever.

QuicConnectionDriver.close() no longer cancels its own caller scope:
  Previously close() was suspend, called connection.close (acquires lock)
  → wakeup → scope.cancel → socket.close. If close() is invoked from
  inside the driver scope (e.g. by the WT factory's exception cleanup
  path), scope.cancel cancels the very coroutine running close, so
  socket.close may never run. close() is now non-suspend and dispatches
  the teardown onto parentScope.launch so the cancel never reaches its
  caller.

QuicWebTransportFactory.connect:
  - readResponseStatus wrapped in withTimeoutOrNull(connectTimeoutMillis,
    default 10s). Dead network → HandshakeFailed instead of forever-hang.
  - Whole post-handshake setup (open control + request streams + read
    response) now in try/catch that calls driver.close() on any unexpected
    exception. Previously a thrown SocketException between awaitHandshake()
    and the explicit non-2xx branch leaked the driver + UDP socket.

JdkCertificateValidator.validateChain authType:
  Was hardcoded "ECDHE_ECDSA". Now derived from the leaf cert's public-key
  algorithm: "RSA" → ECDHE_RSA, "EC"/"EdDSA" → ECDHE_ECDSA. Some Android
  trust managers (RootTrustManager, NetworkSecurityConfig) gate
  algorithm-specific pinning rules on this string and may reject mismatched
  combos.

TlsExtension.decodeList bounds:
  Inner reads were unbounded against the extension-list end. A malicious
  server could claim totalLen=4 but encode an extension whose data length
  declared 1000, reading past the supposed extension-list end into
  trailing handshake bytes. Now: validates totalLen ≤ r.limit upfront and
  asserts r.position ≤ end after each inner decode.

TlsClient KeyUpdate close-on-receipt:
  Previously HS_KEY_UPDATE was silently dropped. RFC 9001 §6: if the peer
  rotates keys and we keep using the old ones, AEAD opens silently fail
  → connection wedges. Until we implement rotation, KeyUpdate must throw
  so the QUIC layer closes cleanly with a fatal error instead of
  desynchronizing.

All :quic:jvmTest + :nestsClient:jvmTest pass.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 22:42:10 +00:00
parent 9d0a7ead7a
commit 94a3d32a6d
7 changed files with 161 additions and 52 deletions
@@ -72,6 +72,11 @@ class QuicWebTransportFactory(
* dev environments can pass a permissive validator explicitly.
*/
private val certificateValidator: CertificateValidator = JdkCertificateValidator(),
/**
* Maximum time we'll suspend waiting for the server's HEADERS response on
* the Extended CONNECT request stream before giving up with HandshakeFailed.
*/
private val connectTimeoutMillis: Long = 10_000L,
) : WebTransportFactory {
override suspend fun connect(
authority: String,
@@ -107,32 +112,56 @@ class QuicWebTransportFactory(
)
}
// Open the H3 control stream and push SETTINGS.
val controlStream = conn.openUniStream()
val controlBytes =
byteArrayOf(Http3StreamType.CONTROL.toByte()) + buildClientWebTransportSettings().encodeFrame()
controlStream.send.enqueue(controlBytes)
// Everything from here through readResponseStatus needs cleanup on
// any exception — wrap so a thrown SocketException / coroutine cancel
// doesn't leak the driver + UDP socket.
try {
// Open the H3 control stream and push SETTINGS.
val controlStream = conn.openUniStream()
val controlBytes =
byteArrayOf(Http3StreamType.CONTROL.toByte()) + buildClientWebTransportSettings().encodeFrame()
controlStream.send.enqueue(controlBytes)
// Open the Extended CONNECT request stream.
val requestStream = conn.openBidiStream()
val headers = buildExtendedConnectHeaders(authority, path, bearerToken)
requestStream.send.enqueue(encodeHeadersFrame(headers))
driver.wakeup()
// Open the Extended CONNECT request stream.
val requestStream = conn.openBidiStream()
val headers = buildExtendedConnectHeaders(authority, path, bearerToken)
requestStream.send.enqueue(encodeHeadersFrame(headers))
driver.wakeup()
// Wait for the response HEADERS and verify :status is 2xx before
// declaring the WebTransport session open. Per RFC 9220 a non-2xx
// status means the server rejected the upgrade.
val responseStatus = readResponseStatus(requestStream)
if (responseStatus !in 200..299) {
// Wait for the response HEADERS and verify :status is 2xx before
// declaring the WebTransport session open. Per RFC 9220 a non-2xx
// status means the server rejected the upgrade.
val responseStatus =
kotlinx.coroutines.withTimeoutOrNull(connectTimeoutMillis) {
readResponseStatus(requestStream)
} ?: -1
if (responseStatus < 0) {
driver.close()
throw WebTransportException(
kind = WebTransportException.Kind.HandshakeFailed,
message = "WebTransport CONNECT response timed out after ${connectTimeoutMillis}ms",
)
}
if (responseStatus !in 200..299) {
driver.close()
throw WebTransportException(
kind = WebTransportException.Kind.ConnectRejected,
message = "WebTransport CONNECT returned :status=$responseStatus",
)
}
val state = QuicWebTransportSessionState(conn, driver, requestStream.streamId)
return QuicWebTransportSession(state)
} catch (we: WebTransportException) {
throw we
} catch (t: Throwable) {
driver.close()
throw WebTransportException(
kind = WebTransportException.Kind.ConnectRejected,
message = "WebTransport CONNECT returned :status=$responseStatus",
kind = WebTransportException.Kind.HandshakeFailed,
message = "WebTransport setup failed: ${t.message}",
cause = t,
)
}
val state = QuicWebTransportSessionState(conn, driver, requestStream.streamId)
return QuicWebTransportSession(state)
}
/**