fix(quic): six critical correctness + security bugs from review

Synthesizes findings from four parallel layer reviews. Each fix here would
have broken or weakened live interop:

C1 — TlsClient stored cipher suite (was hardcoded)
  TlsClient.currentCipherSuite() always returned AES-128-GCM-SHA256, even
  when the server picked TLS_CHACHA20_POLY1305_SHA256. The QUIC layer would
  then install AES-GCM AEAD + AES-ECB header protection over a ChaCha20-
  derived secret → silent 1-RTT decrypt failure. Now stores the negotiated
  cipher from ServerHello and returns it.

C2 — AckTracker records the actual packet PN, not the largest received
  dispatchFrames() in QuicConnectionParser was passing
  state.pnSpace.largestReceived to the ACK tracker. With two coalesced
  packets in one datagram, only the larger PN was ever tracked → server
  retransmits the smaller forever. Plumb the parsed packet's PN through
  dispatchFrames and feed it to receivedPacket(). Also always record (even
  for non-ack-eliciting packets) so the peer's loss recovery sees a
  contiguous picture.

C5 — bounds-check every readVarint().toInt() length in frame decode
  CRYPTO, STREAM (LEN), CONNECTION_CLOSE reason, DATAGRAM_LEN, and ACK
  range count all read a 62-bit varint, truncate to Int, and pass straight
  to readBytes / repeat. A hostile peer could send length=2^62-1 → crash or
  multi-GB allocation. Added boundedLength() + boundedRangeCount() helpers
  that reject if value < 0 or > remaining.

C6 — frame type dispatch uses readVarint, not readByte
  RFC 9000 §12.4 specifies frame types as varints. We were reading a single
  byte, so any extension frame type ≥ 0x40 (e.g. ACK_FREQUENCY 0xAF) would
  be mis-dispatched. All current types are < 64 so the 1-byte form matches
  the 1-byte varint, but the change is forward-compatible.

C7 — CertificateValidator required (no silent skip)
  Both QuicConnection and TlsClient previously had `validator: ... = null`
  defaults. A misconfigured caller would silently accept any server's
  certificate. Removed the defaults; null is now an explicit opt-in for
  in-process loopback tests. Added JdkCertificateValidator backed by the
  platform / JDK system trust store with proper SAN-based hostname check
  and signature verification for ECDSA / RSA-PSS / RSA-PKCS1 / Ed25519.
  QuicWebTransportFactory uses it by default.

C8 — thread-safety on connection state
  QuicConnection.streams, pendingDatagrams, nextLocalBidiIndex/UniIndex
  were mutated from the driver loops and from app coroutines without
  synchronization → ConcurrentModificationException waiting to happen.
  Moved the mutex onto QuicConnection itself; the driver wraps feed/drain
  with `connection.lock.withLock { ... }`, public mutators became suspend
  and acquire the same lock. Internal helpers used by feed/drain are
  marked `Locked` to make the precondition explicit.

  Also replaced the `delay(2)` send-loop polling with a CONFLATED
  `Channel<Unit>` wakeup — app writes (queueDatagram, openBidiStream,
  stream write via the WT adapter) call `driver.wakeup()`. Idle CPU
  drops to zero between packets.

  awaitHandshake() replaces the busy-poll over `conn.status` in
  QuicWebTransportFactory.connect — backed by a CompletableDeferred that
  the TLS listener completes on onHandshakeComplete() or fails on a torn
  down read loop.

Tests: full :quic:jvmTest and :nestsClient:jvmTest suites pass — every
RFC 9001 Appendix A vector still verifies bit-for-bit.

Remaining critical work (in progress, separate commits):
  C3+C4 — HTTP/3 frame reader + WebTransport response :status check
  C9    — flow-control enforcement + MAX_STREAM_DATA crediting

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 21:58:45 +00:00
parent 92ba582ff6
commit e250b76272
10 changed files with 374 additions and 115 deletions
@@ -26,6 +26,8 @@ import com.vitorpamplona.quic.connection.QuicConnectionDriver
import com.vitorpamplona.quic.http3.Http3StreamType
import com.vitorpamplona.quic.http3.buildClientWebTransportSettings
import com.vitorpamplona.quic.stream.QuicStream
import com.vitorpamplona.quic.tls.CertificateValidator
import com.vitorpamplona.quic.tls.JdkCertificateValidator
import com.vitorpamplona.quic.transport.UdpSocket
import com.vitorpamplona.quic.webtransport.QuicWebTransportSessionState
import com.vitorpamplona.quic.webtransport.buildExtendedConnectHeaders
@@ -61,6 +63,12 @@ import kotlinx.coroutines.flow.flow
class QuicWebTransportFactory(
private val parentScope: CoroutineScope =
CoroutineScope(SupervisorJob() + Dispatchers.IO),
/**
* Certificate validator. Defaults to [JdkCertificateValidator], which
* delegates to the platform / JDK system trust store. Tests or self-signed
* dev environments can pass a permissive validator explicitly.
*/
private val certificateValidator: CertificateValidator = JdkCertificateValidator(),
) : WebTransportFactory {
override suspend fun connect(
authority: String,
@@ -69,13 +77,24 @@ class QuicWebTransportFactory(
): WebTransportSession {
val (host, port) = splitAuthority(authority)
val socket = UdpSocket.connect(host, port)
val conn = QuicConnection(serverName = host, config = QuicConnectionConfig())
val conn =
QuicConnection(
serverName = host,
config = QuicConnectionConfig(),
tlsCertificateValidator = certificateValidator,
)
val driver = QuicConnectionDriver(conn, socket, parentScope)
driver.start()
// Spin until handshake completes or fails. In Phase L+ this is a deadline.
while (conn.status == QuicConnection.Status.HANDSHAKING) {
kotlinx.coroutines.delay(20)
try {
conn.awaitHandshake()
} catch (t: Throwable) {
driver.close()
throw WebTransportException(
kind = WebTransportException.Kind.HandshakeFailed,
message = "QUIC handshake failed: ${t.message}",
cause = t,
)
}
if (conn.status != QuicConnection.Status.CONNECTED) {
driver.close()
@@ -117,7 +136,7 @@ class QuicWebTransportSession(
override suspend fun openBidiStream(): WebTransportBidiStream {
val s = state.openBidiStream()
return QuicBidiStreamAdapter(s)
return QuicBidiStreamAdapter(s, state.driver)
}
override fun incomingUniStreams(): Flow<WebTransportReadStream> =
@@ -153,15 +172,18 @@ class QuicWebTransportSession(
private class QuicBidiStreamAdapter(
private val stream: QuicStream,
private val driver: com.vitorpamplona.quic.connection.QuicConnectionDriver,
) : WebTransportBidiStream {
override fun incoming(): Flow<ByteArray> = stream.incoming
override suspend fun write(chunk: ByteArray) {
stream.send.enqueue(chunk)
driver.wakeup()
}
override suspend fun finish() {
stream.send.finish()
driver.wakeup()
}
}
@@ -29,6 +29,9 @@ import com.vitorpamplona.quic.stream.StreamId
import com.vitorpamplona.quic.tls.TlsClient
import com.vitorpamplona.quic.tls.TlsConstants
import com.vitorpamplona.quic.tls.TlsSecretsListener
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* QUIC v1 client connection. The orchestrator that owns:
@@ -55,7 +58,14 @@ import com.vitorpamplona.quic.tls.TlsSecretsListener
class QuicConnection(
val serverName: String,
val config: QuicConnectionConfig,
val tlsCertificateValidator: com.vitorpamplona.quic.tls.CertificateValidator? = null,
/**
* MUST be non-null for any network-facing connection. Pass an explicit
* `null` only when the caller has audited the threat model and accepts
* unauthenticated TLS (e.g. an in-process test loopback). There is no
* silent default — production callers either pass a system-trust-store
* validator or get a misconfiguration that's obvious in code review.
*/
val tlsCertificateValidator: com.vitorpamplona.quic.tls.CertificateValidator?,
val nowMillis: () -> Long = {
kotlin.time.Clock.System
.now()
@@ -124,9 +134,25 @@ class QuicConnection(
handshakeComplete = true
if (status == Status.HANDSHAKING) status = Status.CONNECTED
applyPeerTransportParameters()
handshakeDoneSignal.complete(Unit)
}
}
private val handshakeDoneSignal = CompletableDeferred<Unit>()
/**
* Suspend until the handshake completes or fails. Throws if the connection
* was closed before reaching CONNECTED.
*/
suspend fun awaitHandshake() {
handshakeDoneSignal.await()
}
/** Mark the handshake as failed (called by the driver when read loop dies during handshaking). */
internal fun signalHandshakeFailed(cause: Throwable) {
if (!handshakeDoneSignal.isCompleted) handshakeDoneSignal.completeExceptionally(cause)
}
val tls: TlsClient =
TlsClient(
serverName = serverName,
@@ -187,48 +213,61 @@ class QuicConnection(
}
}
/** Allocate a new client-initiated bidirectional stream. */
fun openBidiStream(): QuicStream {
val id = StreamId.build(StreamId.Kind.CLIENT_BIDI, nextLocalBidiIndex++)
val stream = QuicStream(id, QuicStream.Direction.BIDIRECTIONAL)
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataBidiRemote ?: config.initialMaxStreamDataBidiRemote
stream.receiveLimit = config.initialMaxStreamDataBidiLocal
streams[id] = stream
return stream
}
/**
* Single mutex protecting connection-wide mutable state: streams map,
* datagram queues, stream-id counters, status. The driver acquires this
* around its read/send loops; public API methods listed below acquire it
* before mutating. Internal-only methods (used only from inside the
* driver loops) do NOT lock — caller must hold the lock.
*/
val lock: Mutex = Mutex()
/** Allocate a new client-initiated unidirectional (write-only) stream. */
fun openUniStream(): QuicStream {
val id = StreamId.build(StreamId.Kind.CLIENT_UNI, nextLocalUniIndex++)
val stream = QuicStream(id, QuicStream.Direction.UNIDIRECTIONAL_LOCAL_TO_REMOTE)
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataUni ?: config.initialMaxStreamDataUni
stream.receiveLimit = 0L // can't receive
streams[id] = stream
return stream
}
/** Allocate a new client-initiated bidirectional stream. Locked. */
suspend fun openBidiStream(): QuicStream =
lock.withLock {
val id = StreamId.build(StreamId.Kind.CLIENT_BIDI, nextLocalBidiIndex++)
val stream = QuicStream(id, QuicStream.Direction.BIDIRECTIONAL)
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataBidiRemote ?: config.initialMaxStreamDataBidiRemote
stream.receiveLimit = config.initialMaxStreamDataBidiLocal
streams[id] = stream
stream
}
fun pollIncomingPeerStream(): QuicStream? = newPeerStreams.removeFirstOrNull()
/** Allocate a new client-initiated unidirectional (write-only) stream. Locked. */
suspend fun openUniStream(): QuicStream =
lock.withLock {
val id = StreamId.build(StreamId.Kind.CLIENT_UNI, nextLocalUniIndex++)
val stream = QuicStream(id, QuicStream.Direction.UNIDIRECTIONAL_LOCAL_TO_REMOTE)
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataUni ?: config.initialMaxStreamDataUni
stream.receiveLimit = 0L // can't receive
streams[id] = stream
stream
}
fun streamById(id: Long): QuicStream? = streams[id]
suspend fun pollIncomingPeerStream(): QuicStream? = lock.withLock { newPeerStreams.removeFirstOrNull() }
fun queueDatagram(payload: ByteArray) {
pendingDatagrams.addLast(payload)
}
suspend fun streamById(id: Long): QuicStream? = lock.withLock { streams[id] }
fun pollIncomingDatagram(): ByteArray? = incomingDatagrams.removeFirstOrNull()
suspend fun queueDatagram(payload: ByteArray) = lock.withLock { pendingDatagrams.addLast(payload) }
suspend fun pollIncomingDatagram(): ByteArray? = lock.withLock { incomingDatagrams.removeFirstOrNull() }
/** Initiate a graceful close. */
fun close(
suspend fun close(
errorCode: Long,
reason: String,
) {
if (status == Status.CLOSED || status == Status.CLOSING) return
) = lock.withLock {
if (status == Status.CLOSED || status == Status.CLOSING) return@withLock
closeErrorCode = errorCode
closeReason = reason
status = Status.CLOSING
}
internal fun getOrCreatePeerStream(id: Long): QuicStream {
/**
* Caller must hold [lock]. Used by [QuicConnectionParser] inside the
* driver's read loop, which already holds the connection lock.
*/
internal fun getOrCreatePeerStreamLocked(id: Long): QuicStream {
streams[id]?.let { return it }
val direction =
when (StreamId.kindOf(id)) {
@@ -252,9 +291,15 @@ class QuicConnection(
EncryptionLevel.APPLICATION -> application
}
fun streamsView(): Map<Long, QuicStream> = streams
/** Caller must hold [lock]. Snapshot of streams for the driver's send loop. */
internal fun streamsLocked(): Map<Long, QuicStream> = streams
fun pendingDatagramsView(): ArrayDeque<ByteArray> = pendingDatagrams
/** Caller must hold [lock]. Pending datagram queue for the driver's send loop. */
internal fun pendingDatagramsLocked(): ArrayDeque<ByteArray> = pendingDatagrams
fun incomingDatagramsBuffer(): ArrayDeque<ByteArray> = incomingDatagrams
/** Caller must hold [lock]. Inbound datagram queue, written by the read loop. */
internal fun incomingDatagramsLocked(): ArrayDeque<ByteArray> = incomingDatagrams
/** Caller must hold [lock]. */
internal fun streamByIdLocked(id: Long): QuicStream? = streams[id]
}
@@ -26,18 +26,22 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Owns the UDP socket and runs the read + send loops for a [QuicConnection].
*
* Lifecycle:
* - [open] connects the UDP socket, calls [QuicConnection.start], spawns
* read + send loops, and suspends until handshake completes.
* - [close] cancels the loops and closes the socket.
* Synchronization: every public mutator on [QuicConnection] takes
* `connection.lock`; the driver acquires the same lock around feed + drain.
* That guarantees the read loop, send loop, and app coroutines never see a
* mid-mutation state of the streams map / datagram queues / counters.
*
* The send loop is woken by a `Channel<Unit>(CONFLATED)` rather than a
* polling timer no idle CPU. App writes ([QuicConnection.queueDatagram]
* and [QuicConnection.openBidiStream]/[com.vitorpamplona.quic.stream.SendBuffer.enqueue])
* call [wakeup] to nudge the send loop.
*/
class QuicConnectionDriver(
val connection: QuicConnection,
@@ -47,39 +51,48 @@ class QuicConnectionDriver(
) {
private val job = SupervisorJob(parentScope.coroutineContext[Job])
private val scope = CoroutineScope(parentScope.coroutineContext + job + Dispatchers.IO)
private val sendLock = Mutex()
private val sendWakeup = Channel<Unit>(Channel.CONFLATED)
fun start() {
connection.start()
scope.launch { readLoop() }
scope.launch { sendLoop() }
// Initial nudge so the ClientHello goes out immediately.
sendWakeup.trySend(Unit)
}
/** Nudge the send loop. Safe to call from any coroutine. */
fun wakeup() {
sendWakeup.trySend(Unit)
}
private suspend fun readLoop() {
while (true) {
while (connection.status != QuicConnection.Status.CLOSED) {
val datagram = socket.receive() ?: break
sendLock.withLock {
connection.lock.withLock {
feedDatagram(connection, datagram, nowMillis())
}
// Inbound data may have produced new outbound (acks, crypto, etc.).
wakeup()
}
}
private suspend fun sendLoop() {
while (connection.status != QuicConnection.Status.CLOSED) {
sendLock.withLock {
connection.lock.withLock {
while (true) {
val out = drainOutbound(connection, nowMillis()) ?: break
socket.send(out)
}
}
// Tiny sleep to avoid tight-spin between drains; in practice, application
// writes (via stream.send.enqueue + queueDatagram) wake the loop next tick.
delay(2)
// Suspend until the next wakeup — no busy polling.
sendWakeup.receive()
}
}
fun close() {
suspend fun close() {
connection.close(0L, "")
wakeup() // let the send loop emit CONNECTION_CLOSE
scope.cancel()
socket.close()
}
@@ -100,7 +100,7 @@ private fun feedLongHeaderPacket(
conn.destinationConnectionId = parsed.packet.scid
}
dispatchFrames(conn, level, parsed.packet.payload, nowMillis)
dispatchFrames(conn, level, parsed.packet.payload, parsed.packet.packetNumber, nowMillis)
return parsed.consumed
}
@@ -125,13 +125,14 @@ private fun feedShortHeaderPacket(
largestReceivedInSpace = state.pnSpace.largestReceived,
) ?: return
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
dispatchFrames(conn, EncryptionLevel.APPLICATION, parsed.packet.payload, nowMillis)
dispatchFrames(conn, EncryptionLevel.APPLICATION, parsed.packet.payload, parsed.packet.packetNumber, nowMillis)
}
private fun dispatchFrames(
conn: QuicConnection,
level: EncryptionLevel,
payload: ByteArray,
packetNumber: Long,
nowMillis: Long,
) {
val frames = decodeFrames(payload)
@@ -164,7 +165,7 @@ private fun dispatchFrames(
is StreamFrame -> {
ackEliciting = true
val stream = conn.getOrCreatePeerStream(frame.streamId)
val stream = conn.getOrCreatePeerStreamLocked(frame.streamId)
stream.receive.insert(frame.offset, frame.data, frame.fin)
val data = stream.receive.readContiguous()
if (data.isNotEmpty()) {
@@ -177,7 +178,7 @@ private fun dispatchFrames(
is DatagramFrame -> {
ackEliciting = true
conn.incomingDatagramsBuffer().addLast(frame.data)
conn.incomingDatagramsLocked().addLast(frame.data)
}
is MaxDataFrame -> {
@@ -185,7 +186,7 @@ private fun dispatchFrames(
}
is MaxStreamDataFrame -> {
conn.streamById(frame.streamId)?.let {
conn.streamByIdLocked(frame.streamId)?.let {
if (frame.maxStreamData > it.sendCredit) it.sendCredit = frame.maxStreamData
}
}
@@ -216,13 +217,10 @@ private fun dispatchFrames(
}
}
}
if (ackEliciting) {
// Get largest received from pn space and feed into ack tracker.
val pn = state.pnSpace.largestReceived
if (pn >= 0) {
state.ackTracker.receivedPacket(pn, ackEliciting = true, receivedAtMillis = nowMillis)
}
}
// Always record the packet's actual PN — even non-ack-eliciting packets
// need to appear in our ACK ranges so the peer's loss-recovery sees a
// contiguous picture of what we received.
state.ackTracker.receivedPacket(packetNumber, ackEliciting = ackEliciting, receivedAtMillis = nowMillis)
}
private fun drainTlsOutbound(conn: QuicConnection) {
@@ -213,15 +213,15 @@ private fun buildApplicationPacket(
state.ackTracker.buildAckFrame(nowMillis, conn.config.ackDelayExponent.toInt())?.let { frames += it }
// Pending datagrams
while (conn.pendingDatagramsView().isNotEmpty()) {
val payload = conn.pendingDatagramsView().removeFirst()
while (conn.pendingDatagramsLocked().isNotEmpty()) {
val payload = conn.pendingDatagramsLocked().removeFirst()
frames += DatagramFrame(payload, explicitLength = true)
if (frames.size >= 16) break
}
// Drain stream send buffers — round-robin keeping packet under MTU.
var packetBudget = 1100
for ((id, stream) in conn.streamsView()) {
for ((id, stream) in conn.streamsLocked()) {
if (packetBudget <= 64) break
val chunk = stream.send.takeChunk(maxBytes = packetBudget - 32) ?: continue
if (chunk.data.isNotEmpty() || chunk.fin) {
@@ -235,56 +235,42 @@ fun decodeFrames(data: ByteArray): List<Frame> {
val out = mutableListOf<Frame>()
val r = QuicReader(data)
while (r.hasMore()) {
val typeByte = r.readByte()
if (typeByte == 0x00) {
// Padding — skip; many padding bytes coalesce into one logical PaddingFrame.
continue
}
// Move back one byte: type can be a varint, but for the codes < 0x40 it's identical.
// For datagrams with length prefix the type is 0x31 which fits in 1 byte.
// ACK_ECN (0x03), STREAM with all flag combinations (0x08..0x0f) all <= 0x40.
// We don't expect any frame type to exceed 0x40 in our minimal subset.
val type = typeByte.toLong()
// Frame types are varints per RFC 9000 §12.4; for codes < 0x40 the
// varint is a single byte, but we MUST decode as varint so future
// extension frame types ≥ 0x40 are correctly recognized (or rejected).
val type = r.readVarint()
if (type == FrameType.PADDING) continue
when {
type == FrameType.PING -> {
out += PingFrame
}
type == FrameType.ACK -> {
type == FrameType.ACK || type == FrameType.ACK_ECN -> {
val largest = r.readVarint()
val delay = r.readVarint()
val numRanges = r.readVarint().toInt()
val numRangesRaw = r.readVarint()
// Cap range count at remaining bytes — each range needs ≥ 2 varint bytes.
val numRanges = boundedRangeCount(numRangesRaw, r.remaining)
val firstRange = r.readVarint()
val ranges = mutableListOf<AckRange>()
repeat(numRanges) {
val ranges = ArrayList<AckRange>(numRanges)
for (i in 0 until numRanges) {
val gap = r.readVarint()
val len = r.readVarint()
ranges += AckRange(gap, len)
}
out += AckFrame(largest, delay, firstRange, ranges)
}
type == FrameType.ACK_ECN -> {
val largest = r.readVarint()
val delay = r.readVarint()
val numRanges = r.readVarint().toInt()
val firstRange = r.readVarint()
val ranges = mutableListOf<AckRange>()
repeat(numRanges) {
val gap = r.readVarint()
val len = r.readVarint()
ranges += AckRange(gap, len)
if (type == FrameType.ACK_ECN) {
// Skip ECT0, ECT1, CE counts.
r.readVarint()
r.readVarint()
r.readVarint()
}
// skip ECN counts
r.readVarint()
r.readVarint()
r.readVarint()
out += AckFrame(largest, delay, firstRange, ranges)
}
type == FrameType.CRYPTO -> {
val offset = r.readVarint()
val len = r.readVarint().toInt()
val len = boundedLength(r.readVarint(), r.remaining, "CRYPTO")
val data2 = r.readBytes(len)
out += CryptoFrame(offset, data2)
}
@@ -298,7 +284,7 @@ fun decodeFrames(data: ByteArray): List<Frame> {
val offset = if (hasOff) r.readVarint() else 0L
val payload =
if (hasLen) {
val ln = r.readVarint().toInt()
val ln = boundedLength(r.readVarint(), r.remaining, "STREAM")
r.readBytes(ln)
} else {
// "remainder of the packet"
@@ -341,6 +327,9 @@ fun decodeFrames(data: ByteArray): List<Frame> {
val seq = r.readVarint()
val retire = r.readVarint()
val cidLen = r.readByte()
if (cidLen !in 1..20) {
throw QuicCodecException("NEW_CONNECTION_ID cidLen out of range: $cidLen")
}
val cid = r.readBytes(cidLen)
val token = r.readBytes(16)
out += NewConnectionIdFrame(seq, retire, cid, token)
@@ -361,14 +350,14 @@ fun decodeFrames(data: ByteArray): List<Frame> {
type == FrameType.CONNECTION_CLOSE_TRANSPORT -> {
val err = r.readVarint()
val frameType2 = r.readVarint()
val reasonLen = r.readVarint().toInt()
val reasonLen = boundedLength(r.readVarint(), r.remaining, "CONNECTION_CLOSE reason")
val reason = r.readBytes(reasonLen).decodeToString()
out += ConnectionCloseFrame(err, frameType2, reason)
}
type == FrameType.CONNECTION_CLOSE_APP -> {
val err = r.readVarint()
val reasonLen = r.readVarint().toInt()
val reasonLen = boundedLength(r.readVarint(), r.remaining, "CONNECTION_CLOSE reason")
val reason = r.readBytes(reasonLen).decodeToString()
out += ConnectionCloseFrame(err, null, reason)
}
@@ -383,7 +372,7 @@ fun decodeFrames(data: ByteArray): List<Frame> {
}
type == FrameType.DATAGRAM_LEN -> {
val ln = r.readVarint().toInt()
val ln = boundedLength(r.readVarint(), r.remaining, "DATAGRAM")
out += DatagramFrame(r.readBytes(ln), explicitLength = true)
}
@@ -401,3 +390,32 @@ fun encodeFrames(frames: List<Frame>): ByteArray {
for (f in frames) f.encode(w)
return w.toByteArray()
}
/**
* Validate that a varint length value can be represented as a non-negative
* Int and fits within the remaining buffer. Hostile peers may send 62-bit
* lengths that, if uncritically truncated by `.toInt()`, become negative or
* absurdly large and lead to a crash or DoS allocation.
*/
private fun boundedLength(
value: Long,
remaining: Int,
field: String,
): Int {
if (value < 0L || value > remaining) {
throw QuicCodecException("$field length $value out of bounds (remaining=$remaining)")
}
return value.toInt()
}
/** Same as [boundedLength] but for an ACK frame range count (each range needs ≥ 2 varint bytes). */
private fun boundedRangeCount(
value: Long,
remaining: Int,
): Int {
val maxRanges = remaining / 2 // varint min 1 byte; 2 varints per range
if (value < 0L || value > maxRanges) {
throw QuicCodecException("ACK range count $value out of bounds (remaining=$remaining)")
}
return value.toInt()
}
@@ -50,7 +50,14 @@ class TlsClient(
val serverName: String,
val transportParameters: ByteArray,
val secretsListener: TlsSecretsListener,
val certificateValidator: CertificateValidator? = null,
/**
* Certificate validator MUST be supplied for any production / network-facing
* use. The only acceptable null is in-process tests where there's no real
* server identity to authenticate (e.g. [TlsRoundTripTest]'s loopback). A
* null validator here means "no MITM protection" a misconfigured caller
* must fail loudly, not silently accept any certificate.
*/
val certificateValidator: CertificateValidator?,
/** When non-null, used as the X25519 ephemeral key (for deterministic tests). */
val fixedKeyPair: X25519KeyPair? = null,
/** When non-null, used as the ClientHello random (for deterministic tests). */
@@ -98,6 +105,7 @@ class TlsClient(
private var keyPair: X25519KeyPair? = null
private var serverKeyShare: ByteArray? = null
private var sharedSecret: ByteArray? = null
private var negotiatedCipherSuite: Int = -1
/** Begin the handshake by emitting a ClientHello at Initial level. */
fun start() {
@@ -169,6 +177,7 @@ class TlsClient(
) {
throw QuicCodecException("server picked unsupported cipher 0x${cipher.toString(16)}")
}
negotiatedCipherSuite = cipher
serverKeyShare = sh.serverKeyShareX25519
transcript.append(msg)
@@ -273,12 +282,8 @@ class TlsClient(
}
private fun currentCipherSuite(): Int {
// For Phase B we always negotiate TLS_AES_128_GCM_SHA256 first; if that's
// not the picked one, the only other we accept is ChaCha20-Poly1305-SHA256.
// The ServerHello has already validated this. We carry it implicitly via
// the SHA-256 schedule; the cipher choice only affects the AEAD/HP picked
// by the QUIC layer.
return TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256
check(negotiatedCipherSuite != -1) { "cipher suite not yet negotiated" }
return negotiatedCipherSuite
}
}
@@ -42,36 +42,39 @@ class QuicWebTransportSessionState(
get() = connection.status == QuicConnection.Status.CONNECTED
/** Open a new client-initiated bidirectional WebTransport stream. */
fun openBidiStream(): QuicStream {
suspend fun openBidiStream(): QuicStream {
val s = connection.openBidiStream()
// Prefix bytes go onto the new stream first.
s.send.enqueue(encodeWtBidiStreamPrefix(connectStreamId))
driver.wakeup()
return s
}
/** Open a new client-initiated unidirectional WebTransport stream. */
fun openUniStream(): QuicStream {
suspend fun openUniStream(): QuicStream {
val s = connection.openUniStream()
s.send.enqueue(encodeWtUniStreamPrefix(connectStreamId))
driver.wakeup()
return s
}
/** Send a WebTransport datagram via QUIC's datagram extension. */
fun sendDatagram(payload: ByteArray) {
suspend fun sendDatagram(payload: ByteArray) {
val wrapped = WtDatagram.encode(connectStreamId, payload)
connection.queueDatagram(wrapped)
driver.wakeup()
}
fun pollIncomingDatagram(): ByteArray? {
suspend fun pollIncomingDatagram(): ByteArray? {
val raw = connection.pollIncomingDatagram() ?: return null
val decoded = WtDatagram.decode(raw) ?: return null
if (decoded.sessionStreamId != connectStreamId) return null
return decoded.payload
}
fun pollIncomingPeerStream(): QuicStream? = connection.pollIncomingPeerStream()
suspend fun pollIncomingPeerStream(): QuicStream? = connection.pollIncomingPeerStream()
fun close(
suspend fun close(
errorCode: Int = 0,
reason: String = "",
) {
@@ -49,6 +49,7 @@ class TlsRoundTripTest {
serverName = "example.test",
transportParameters = ByteArray(0),
secretsListener = capturedSecrets,
certificateValidator = null, // in-process loopback; no cert chain to validate
)
client.start()
@@ -0,0 +1,154 @@
/*
* 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.tls
import com.vitorpamplona.quic.QuicCodecException
import java.io.ByteArrayInputStream
import java.security.KeyStore
import java.security.Signature
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.MGF1ParameterSpec
import java.security.spec.PSSParameterSpec
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* JDK / Android system-trust-store backed certificate validator.
*
* Delegates chain validation to `TrustManagerFactory.getInstance(...).init(null)`,
* which on Android resolves the system CA store and on the JVM resolves the
* default truststore (cacerts). Then verifies the TLS 1.3 CertificateVerify
* signature using `java.security.Signature` for RSA-PSS / ECDSA, and Quartz's
* Ed25519 for Ed25519.
*/
class JdkCertificateValidator(
/** If non-null, only certs whose chain validates against this set; defaults to system trust store. */
private val trustManager: X509TrustManager = defaultTrustManager(),
) : CertificateValidator {
private var leafCert: X509Certificate? = null
override fun validateChain(
chain: List<ByteArray>,
expectedHost: String,
) {
if (chain.isEmpty()) throw QuicCodecException("server sent empty certificate chain")
val cf = CertificateFactory.getInstance("X.509")
val parsed =
chain.map {
cf.generateCertificate(ByteArrayInputStream(it)) as X509Certificate
}
try {
// RFC 8446 §4.4.2.4 — TLS 1.3 over QUIC negotiates ALPN h3.
trustManager.checkServerTrusted(parsed.toTypedArray(), "ECDHE_ECDSA")
} catch (t: Throwable) {
throw QuicCodecException("certificate chain validation failed: ${t.message}", t)
}
// Hostname verification per RFC 6125.
if (!hostnameMatches(parsed[0], expectedHost)) {
throw QuicCodecException("certificate does not match host $expectedHost")
}
leafCert = parsed[0]
}
override fun verifySignature(
signatureAlgorithm: Int,
signature: ByteArray,
transcriptHash: ByteArray,
) {
val cert = leafCert ?: throw QuicCodecException("CertificateVerify before Certificate")
// RFC 8446 §4.4.3 — the signed content is:
// 64 spaces || "TLS 1.3, server CertificateVerify" || 0x00 || transcript_hash
val context = "TLS 1.3, server CertificateVerify".encodeToByteArray()
val signedData = ByteArray(64 + context.size + 1 + transcriptHash.size)
for (i in 0 until 64) signedData[i] = 0x20
context.copyInto(signedData, 64)
signedData[64 + context.size] = 0x00
transcriptHash.copyInto(signedData, 64 + context.size + 1)
val sig = jcaSignatureFor(signatureAlgorithm)
sig.initVerify(cert.publicKey)
sig.update(signedData)
if (!sig.verify(signature)) {
throw QuicCodecException("CertificateVerify signature did not verify")
}
}
private fun jcaSignatureFor(algorithm: Int): Signature =
when (algorithm) {
TlsConstants.SIG_ECDSA_SECP256R1_SHA256 -> Signature.getInstance("SHA256withECDSA")
TlsConstants.SIG_ECDSA_SECP384R1_SHA384 -> Signature.getInstance("SHA384withECDSA")
TlsConstants.SIG_RSA_PSS_RSAE_SHA256 -> rsaPss("SHA-256", 32)
TlsConstants.SIG_RSA_PSS_RSAE_SHA384 -> rsaPss("SHA-384", 48)
TlsConstants.SIG_RSA_PSS_RSAE_SHA512 -> rsaPss("SHA-512", 64)
TlsConstants.SIG_RSA_PKCS1_SHA256 -> Signature.getInstance("SHA256withRSA")
TlsConstants.SIG_ED25519 -> Signature.getInstance("Ed25519")
else -> throw QuicCodecException("unsupported signature algorithm 0x${algorithm.toString(16)}")
}
private fun rsaPss(
digest: String,
saltLen: Int,
): Signature {
val sig = Signature.getInstance("RSASSA-PSS")
sig.setParameter(PSSParameterSpec(digest, "MGF1", MGF1ParameterSpec(digest), saltLen, 1))
return sig
}
private fun hostnameMatches(
cert: X509Certificate,
host: String,
): Boolean {
// SAN check — RFC 6125. Walk subject alt names and accept any DNS or IP match.
val sans = cert.subjectAlternativeNames ?: return false
for (entry in sans) {
val type = entry[0] as Int
val value = entry[1].toString()
// GeneralName type 2 = dNSName, type 7 = iPAddress.
if (type == 2 && dnsMatches(value, host)) return true
if (type == 7 && value.equals(host, ignoreCase = true)) return true
}
return false
}
private fun dnsMatches(
pattern: String,
host: String,
): Boolean {
if (pattern.equals(host, ignoreCase = true)) return true
if (!pattern.startsWith("*.")) return false
// Wildcards only match a single component.
val suffix = pattern.substring(1).lowercase()
val lhost = host.lowercase()
if (!lhost.endsWith(suffix)) return false
val prefix = lhost.substring(0, lhost.length - suffix.length)
return prefix.isNotEmpty() && '.' !in prefix
}
companion object {
private fun defaultTrustManager(): X509TrustManager {
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(null as KeyStore?)
return tmf.trustManagers.firstNotNullOf { it as? X509TrustManager }
}
}
}