test(quic): adversarial + parametrized + negative-path tests from review

Builds the test categories the audit identified as missing. Patterns informed
by surveys of Cloudflare quiche (the `Pipe` style + flow-control assertions),
kwik (server-side hostile-peer matrix), and quic-interop-runner (scenario
checklist).

FrameFuzzerTest — 8 tests
  - 2000 random byte sequences fed through decodeFrames; the contract is
    "succeed or throw QuicCodecException, never crash." Catches the C5 class
    (oversized varints) plus general DoS resilience.
  - Crafted hostile vectors: STREAM with length=2^62-1, CRYPTO 1 GiB,
    ACK with 1B range count, NCID with cidLen=255, CONNECTION_CLOSE with
    1 GiB reason, DATAGRAM 1 GiB, valid frame followed by unknown type.

AckTrackerCoalescedTest — 3 tests
  - Two coalesced packets in one datagram both end up in the ACK frame.
    Direct regression test for the C2 bug where the parser fed
    `state.pnSpace.largestReceived` instead of the actual decrypted PN.
  - Gapped PNs produce two ranges with the correct gap encoding (RFC 9000
    §19.3.1 `previous_smallest - current_largest - 2`).
  - Out-of-order arrival of contiguous PNs still merges into one range.

FlowControlEnforcementTest — 6 tests
  - SendBuffer respects maxBytes (the writer's `sendCredit - sentOffset`
    enforcement point).
  - maxBytes=0 with pending data returns null (sender stalls cleanly).
  - Multi-take across chunked-queue boundaries preserves byte order
    (regression coverage for the new O(1) chunked enqueue replacing the
    old O(N²) copyOf path).
  - FIN handling: piggyback on final data chunk vs. zero-length post-data.

TlsSecurityPropertiesTest — 5 tests
  - ServerHello with non-empty session_id_echo rejected (RFC 8446 §4.1.3
    downgrade signal).
  - Pre-TLS-1.3 legacy_version rejected.
  - Server picking unsupported group (secp256r1) rejected — we advertise
    X25519 only.
  - Missing supported_versions / missing key_share extensions rejected.

TlsRoundTripTest — multi-cipher parametrization
  - InProcessTlsServer takes a `preferredCiphers` list.
  - New test forces ChaCha20-Poly1305-SHA256 selection and asserts the
    full handshake completes with that suite, with the
    onApplicationKeysReady callback reporting the actual negotiated
    cipher (not the previously-hardcoded AES). This is direct regression
    coverage for the C1 bug.

Total: 22 new tests + multi-cipher parametrization. All :quic:jvmTest +
:nestsClient:jvmTest pass.

Notable gap acknowledged from surveys: an in-memory `Pipe`-style
client+server harness (quiche pattern). Requires a server-side
QuicConnection implementation, which is ~1 day of work; deferred until we
have a concrete need beyond what the in-process TLS server already covers.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 22:19:15 +00:00
parent 368b8dd432
commit cd68502355
6 changed files with 557 additions and 1 deletions
@@ -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
}
}
}
@@ -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())
}
}
@@ -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")
}
}
@@ -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<Int> =
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")
@@ -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() {
@@ -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<QuicCodecException>("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<QuicCodecException>("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<QuicCodecException>("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<QuicCodecException>("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<QuicCodecException>("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<TlsExtension> =
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()
}
}