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:
@@ -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())
|
||||
}
|
||||
}
|
||||
+116
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user