diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/frame/FrameFuzzerTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/frame/FrameFuzzerTest.kt new file mode 100644 index 000000000..a4633af26 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/frame/FrameFuzzerTest.kt @@ -0,0 +1,165 @@ +/* + * 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.frame + +import com.vitorpamplona.quic.QuicCodecException +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.fail + +/** + * Property-based fuzzing of [decodeFrames]. The contract is simple: + * + * For ANY byte sequence, [decodeFrames] must either succeed or throw + * [QuicCodecException]. It MUST NOT crash with OOM, infinite-loop, + * IndexOutOfBoundsException, NumberFormatException, etc. + * + * A QUIC client receives whatever bytes a malicious or buggy peer sends + * after AEAD authentication. RFC 9000 §10 / §12.4 mandates closing with + * FRAME_ENCODING_ERROR / PROTOCOL_VIOLATION on any framing fault — but + * the codec itself must surface the fault as a typed exception, never as + * a JVM crash. + * + * The fuzzer is deterministic (fixed seed) so any failure can be replayed. + */ +class FrameFuzzerTest { + @Test + fun random_bytes_never_crash() { + val rng = Random(0xCAFEBABEL) + repeat(2000) { iteration -> + val len = rng.nextInt(0, 4096) + val payload = ByteArray(len).also { rng.nextBytes(it) } + try { + decodeFrames(payload) + } catch (_: QuicCodecException) { + // expected on malformed input + } catch (t: Throwable) { + fail("decodeFrames($len bytes) on iteration $iteration threw ${t::class.simpleName}: ${t.message}") + } + } + } + + @Test + fun frame_with_oversized_length_is_rejected() { + // STREAM frame (type 0x0a = OFF|LEN, no FIN) with stream_id=0, + // offset=0, length=2^62-1, then no body bytes. Must throw, not OOM. + val w = com.vitorpamplona.quic.QuicWriter() + w.writeByte(0x0e) // STREAM with OFF + LEN + FIN bits set (0x0e = 0x08+0x06) + w.writeVarint(0L) // streamId + w.writeVarint(0L) // offset + w.writeVarint(com.vitorpamplona.quic.Varint.MAX_VALUE) // length: ~4.6 quintillion + // intentionally no body bytes + try { + decodeFrames(w.toByteArray()) + fail("expected QuicCodecException on oversized STREAM length") + } catch (_: QuicCodecException) { + // good + } + } + + @Test + fun crypto_frame_with_oversized_length_is_rejected() { + val w = com.vitorpamplona.quic.QuicWriter() + w.writeByte(0x06) // CRYPTO + w.writeVarint(0L) // offset + w.writeVarint(0x4000_0000L) // length 1 GiB but only 0 bytes follow + try { + decodeFrames(w.toByteArray()) + fail("expected QuicCodecException on oversized CRYPTO length") + } catch (_: QuicCodecException) { + // good + } + } + + @Test + fun ack_frame_with_excessive_range_count_is_rejected() { + val w = com.vitorpamplona.quic.QuicWriter() + w.writeByte(0x02) // ACK + w.writeVarint(0L) // largest_acknowledged + w.writeVarint(0L) // ack_delay + w.writeVarint(0x4000_0000L) // numRanges = 1B (would allocate gigabytes) + w.writeVarint(0L) // first_ack_range + try { + decodeFrames(w.toByteArray()) + fail("expected QuicCodecException on excessive ACK range count") + } catch (_: QuicCodecException) { + // good + } + } + + @Test + fun new_connection_id_with_invalid_cid_length_is_rejected() { + // RFC 9000 §19.15: cidLen MUST be 1..20. + val w = com.vitorpamplona.quic.QuicWriter() + w.writeByte(0x18) // NEW_CONNECTION_ID + w.writeVarint(1L) // sequence + w.writeVarint(0L) // retire_prior_to + w.writeByte(0xFF) // cidLen = 255 (illegal) + // Buffer remaining bytes too short to satisfy the CID claim — we expect rejection. + try { + decodeFrames(w.toByteArray()) + fail("expected QuicCodecException on illegal NCID length") + } catch (_: QuicCodecException) { + // good + } + } + + @Test + fun connection_close_with_oversized_reason_length_is_rejected() { + val w = com.vitorpamplona.quic.QuicWriter() + w.writeByte(0x1c) // CONNECTION_CLOSE_TRANSPORT + w.writeVarint(0L) // err + w.writeVarint(0L) // frameType + w.writeVarint(0x4000_0000L) // reason length 1GB but no body + try { + decodeFrames(w.toByteArray()) + fail("expected QuicCodecException on oversized CONNECTION_CLOSE reason") + } catch (_: QuicCodecException) { + // good + } + } + + @Test + fun datagram_len_with_oversized_length_is_rejected() { + val w = com.vitorpamplona.quic.QuicWriter() + w.writeByte(0x31) // DATAGRAM_LEN + w.writeVarint(0x4000_0000L) // 1GB + try { + decodeFrames(w.toByteArray()) + fail("expected QuicCodecException on oversized DATAGRAM length") + } catch (_: QuicCodecException) { + // good + } + } + + @Test + fun valid_frame_followed_by_garbage_throws_on_garbage() { + // Valid PING (0x01) then a byte that's not a valid frame type at all + // (0x40 with no continuation — actually a 2-byte varint; let's try 0x60 = unknown 1-byte type). + val payload = byteArrayOf(0x01, 0x60) + try { + decodeFrames(payload) + fail("expected QuicCodecException on unknown frame type") + } catch (_: QuicCodecException) { + // good + } + } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/recovery/AckTrackerCoalescedTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/recovery/AckTrackerCoalescedTest.kt new file mode 100644 index 000000000..38112bb1c --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/recovery/AckTrackerCoalescedTest.kt @@ -0,0 +1,75 @@ +/* + * 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.recovery + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Regression coverage for the bug where AckTracker only saw the largest PN + * per inbound batch (because the parser was passing + * `state.pnSpace.largestReceived` instead of the just-decrypted packet's + * actual PN). With two coalesced packets in one datagram, only the larger PN + * was tracked — the server retransmits the smaller forever. + */ +class AckTrackerCoalescedTest { + @Test + fun two_packets_with_distinct_pns_both_get_acked() { + val tracker = AckTracker() + // Two coalesced packets in one datagram: PN 5 then PN 6. + tracker.receivedPacket(packetNumber = 5L, ackEliciting = true, receivedAtMillis = 1000L) + tracker.receivedPacket(packetNumber = 6L, ackEliciting = true, receivedAtMillis = 1000L) + val ack = tracker.buildAckFrame(nowMillis = 1010L) + assertNotNull(ack) + assertEquals(6L, ack.largestAcknowledged) + assertEquals(1L, ack.firstAckRange, "first range covers PNs 5..6 (length 1 = 2 packets)") + assertTrue(ack.additionalRanges.isEmpty(), "no gap between PN 5 and PN 6 — single contiguous range") + } + + @Test + fun gapped_pns_produce_two_ranges() { + val tracker = AckTracker() + tracker.receivedPacket(packetNumber = 0L, ackEliciting = true, receivedAtMillis = 1000L) + tracker.receivedPacket(packetNumber = 2L, ackEliciting = true, receivedAtMillis = 1000L) // gap at 1 + val ack = tracker.buildAckFrame(nowMillis = 1010L)!! + assertEquals(2L, ack.largestAcknowledged) + assertEquals(0L, ack.firstAckRange, "first range covers PN 2 alone") + assertEquals(1, ack.additionalRanges.size) + // gap = previous_smallest - current_largest - 2 = 2 - 0 - 2 = 0 (one packet missing between) + assertEquals(0L, ack.additionalRanges[0].gap) + assertEquals(0L, ack.additionalRanges[0].ackRangeLength, "second range covers PN 0 alone") + } + + @Test + fun out_of_order_arrival_still_yields_one_contiguous_range() { + val tracker = AckTracker() + // Arrive 7, 5, 6 — out of order but contiguous. + tracker.receivedPacket(7L, true, 1000L) + tracker.receivedPacket(5L, true, 1001L) + tracker.receivedPacket(6L, true, 1002L) + val ack = tracker.buildAckFrame(1010L)!! + assertEquals(7L, ack.largestAcknowledged) + assertEquals(2L, ack.firstAckRange, "5..7 is 3 packets = length 2") + assertTrue(ack.additionalRanges.isEmpty()) + } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/stream/FlowControlEnforcementTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/stream/FlowControlEnforcementTest.kt new file mode 100644 index 000000000..8b77d88c9 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/stream/FlowControlEnforcementTest.kt @@ -0,0 +1,116 @@ +/* + * 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.stream + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Asserts the SendBuffer correctly bounds [takeChunk] by `maxBytes` and that + * unsent bytes remain in the buffer for a later call. + * + * The connection-level writer enforces flow control by passing + * `min(packet_budget, sendCredit - sentOffset)` as `maxBytes`; we verify + * the buffer respects that contract. + */ +class FlowControlEnforcementTest { + @Test + fun take_chunk_respects_max_bytes() { + val buf = SendBuffer() + buf.enqueue(ByteArray(100) { it.toByte() }) + // sendCredit acts via maxBytes here. + val first = buf.takeChunk(maxBytes = 30) + assertNotNull(first) + assertEquals(30, first.data.size) + assertEquals(0L, first.offset) + assertEquals(70, buf.readableBytes, "unsent bytes remain") + } + + @Test + fun take_chunk_zero_with_pending_bytes_returns_null() { + // The writer passes maxBytes=0 when sendCredit is exhausted. + val buf = SendBuffer() + buf.enqueue(ByteArray(50)) + assertNull(buf.takeChunk(maxBytes = 0)) + assertEquals(50, buf.readableBytes, "buffer must not be drained when credit is zero") + } + + @Test + fun multiple_takes_accumulate_offset() { + val buf = SendBuffer() + buf.enqueue(ByteArray(100)) + val a = buf.takeChunk(maxBytes = 30)!! + val b = buf.takeChunk(maxBytes = 40)!! + val c = buf.takeChunk(maxBytes = 100)!! + assertEquals(0L, a.offset) + assertEquals(30L, b.offset) + assertEquals(70L, c.offset) + assertEquals(30, a.data.size) + assertEquals(40, b.data.size) + assertEquals(30, c.data.size, "third take exhausts the buffer") + assertEquals(100L, buf.sentOffset) + } + + @Test + fun chunked_enqueue_preserves_order_across_takes() { + // Multi-enqueue, multi-take exercises the chunked-queue path that + // replaced the O(N²) copyOf-on-every-enqueue. + val buf = SendBuffer() + buf.enqueue(byteArrayOf(0x01, 0x02, 0x03)) + buf.enqueue(byteArrayOf(0x04, 0x05)) + buf.enqueue(byteArrayOf(0x06, 0x07, 0x08, 0x09)) + // Take in odd sizes to cross chunk boundaries. + val a = buf.takeChunk(maxBytes = 4)!! + val b = buf.takeChunk(maxBytes = 4)!! + val c = buf.takeChunk(maxBytes = 100)!! + // Concatenate and check the original byte sequence is preserved. + val all = a.data + b.data + c.data + assertEquals(9, all.size) + for (i in 0..8) assertEquals((i + 1).toByte(), all[i], "byte $i mismatch") + } + + @Test + fun fin_only_chunk_is_emitted_after_buffer_drained() { + val buf = SendBuffer() + buf.enqueue(byteArrayOf(0x01, 0x02)) + buf.finish() + val a = buf.takeChunk(maxBytes = 10)!! + assertEquals(true, a.fin, "FIN piggybacks on the final data chunk") + assertEquals(2, a.data.size) + assertNull(buf.takeChunk(maxBytes = 10), "no more chunks after FIN+empty") + } + + @Test + fun fin_only_chunk_with_separate_takes() { + val buf = SendBuffer() + buf.enqueue(byteArrayOf(0x01, 0x02)) + // Take everything, no FIN yet. + val a = buf.takeChunk(maxBytes = 100)!! + assertEquals(false, a.fin) + // Then mark FIN and take. + buf.finish() + val b = buf.takeChunk(maxBytes = 100)!! + assertEquals(true, b.fin) + assertEquals(0, b.data.size, "final chunk is zero-length when FIN comes after the last data") + } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt index 620cb5916..9121a2f56 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/InProcessTlsServer.kt @@ -48,6 +48,15 @@ class InProcessTlsServer( private val random: ByteArray = RandomInstance.bytes(32), private val transportParameters: ByteArray = ByteArray(0), private val alpn: ByteArray = TlsConstants.ALPN_H3, + /** + * Server-side cipher suite preference order. The first suite from the + * client's offered list that matches one in [preferredCiphers] wins. + */ + private val preferredCiphers: List = + listOf( + TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256, + TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256, + ), ) { private val transcript = TlsTranscriptHash() private val keySchedule = TlsKeySchedule(transcript) @@ -84,8 +93,9 @@ class InProcessTlsServer( r.readBytes(32) // random r.readTlsOpaque1() // legacy_session_id val cipherSuiteCount = r.readUint16() / 2 + val offered = (0 until cipherSuiteCount).map { r.readUint16() } val pickedSuite = - (0 until cipherSuiteCount).map { r.readUint16() }.firstOrNull { + preferredCiphers.firstOrNull { it in offered } ?: offered.firstOrNull { it == TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 || it == TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256 } ?: error("no acceptable cipher suite in ClientHello") 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 c98e13090..5d9d52362 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/TlsRoundTripTest.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/TlsRoundTripTest.kt @@ -91,11 +91,49 @@ class TlsRoundTripTest { assertContentEquals(tps, client.peerTransportParameters) } + /** + * The same handshake but the in-process server is configured to prefer + * ChaCha20-Poly1305 over AES-GCM. This catches the class of bug where + * the client hardcodes a cipher suite and silently miscomputes 1-RTT + * keys when the server picks the other one. + */ + @Test + fun handshake_completes_with_chacha20_cipher() { + val capturedSecrets = CapturedSecrets() + val server = + InProcessTlsServer( + preferredCiphers = listOf(TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256), + ) + val client = + TlsClient( + serverName = "example.test", + transportParameters = ByteArray(0), + secretsListener = capturedSecrets, + certificateValidator = null, + ) + client.start() + + val ch = client.pollOutbound(TlsClient.Level.INITIAL)!! + server.receiveClientHello(ch) + client.pushHandshakeBytes(TlsClient.Level.INITIAL, server.pollOutboundInitial()!!) + client.pushHandshakeBytes(TlsClient.Level.HANDSHAKE, server.pollOutboundHandshake()!!) + client.pushHandshakeBytes(TlsClient.Level.HANDSHAKE, server.pollOutboundHandshake()!!) + server.receiveClientFinished(client.pollOutbound(TlsClient.Level.HANDSHAKE)!!) + + assertEquals(TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256, server.negotiatedCipherSuite) + // The client's onApplicationKeysReady reports the negotiated suite. + assertEquals(TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256, capturedSecrets.applicationCipherSuite) + assertContentEquals(server.clientApplicationSecret, capturedSecrets.applicationClient) + assertContentEquals(server.serverApplicationSecret, capturedSecrets.applicationServer) + assertTrue(capturedSecrets.handshakeComplete) + } + private class CapturedSecrets : TlsSecretsListener { var handshakeClient: ByteArray? = null var handshakeServer: ByteArray? = null var applicationClient: ByteArray? = null var applicationServer: ByteArray? = null + var applicationCipherSuite: Int = -1 var handshakeComplete = false override fun onHandshakeKeysReady( @@ -114,6 +152,7 @@ class TlsRoundTripTest { ) { applicationClient = clientSecret applicationServer = serverSecret + applicationCipherSuite = cipherSuite } override fun onHandshakeComplete() { diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/TlsSecurityPropertiesTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/TlsSecurityPropertiesTest.kt new file mode 100644 index 000000000..ba6917150 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/TlsSecurityPropertiesTest.kt @@ -0,0 +1,151 @@ +/* + * 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 com.vitorpamplona.quic.QuicReader +import com.vitorpamplona.quic.QuicWriter +import kotlin.test.Test +import kotlin.test.assertFailsWith + +/** + * TLS 1.3 client must REJECT specific server misbehaviors. These are the + * negative-path security properties that have to be assertions, not + * comments — without an explicit `assertFailsWith` the compiler doesn't + * catch a regression when someone removes the validation. + * + * Patterns informed by the kwik server-side hostile-peer matrix + * (`whenParsingClientHelloLeadsToTlsErrorConnectionIsClosed` etc.) and + * RFC 8446 §4.1.3 / §4.4 mandates. + */ +class TlsSecurityPropertiesTest { + @Test + fun server_hello_with_non_empty_session_id_echo_is_rejected() { + // We send legacy_session_id = empty. RFC 8446 §4.1.3: server MUST + // echo it. Non-empty echo means the server is in a TLS-1.2-resumption + // mindset (downgrade signal) — abort. + val sh = buildServerHello(sessionIdEcho = byteArrayOf(0xAA.toByte(), 0xBB.toByte())) + assertFailsWith("non-empty session_id_echo must be rejected") { + TlsServerHello.decodeBody(QuicReader(sh)) + } + } + + @Test + fun server_hello_with_pre_tls13_legacy_version_is_rejected() { + // We must reject anything that's not 0x0303 in legacy_version. + val sh = buildServerHello(legacyVersion = 0x0301) + assertFailsWith("non-TLS-1.2 legacy_version must be rejected") { + TlsServerHello.decodeBody(QuicReader(sh)) + } + } + + @Test + fun server_hello_with_unsupported_group_in_key_share_is_rejected() { + // We advertise X25519 only. If the server picks secp256r1 we have no + // ECDH primitive for it and must abort. + val sh = + buildServerHello( + extensions = + listOf( + TlsExtension( + TlsConstants.EXT_SUPPORTED_VERSIONS, + byteArrayOf(0x03, 0x04), // selected_version = TLS 1.3 + ), + TlsExtension( + TlsConstants.EXT_KEY_SHARE, + buildKeyShare(group = TlsConstants.GROUP_SECP256R1, pubLen = 65), + ), + ), + ) + val parsed = TlsServerHello.decodeBody(QuicReader(sh)) + assertFailsWith("unsupported group must be rejected") { + parsed.serverKeyShareX25519 + } + } + + @Test + fun server_hello_missing_supported_versions_extension_is_rejected() { + val sh = + buildServerHello( + extensions = + listOf( + TlsExtension( + TlsConstants.EXT_KEY_SHARE, + buildKeyShare(group = TlsConstants.GROUP_X25519, pubLen = 32), + ), + ), + ) + val parsed = TlsServerHello.decodeBody(QuicReader(sh)) + assertFailsWith("missing supported_versions must be rejected") { + parsed.negotiatedVersion + } + } + + @Test + fun server_hello_missing_key_share_is_rejected() { + val sh = + buildServerHello( + extensions = + listOf( + TlsExtension( + TlsConstants.EXT_SUPPORTED_VERSIONS, + byteArrayOf(0x03, 0x04), + ), + ), + ) + val parsed = TlsServerHello.decodeBody(QuicReader(sh)) + assertFailsWith("missing key_share must be rejected") { + parsed.serverKeyShareX25519 + } + } + + private fun buildServerHello( + legacyVersion: Int = TlsConstants.LEGACY_VERSION_TLS_1_2, + sessionIdEcho: ByteArray = ByteArray(0), + cipherSuite: Int = TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256, + extensions: List = + listOf( + TlsExtension(TlsConstants.EXT_SUPPORTED_VERSIONS, byteArrayOf(0x03, 0x04)), + TlsExtension(TlsConstants.EXT_KEY_SHARE, buildKeyShare(TlsConstants.GROUP_X25519, 32)), + ), + ): ByteArray { + val w = QuicWriter() + w.writeUint16(legacyVersion) + w.writeBytes(ByteArray(32)) + w.writeTlsOpaque1(sessionIdEcho) + w.writeUint16(cipherSuite) + w.writeByte(0) // null compression + w.withUint16Length { + for (e in extensions) e.encode(this) + } + return w.toByteArray() + } + + private fun buildKeyShare( + group: Int, + pubLen: Int, + ): ByteArray { + val w = QuicWriter() + w.writeUint16(group) + w.writeTlsOpaque2(ByteArray(pubLen)) + return w.toByteArray() + } +}