fix(quic): audit-3 follow-ups + regression coverage
Cipher caching: cache JCA Cipher + SecretKeySpec per direction in a new
JcaAesGcmAead so steady-state seal/open avoids Cipher.getInstance("AES/GCM/
NoPadding") per packet (audit-1, audit-3 hot path). Initial-padding rebuild
edge case (re-encrypting the same PN with the same nonce) falls back to a
fresh cipher because JCA tracks (key, iv) pairs and rejects legitimate reuse.
Channel-based wakeups: replace the WT peer-stream poller's delay(5)
busy-loop with awaitIncomingPeerStream/awaitIncomingDatagram suspending
on conflated wakeup channels fired by the parser. Connection close also
closes a closedSignal so any awaiter unblocks promptly with null.
Driver close ordering: close() now joins the read + send loops with a
bounded timeout instead of yield()+cancel()-racing them. Catches the case
where scope.cancel() fired mid-socket.send, occasionally producing partial
datagrams or skipping CONNECTION_CLOSE entirely.
WT graceful close: spawn a CapsuleReader-driven coroutine on the CONNECT
bidi that decodes WT_CLOSE_SESSION and surfaces it via peerCloseSession +
awaitPeerClose. Previously the encoder existed but no decoder consumed
incoming capsules, so peer-initiated graceful close was silent.
GOAWAY: WtPeerStreamDemux decodes the GOAWAY varint body into
peerGoawayStreamId instead of `is Goaway -> Unit`-dropping it.
TLS transcript hash: incremental SHA-256 backed by JCA MessageDigest,
snapshotted via clone() — replaces the O(n²) "concatenate-everything-and-
re-hash on every snapshot" implementation. TLS 1.3 takes ≥3 snapshots per
handshake.
MAX_STREAMS routing: parser now bumps peerMaxStreamsBidi/Uni on inbound
MAX_STREAMS frames; openBidiStream/openUniStream throw QuicStreamLimitException
when the cap is reached instead of silently overrunning it. Initial cap
sourced from peer transport parameters.
Regression tests:
* CapsuleReaderTest – round-trip, split-chunk, partial, unknown types
* TlsTranscriptHashTest – snapshot determinism, no consume-on-snapshot
* PeerStreamLimitTest – TP-driven cap, MAX_STREAMS frame round-trip
* WtPeerStreamDemuxTest – CONTROL stream GOAWAY decode
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
@@ -58,8 +58,13 @@ import com.vitorpamplona.quic.tls.TlsConstants
|
||||
class InMemoryQuicPipe(
|
||||
val client: QuicConnection,
|
||||
val initialDcid: ByteArray,
|
||||
/**
|
||||
* Optional pre-configured TLS server. Tests that need to advertise non-empty
|
||||
* QUIC transport parameters (e.g. to exercise MAX_STREAMS routing) build
|
||||
* their own [InProcessTlsServer] and pass it here.
|
||||
*/
|
||||
private val tlsServer: InProcessTlsServer = InProcessTlsServer(),
|
||||
) {
|
||||
private val tlsServer = InProcessTlsServer()
|
||||
private val initial = InitialSecrets.derive(initialDcid)
|
||||
private val hp = AesEcbHeaderProtection(PlatformAesOneBlock)
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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.connection
|
||||
|
||||
import com.vitorpamplona.quic.frame.MaxStreamsFrame
|
||||
import com.vitorpamplona.quic.tls.InProcessTlsServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Verifies the audit-3 fix: peer-granted stream concurrency limits are now
|
||||
* tracked and enforced.
|
||||
*
|
||||
* Two paths feed the cap:
|
||||
* 1. Peer transport parameters at handshake (`initial_max_streams_bidi/uni`)
|
||||
* 2. Subsequent MAX_STREAMS frames (RFC 9000 §19.11)
|
||||
*
|
||||
* Without this enforcement, [QuicConnection.openBidiStream] silently allocated
|
||||
* stream IDs past the cap, and the peer eventually closed the connection with
|
||||
* STREAM_LIMIT_ERROR — a failure that surfaced as "connection randomly drops
|
||||
* after a burst of opens" rather than a clean error.
|
||||
*/
|
||||
class PeerStreamLimitTest {
|
||||
@Test
|
||||
fun open_bidi_throws_when_peer_advertises_zero_bidi_streams() {
|
||||
runBlocking {
|
||||
val client =
|
||||
QuicConnection(
|
||||
serverName = "example.test",
|
||||
config = QuicConnectionConfig(),
|
||||
tlsCertificateValidator = null,
|
||||
)
|
||||
// Default InProcessTlsServer sends empty transport parameters — so
|
||||
// the client's peerMaxStreamsBidi resolves to 0 after handshake.
|
||||
val pipe = InMemoryQuicPipe(client, client.destinationConnectionId.bytes)
|
||||
client.start()
|
||||
pipe.drive(maxRounds = 16)
|
||||
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||
|
||||
assertFailsWith<QuicStreamLimitException> {
|
||||
client.openBidiStream()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun open_bidi_succeeds_within_peer_advertised_cap_and_throws_at_boundary() {
|
||||
runBlocking {
|
||||
val client =
|
||||
QuicConnection(
|
||||
serverName = "example.test",
|
||||
config = QuicConnectionConfig(),
|
||||
tlsCertificateValidator = null,
|
||||
)
|
||||
val serverTpBytes =
|
||||
TransportParameters(
|
||||
initialMaxData = 1_000_000,
|
||||
initialMaxStreamDataBidiLocal = 100_000,
|
||||
initialMaxStreamDataBidiRemote = 100_000,
|
||||
initialMaxStreamDataUni = 100_000,
|
||||
initialMaxStreamsBidi = 3,
|
||||
initialMaxStreamsUni = 0,
|
||||
).encode()
|
||||
val tlsServer = InProcessTlsServer(transportParameters = serverTpBytes)
|
||||
val pipe =
|
||||
InMemoryQuicPipe(
|
||||
client = client,
|
||||
initialDcid = client.destinationConnectionId.bytes,
|
||||
tlsServer = tlsServer,
|
||||
)
|
||||
client.start()
|
||||
pipe.drive(maxRounds = 16)
|
||||
assertEquals(QuicConnection.Status.CONNECTED, client.status)
|
||||
assertEquals(3L, client.peerMaxStreamsBidiSnapshot())
|
||||
|
||||
// Three opens should succeed.
|
||||
client.openBidiStream()
|
||||
client.openBidiStream()
|
||||
client.openBidiStream()
|
||||
|
||||
// Fourth must throw — we'd otherwise violate the peer's cap.
|
||||
assertFailsWith<QuicStreamLimitException> {
|
||||
client.openBidiStream()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun max_streams_frame_roundtrips_via_decode_frames() {
|
||||
// Bytes-level sanity: encode a MaxStreamsFrame and decode it back.
|
||||
// Catches a regression where the parser ignored MAX_STREAMS entirely
|
||||
// (the pre-fix `is MaxStreamsFrame -> { /* tracking left for later */ }`
|
||||
// branch was dead code as far as tests could observe).
|
||||
val encoded =
|
||||
com.vitorpamplona.quic.frame
|
||||
.encodeFrames(listOf(MaxStreamsFrame(bidi = true, maxStreams = 100)))
|
||||
val decoded =
|
||||
com.vitorpamplona.quic.frame
|
||||
.decodeFrames(encoded)
|
||||
assertEquals(1, decoded.size)
|
||||
val frame = decoded.first() as MaxStreamsFrame
|
||||
assertTrue(frame.bidi)
|
||||
assertEquals(100L, frame.maxStreams)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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.packet
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Regression coverage for hostile-input handling at the packet layer.
|
||||
* Each test pins a class of bug an audit caught — if any of these starts
|
||||
* passing through, that's a re-regression.
|
||||
*/
|
||||
class HostilePacketInputTest {
|
||||
/**
|
||||
* RFC 9000 §17.2 caps CID length at 20 bytes. A hostile peer with
|
||||
* dcidLen=0xFF would, before the fix, cause `r.readBytes(255)` to
|
||||
* either crash or read 255 bytes of arbitrary buffer state.
|
||||
*/
|
||||
@Test
|
||||
fun retry_packet_with_oversized_dcid_len_is_rejected() {
|
||||
// Build a "Retry" with dcidLen=255, scidLen=8 (legal), then 16 bytes of random tag.
|
||||
val w = com.vitorpamplona.quic.QuicWriter()
|
||||
w.writeByte(0xff)
|
||||
w.writeUint32(0x00000001) // version
|
||||
w.writeByte(0xff) // dcidLen = 255 — illegal
|
||||
w.writeBytes(ByteArray(8)) // not enough bytes for a 255-byte CID
|
||||
// Padding so we don't hit other underflow checks before the CID one.
|
||||
w.writeBytes(ByteArray(40))
|
||||
val parsed = RetryPacket.parse(w.toByteArray())
|
||||
assertNull(parsed, "Retry with dcidLen=255 must be rejected")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retry_packet_with_oversized_scid_len_is_rejected() {
|
||||
val w = com.vitorpamplona.quic.QuicWriter()
|
||||
w.writeByte(0xff)
|
||||
w.writeUint32(0x00000001)
|
||||
w.writeByte(0)
|
||||
w.writeByte(0xff) // scidLen = 255 — illegal
|
||||
w.writeBytes(ByteArray(40))
|
||||
val parsed = RetryPacket.parse(w.toByteArray())
|
||||
assertNull(parsed)
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9001 §5.5: a peer that sends one bad packet inside a coalesced
|
||||
* datagram must NOT cause subsequent packets to be dropped. Before the
|
||||
* fix, [feedDatagram]'s `?: break` discarded everything after the first
|
||||
* unparseable header.
|
||||
*
|
||||
* We simulate by feeding a datagram whose Initial packet has bogus
|
||||
* DCID-len followed by an unrelated trailing packet. peekHeader returns
|
||||
* null on the bogus first byte → outer loop breaks.
|
||||
*
|
||||
* Specifically, this test asserts the post-fix behavior: when peekHeader
|
||||
* succeeds but decrypt fails, the loop advances by [PeekedHeader.totalLength]
|
||||
* rather than breaking. We can't easily fake "decrypt-fails-but-peek-succeeds"
|
||||
* in a unit test without crypto, so this tests the structural invariant
|
||||
* via [LongHeaderPacket.peekHeader] directly.
|
||||
*/
|
||||
@Test
|
||||
fun peek_header_on_oversized_dcid_returns_null_so_caller_breaks() {
|
||||
val w = com.vitorpamplona.quic.QuicWriter()
|
||||
// Long-header type INITIAL, version 1, dcidLen = 21 (illegal: max 20)
|
||||
w.writeByte(0xC0)
|
||||
w.writeUint32(0x00000001)
|
||||
w.writeByte(21)
|
||||
w.writeBytes(ByteArray(50)) // pretend more bytes
|
||||
val peeked = LongHeaderPacket.peekHeader(w.toByteArray(), 0)
|
||||
assertNull(peeked, "peekHeader must reject dcidLen out of [0..20]")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun peek_header_on_oversized_scid_returns_null() {
|
||||
val w = com.vitorpamplona.quic.QuicWriter()
|
||||
w.writeByte(0xC0)
|
||||
w.writeUint32(0x00000001)
|
||||
w.writeByte(0) // dcidLen = 0
|
||||
w.writeByte(21) // scidLen = 21 — illegal
|
||||
w.writeBytes(ByteArray(50))
|
||||
val peeked = LongHeaderPacket.peekHeader(w.toByteArray(), 0)
|
||||
assertNull(peeked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun peek_header_on_initial_with_oversized_token_len_returns_null() {
|
||||
val w = com.vitorpamplona.quic.QuicWriter()
|
||||
w.writeByte(0xC0)
|
||||
w.writeUint32(0x00000001)
|
||||
w.writeByte(0) // dcidLen
|
||||
w.writeByte(0) // scidLen
|
||||
// Token length varint: claim 0x4000_0000 (1 GiB)
|
||||
w.writeVarint(0x4000_0000L)
|
||||
// No bytes follow — varint length exceeds remaining
|
||||
val peeked = LongHeaderPacket.peekHeader(w.toByteArray(), 0)
|
||||
assertNull(peeked, "peekHeader must reject token length > remaining")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retry_packet_peek_returns_total_length() {
|
||||
// A valid Retry: 0xff || 00000001 || 00 || 08 || cid(8) || token(0) || tag(16)
|
||||
val w = com.vitorpamplona.quic.QuicWriter()
|
||||
w.writeByte(0xff)
|
||||
w.writeUint32(0x00000001)
|
||||
w.writeByte(0)
|
||||
w.writeByte(8)
|
||||
w.writeBytes(ByteArray(8))
|
||||
// Retry token + tag = 16 bytes total (token is empty here for simplicity)
|
||||
w.writeBytes(ByteArray(16))
|
||||
val peeked = LongHeaderPacket.peekHeader(w.toByteArray(), 0)
|
||||
assertTrue(peeked != null, "peekHeader must accept a structurally-valid Retry")
|
||||
// Total length is bytes.size - offset (no `length` field in Retry)
|
||||
kotlin.test.assertEquals(w.toByteArray().size, peeked.totalLength)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* 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.QuicWriter
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
/**
|
||||
* RFC 8446 §4.1.4 — HelloRetryRequest is a ServerHello whose `random` equals
|
||||
* the SHA-256 of the ASCII string "HelloRetryRequest". We don't implement
|
||||
* HRR (we offer X25519 only, the group every modern QUIC server accepts).
|
||||
* Before the round-3 fix, an HRR was treated as a regular ServerHello, then
|
||||
* X25519 was performed against the cookie/extension bytes (garbage), then
|
||||
* AEAD failed downstream with a confusing error. The current code rejects
|
||||
* cleanly with a [QuicCodecException].
|
||||
*/
|
||||
class HelloRetryRequestTest {
|
||||
@Test
|
||||
fun hello_retry_request_is_rejected_cleanly() {
|
||||
val tls =
|
||||
TlsClient(
|
||||
serverName = "example.test",
|
||||
transportParameters = ByteArray(0),
|
||||
secretsListener = NoopSecretsListener,
|
||||
certificateValidator = null,
|
||||
)
|
||||
tls.start()
|
||||
// Drain (and discard) ClientHello.
|
||||
tls.pollOutbound(TlsClient.Level.INITIAL)
|
||||
|
||||
val hrr = buildHelloRetryRequest()
|
||||
assertFailsWith<QuicCodecException> {
|
||||
tls.pushHandshakeBytes(TlsClient.Level.INITIAL, hrr)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal HRR: handshake type 2 (ServerHello), with the magic
|
||||
* SHA-256("HelloRetryRequest") random.
|
||||
*/
|
||||
private fun buildHelloRetryRequest(): ByteArray {
|
||||
val helloRetryRequestRandom =
|
||||
byteArrayOf(
|
||||
0xCF.toByte(),
|
||||
0x21.toByte(),
|
||||
0xAD.toByte(),
|
||||
0x74.toByte(),
|
||||
0xE5.toByte(),
|
||||
0x9A.toByte(),
|
||||
0x61.toByte(),
|
||||
0x11.toByte(),
|
||||
0xBE.toByte(),
|
||||
0x1D.toByte(),
|
||||
0x8C.toByte(),
|
||||
0x02.toByte(),
|
||||
0x1E.toByte(),
|
||||
0x65.toByte(),
|
||||
0xB8.toByte(),
|
||||
0x91.toByte(),
|
||||
0xC2.toByte(),
|
||||
0xA2.toByte(),
|
||||
0x11.toByte(),
|
||||
0x16.toByte(),
|
||||
0x7A.toByte(),
|
||||
0xBB.toByte(),
|
||||
0x8C.toByte(),
|
||||
0x5E.toByte(),
|
||||
0x07.toByte(),
|
||||
0x9E.toByte(),
|
||||
0x09.toByte(),
|
||||
0xE2.toByte(),
|
||||
0xC8.toByte(),
|
||||
0xA8.toByte(),
|
||||
0x33.toByte(),
|
||||
0x9C.toByte(),
|
||||
)
|
||||
val w = QuicWriter()
|
||||
w.writeByte(TlsConstants.HS_SERVER_HELLO)
|
||||
w.withUint24Length {
|
||||
writeUint16(TlsConstants.LEGACY_VERSION_TLS_1_2)
|
||||
writeBytes(helloRetryRequestRandom)
|
||||
writeByte(0) // legacy_session_id_echo: empty
|
||||
writeUint16(TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256)
|
||||
writeByte(0) // null compression
|
||||
withUint16Length {
|
||||
// supported_versions = TLS 1.3
|
||||
writeUint16(TlsConstants.EXT_SUPPORTED_VERSIONS)
|
||||
withUint16Length { writeUint16(TlsConstants.VERSION_TLS_1_3) }
|
||||
// key_share with empty group (we don't care; HRR check fires first)
|
||||
writeUint16(TlsConstants.EXT_KEY_SHARE)
|
||||
withUint16Length { writeUint16(TlsConstants.GROUP_X25519) }
|
||||
}
|
||||
}
|
||||
return w.toByteArray()
|
||||
}
|
||||
|
||||
private object NoopSecretsListener : TlsSecretsListener {
|
||||
override fun onHandshakeKeysReady(
|
||||
cipherSuite: Int,
|
||||
clientSecret: ByteArray,
|
||||
serverSecret: ByteArray,
|
||||
) = Unit
|
||||
|
||||
override fun onApplicationKeysReady(
|
||||
cipherSuite: Int,
|
||||
clientSecret: ByteArray,
|
||||
serverSecret: ByteArray,
|
||||
) = Unit
|
||||
|
||||
override fun onHandshakeComplete() = Unit
|
||||
}
|
||||
}
|
||||
@@ -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.tls
|
||||
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
/**
|
||||
* Regression coverage for [TlsTranscriptHash] after the switch from the
|
||||
* O(n²) "concatenate-and-rehash on every snapshot" implementation to an
|
||||
* incremental SHA-256 driven by [TlsRunningSha256].
|
||||
*
|
||||
* The contract that production TLS code depends on:
|
||||
*
|
||||
* 1. snapshot() bytes equal the SHA-256 of all appended bytes in order.
|
||||
* 2. Multiple snapshots taken at the same point yield identical bytes.
|
||||
* 3. snapshot() must NOT consume the running hash — further append() calls
|
||||
* keep extending the same digest. This is the non-obvious bit: a naive
|
||||
* `digest.digest()` finalizes and resets, so without the clone trick a
|
||||
* handshake-mid snapshot would silently corrupt later snapshots.
|
||||
* 4. Output is always 32 bytes.
|
||||
*/
|
||||
class TlsTranscriptHashTest {
|
||||
@Test
|
||||
fun snapshot_matches_sha256_of_concatenated_bytes() {
|
||||
val transcript = TlsTranscriptHash()
|
||||
val msg1 = byteArrayOf(1, 2, 3, 4)
|
||||
val msg2 = byteArrayOf(5, 6, 7, 8, 9)
|
||||
transcript.append(msg1)
|
||||
transcript.append(msg2)
|
||||
|
||||
val expected = sha256(msg1 + msg2)
|
||||
assertContentEquals(expected, transcript.snapshot())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun empty_transcript_hashes_empty_input() {
|
||||
val transcript = TlsTranscriptHash()
|
||||
assertContentEquals(sha256(ByteArray(0)), transcript.snapshot())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun output_is_always_32_bytes() {
|
||||
val transcript = TlsTranscriptHash()
|
||||
assertEquals(32, transcript.snapshot().size)
|
||||
transcript.append(byteArrayOf(0x42))
|
||||
assertEquals(32, transcript.snapshot().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snapshot_does_not_consume_running_state() {
|
||||
// The bug we're guarding against: `MessageDigest.digest()` finalizes
|
||||
// and resets. If the implementation accidentally uses that instead of
|
||||
// cloning, the second snapshot would hash only the post-snapshot
|
||||
// bytes, not the full transcript. TLS 1.3 takes ≥3 snapshots per
|
||||
// handshake, so this would corrupt every later key.
|
||||
val transcript = TlsTranscriptHash()
|
||||
val ch = byteArrayOf(0x01, 0x00, 0x00, 0x04, 0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte())
|
||||
val sh = byteArrayOf(0x02, 0x00, 0x00, 0x04, 0xCA.toByte(), 0xFE.toByte(), 0xBA.toByte(), 0xBE.toByte())
|
||||
val ee = byteArrayOf(0x08, 0x00, 0x00, 0x02, 0x00, 0x00)
|
||||
|
||||
transcript.append(ch)
|
||||
transcript.append(sh)
|
||||
val handshakeSnapshot = transcript.snapshot()
|
||||
|
||||
transcript.append(ee)
|
||||
val applicationSnapshot = transcript.snapshot()
|
||||
|
||||
// Both must be SHA-256 of their respective prefixes.
|
||||
assertContentEquals(sha256(ch + sh), handshakeSnapshot)
|
||||
assertContentEquals(sha256(ch + sh + ee), applicationSnapshot)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun two_snapshots_at_same_position_match() {
|
||||
val transcript = TlsTranscriptHash()
|
||||
transcript.append(byteArrayOf(0x10, 0x20, 0x30))
|
||||
val a = transcript.snapshot()
|
||||
val b = transcript.snapshot()
|
||||
assertContentEquals(a, b)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snapshots_at_different_positions_differ() {
|
||||
// Sanity: bumping the transcript should produce a new hash. Catches
|
||||
// an implementation that accidentally caches and returns a stale
|
||||
// snapshot.
|
||||
val transcript = TlsTranscriptHash()
|
||||
transcript.append(byteArrayOf(0x00))
|
||||
val before = transcript.snapshot()
|
||||
transcript.append(byteArrayOf(0x01))
|
||||
val after = transcript.snapshot()
|
||||
assertFalse(before.contentEquals(after))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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.webtransport
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Regression coverage for [CapsuleReader] — the decoder powering peer-initiated
|
||||
* WT_CLOSE_SESSION detection on the WT CONNECT bidi.
|
||||
*
|
||||
* Audit-3 finding: the encoder existed but no decoder consumed the CONNECT
|
||||
* stream, so a peer-initiated graceful close was silently ignored. These tests
|
||||
* cover the byte-level decode plus split-chunk/empty-body edge cases that the
|
||||
* production reader (driven by `QuicStream.incoming.collect`) will hit.
|
||||
*/
|
||||
class CapsuleReaderTest {
|
||||
@Test
|
||||
fun decodes_complete_close_session_capsule_in_one_push() {
|
||||
val reader = CapsuleReader()
|
||||
val capsule = encodeCloseSessionCapsule(errorCode = 7, reason = "bye")
|
||||
reader.push(capsule)
|
||||
|
||||
val first = reader.next()
|
||||
assertIs<WtCloseSession>(first)
|
||||
assertEquals(7, first.errorCode)
|
||||
assertEquals("bye", first.reason)
|
||||
|
||||
// No second capsule queued.
|
||||
assertNull(reader.next())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handles_split_chunks_across_push_calls() {
|
||||
// Chunk the encoded capsule arbitrarily — the QUIC stream callback
|
||||
// does not respect framing boundaries, so the reader must reassemble.
|
||||
val capsule = encodeCloseSessionCapsule(errorCode = 42, reason = "split-recv")
|
||||
val reader = CapsuleReader()
|
||||
// Push one byte at a time — pathological but tests the buffer logic.
|
||||
for (b in capsule) {
|
||||
reader.push(byteArrayOf(b))
|
||||
}
|
||||
|
||||
val parsed = reader.next()
|
||||
assertIs<WtCloseSession>(parsed)
|
||||
assertEquals(42, parsed.errorCode)
|
||||
assertEquals("split-recv", parsed.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returns_null_when_buffer_holds_only_partial_capsule() {
|
||||
val reader = CapsuleReader()
|
||||
val full = encodeCloseSessionCapsule(0, "x")
|
||||
// Push everything except the last byte. Reader must wait for more data.
|
||||
reader.push(full.copyOfRange(0, full.size - 1))
|
||||
assertNull(reader.next())
|
||||
|
||||
// After the final byte arrives, the capsule decodes.
|
||||
reader.push(byteArrayOf(full.last()))
|
||||
val parsed = reader.next()
|
||||
assertIs<WtCloseSession>(parsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodes_empty_body_close_session_as_zero_error_empty_reason() {
|
||||
// Spec lower bound: body length 0, no error code present.
|
||||
val empty = encodeCapsule(WtCapsuleType.WT_CLOSE_SESSION, ByteArray(0))
|
||||
val reader = CapsuleReader()
|
||||
reader.push(empty)
|
||||
val parsed = reader.next()
|
||||
assertIs<WtCloseSession>(parsed)
|
||||
assertEquals(0, parsed.errorCode)
|
||||
assertEquals("", parsed.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknown_capsule_type_surfaces_as_raw_pair_without_breaking_stream() {
|
||||
// Server may send capsule types we don't recognise (e.g., DRAIN, future
|
||||
// extensions). They should not break the reader for subsequent
|
||||
// recognised capsules.
|
||||
val reader = CapsuleReader()
|
||||
val drainBody = byteArrayOf(0x01, 0x02)
|
||||
reader.push(encodeCapsule(WtCapsuleType.WT_DRAIN_SESSION, drainBody))
|
||||
reader.push(encodeCloseSessionCapsule(9, "after"))
|
||||
|
||||
val first = reader.next()
|
||||
|
||||
// Unknown types come back as a Pair<typeVarint, body>.
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val pair = first as Pair<Long, ByteArray>
|
||||
assertEquals(WtCapsuleType.WT_DRAIN_SESSION, pair.first)
|
||||
assertEquals(2, pair.second.size)
|
||||
|
||||
val second = reader.next()
|
||||
assertIs<WtCloseSession>(second)
|
||||
assertEquals(9, second.errorCode)
|
||||
assertEquals("after", second.reason)
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* 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.webtransport
|
||||
|
||||
import com.vitorpamplona.quic.QuicWriter
|
||||
import com.vitorpamplona.quic.http3.Http3FrameType
|
||||
import com.vitorpamplona.quic.http3.Http3Settings
|
||||
import com.vitorpamplona.quic.http3.Http3StreamType
|
||||
import com.vitorpamplona.quic.stream.QuicStream
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Verifies the audit-3 fix: GOAWAY frames on the H3 CONTROL stream are now
|
||||
* decoded into [WtPeerStreamDemux.peerGoawayStreamId] instead of being
|
||||
* silently dropped.
|
||||
*
|
||||
* Drives the demux directly with a fake server-initiated unidirectional
|
||||
* QuicStream that carries:
|
||||
* varint(CONTROL) – stream-type prefix
|
||||
* SETTINGS frame – any well-formed body
|
||||
* GOAWAY frame – body = single varint stream id
|
||||
*
|
||||
* The pre-fix code matched `Http3Frame.Goaway -> Unit`, dropping the body.
|
||||
* Without this test, the fix could silently regress to the old no-op.
|
||||
*/
|
||||
class WtPeerStreamDemuxTest {
|
||||
@Test
|
||||
fun control_stream_goaway_sets_peer_goaway_stream_id() {
|
||||
runBlocking {
|
||||
// Server-initiated unidirectional stream id (kind == 3 mod 4).
|
||||
val controlStreamId = 3L
|
||||
val stream = QuicStream(controlStreamId, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
|
||||
|
||||
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
|
||||
demux.process(stream)
|
||||
|
||||
// Push the bytes the server would have sent on its CONTROL stream:
|
||||
// 1. Stream-type prefix = 0x00 (CONTROL).
|
||||
// 2. SETTINGS frame with a single QPACK_MAX_TABLE_CAPACITY=0.
|
||||
// 3. GOAWAY frame with stream id 12.
|
||||
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
|
||||
val settingsFrame = Http3Settings(emptyMap()).encodeFrame()
|
||||
val goawayBody = QuicWriter().also { it.writeVarint(12L) }.toByteArray()
|
||||
val goawayFrame =
|
||||
QuicWriter()
|
||||
.apply {
|
||||
writeVarint(Http3FrameType.GOAWAY)
|
||||
writeVarint(goawayBody.size.toLong())
|
||||
writeBytes(goawayBody)
|
||||
}.toByteArray()
|
||||
|
||||
stream.deliverIncoming(typePrefix)
|
||||
stream.deliverIncoming(settingsFrame)
|
||||
stream.deliverIncoming(goawayFrame)
|
||||
// Closing the stream lets the demux's read loop finish — without
|
||||
// this the test hangs on the chunk channel.
|
||||
stream.closeIncoming()
|
||||
|
||||
// Wait up to a generous bound for the demux coroutine to consume
|
||||
// the bytes; the actual work is microseconds but JVM scheduling
|
||||
// jitter can stretch that.
|
||||
val ok =
|
||||
withTimeoutOrNull(2_000L) {
|
||||
while (demux.peerGoawayStreamId == null) delay(5)
|
||||
true
|
||||
}
|
||||
assertEquals(true, ok, "demux should observe GOAWAY within timeout")
|
||||
assertEquals(12L, demux.peerGoawayStreamId)
|
||||
assertNotNull(demux.peerSettings, "SETTINGS should also have been captured")
|
||||
|
||||
demuxScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun control_stream_without_goaway_leaves_peer_goaway_null() {
|
||||
runBlocking {
|
||||
// Same setup minus the GOAWAY frame — peerGoawayStreamId stays null,
|
||||
// peerSettings still populated. Catches an over-eager fix that
|
||||
// accidentally fires on SETTINGS or DATA.
|
||||
val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
|
||||
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
|
||||
demux.process(stream)
|
||||
|
||||
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
|
||||
val settingsFrame = Http3Settings(emptyMap()).encodeFrame()
|
||||
stream.deliverIncoming(typePrefix)
|
||||
stream.deliverIncoming(settingsFrame)
|
||||
stream.closeIncoming()
|
||||
|
||||
val ok =
|
||||
withTimeoutOrNull(2_000L) {
|
||||
while (demux.peerSettings == null) delay(5)
|
||||
true
|
||||
}
|
||||
assertEquals(true, ok)
|
||||
assertNull(demux.peerGoawayStreamId)
|
||||
|
||||
demuxScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user