diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt index b14ba11af..7a71f85e5 100644 --- a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt @@ -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 = @@ -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 = stream.incoming override suspend fun write(chunk: ByteArray) { stream.send.enqueue(chunk) + driver.wakeup() } override suspend fun finish() { stream.send.finish() + driver.wakeup() } } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt index cf4cb1cb9..c5577a97e 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -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() + + /** + * 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 = streams + /** Caller must hold [lock]. Snapshot of streams for the driver's send loop. */ + internal fun streamsLocked(): Map = streams - fun pendingDatagramsView(): ArrayDeque = pendingDatagrams + /** Caller must hold [lock]. Pending datagram queue for the driver's send loop. */ + internal fun pendingDatagramsLocked(): ArrayDeque = pendingDatagrams - fun incomingDatagramsBuffer(): ArrayDeque = incomingDatagrams + /** Caller must hold [lock]. Inbound datagram queue, written by the read loop. */ + internal fun incomingDatagramsLocked(): ArrayDeque = incomingDatagrams + + /** Caller must hold [lock]. */ + internal fun streamByIdLocked(id: Long): QuicStream? = streams[id] } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt index 4837e41fd..36eb5a996 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt @@ -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(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(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() } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt index c71da2ca7..4b594a8cb 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -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) { diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt index b082e6127..1a00294b1 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt @@ -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) { diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/frame/Frame.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/frame/Frame.kt index 7efe9ab57..56ae817c2 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/frame/Frame.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/frame/Frame.kt @@ -235,56 +235,42 @@ fun decodeFrames(data: ByteArray): List { val out = mutableListOf() 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() - repeat(numRanges) { + val ranges = ArrayList(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() - 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 { 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 { 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 { 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 { } 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): 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() +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt index ffdfed4b2..a12fac9f3 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt @@ -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 } } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/QuicWebTransportSessionState.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/QuicWebTransportSessionState.kt index 9943b6329..f85123791 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/QuicWebTransportSessionState.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/QuicWebTransportSessionState.kt @@ -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 = "", ) { diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/TlsRoundTripTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/TlsRoundTripTest.kt index dddb58cb1..c98e13090 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/TlsRoundTripTest.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/TlsRoundTripTest.kt @@ -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() diff --git a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/tls/JdkCertificateValidator.kt b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/tls/JdkCertificateValidator.kt new file mode 100644 index 000000000..6a2cd0964 --- /dev/null +++ b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/tls/JdkCertificateValidator.kt @@ -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, + 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 } + } + } +}