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:
Claude
2026-04-25 23:48:44 +00:00
parent 2f9a0a0e03
commit 62a42cb36d
20 changed files with 1345 additions and 86 deletions
@@ -21,10 +21,10 @@
package com.vitorpamplona.quic.connection
import com.vitorpamplona.quic.crypto.Aead
import com.vitorpamplona.quic.crypto.Aes128Gcm
import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection
import com.vitorpamplona.quic.crypto.HeaderProtection
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
import com.vitorpamplona.quic.crypto.bestAes128GcmAead
import com.vitorpamplona.quic.tls.TlsConstants
import com.vitorpamplona.quic.tls.deriveQuicKeys
@@ -40,42 +40,39 @@ fun packetProtectionFromSecret(
cipherSuite: Int,
secret: ByteArray,
): PacketProtection {
val (aead, keyLen, ivLen, hpLen, hp) =
when (cipherSuite) {
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 -> {
ProtectionParams(
aead = Aes128Gcm,
keyLen = 16,
ivLen = 12,
hpLen = 16,
hp = AesEcbHeaderProtection(PlatformAesOneBlock),
)
}
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256 -> {
ProtectionParams(
aead = com.vitorpamplona.quic.crypto.ChaCha20Poly1305Aead,
keyLen = 32,
ivLen = 12,
hpLen = 32,
hp =
com.vitorpamplona.quic.crypto
.ChaCha20HeaderProtection(com.vitorpamplona.quic.crypto.PlatformChaCha20Block),
)
}
else -> {
error("unsupported cipher suite 0x${cipherSuite.toString(16)}")
}
val keyLen: Int
val ivLen: Int
val hpLen: Int
val hp: HeaderProtection
when (cipherSuite) {
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 -> {
keyLen = 16
ivLen = 12
hpLen = 16
hp = AesEcbHeaderProtection(PlatformAesOneBlock)
}
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256 -> {
keyLen = 32
ivLen = 12
hpLen = 32
hp =
com.vitorpamplona.quic.crypto
.ChaCha20HeaderProtection(com.vitorpamplona.quic.crypto.PlatformChaCha20Block)
}
else -> {
error("unsupported cipher suite 0x${cipherSuite.toString(16)}")
}
}
val keys = deriveQuicKeys(secret, keyLen, ivLen, hpLen)
// For AES-128-GCM, prefer the platform's cached-cipher implementation
// which avoids `Cipher.getInstance` per-packet (audit-1, audit-3 finding).
val aead: Aead =
when (cipherSuite) {
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 -> bestAes128GcmAead(keys.key)
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256 -> com.vitorpamplona.quic.crypto.ChaCha20Poly1305Aead
else -> error("unreachable")
}
return PacketProtection(aead, keys.key, keys.iv, hp, keys.hp)
}
private data class ProtectionParams(
val aead: Aead,
val keyLen: Int,
val ivLen: Int,
val hpLen: Int,
val hp: HeaderProtection,
)
@@ -20,16 +20,18 @@
*/
package com.vitorpamplona.quic.connection
import com.vitorpamplona.quic.crypto.Aes128Gcm
import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection
import com.vitorpamplona.quic.crypto.InitialSecrets
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
import com.vitorpamplona.quic.crypto.bestAes128GcmAead
import com.vitorpamplona.quic.stream.QuicStream
import com.vitorpamplona.quic.stream.StreamId
import com.vitorpamplona.quic.tls.TlsClient
import com.vitorpamplona.quic.tls.TlsConstants
import com.vitorpamplona.quic.tls.TlsSecretsListener
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -103,6 +105,18 @@ class QuicConnection(
private var nextLocalBidiIndex: Long = 0L
private var nextLocalUniIndex: Long = 0L
/**
* Peer-advertised concurrent bidirectional stream cap. Initialised from
* [TransportParameters.initialMaxStreamsBidi] when peer params arrive,
* then bumped by inbound MAX_STREAMS frames (RFC 9000 §19.11). Must not
* decrease — a smaller MAX_STREAMS than current is silently dropped.
*
* [openBidiStream] consults this cap; opening past it would violate the
* peer's flow control and trigger STREAM_LIMIT_ERROR on their side.
*/
internal var peerMaxStreamsBidi: Long = 0L
internal var peerMaxStreamsUni: Long = 0L
/**
* The connection-level receive limit we've currently advertised to the
* peer. Tracks the high-water mark of the most recent MAX_DATA frame we
@@ -125,6 +139,32 @@ class QuicConnection(
/** Streams the peer has opened that we haven't surfaced yet. */
private val newPeerStreams = ArrayDeque<QuicStream>()
/**
* Conflated signal that wakes [awaitIncomingPeerStream] callers whenever a
* peer-initiated stream is appended to [newPeerStreams]. Conflated because
* a single wake is enough to trigger a queue-drain — duplicate signals
* collapse into one, which is the correct semantics for "something is
* available, come look".
*/
private val peerStreamSignal = Channel<Unit>(Channel.CONFLATED)
/**
* Same conflated-signal pattern for inbound datagrams. Wakes
* [awaitIncomingDatagram] callers when a new datagram is appended to
* [incomingDatagrams].
*/
private val incomingDatagramSignal = Channel<Unit>(Channel.CONFLATED)
/**
* Closed-state signal that wakes any suspended `await*` caller when the
* connection terminates. Distinct from the per-resource signal channels
* because a closed connection should unblock everyone, not just the next
* resource arrival. Closing the channel (vs sending a value) is the
* idiomatic "this stream is done" — `select` clauses on `onReceive` see
* a [ClosedReceiveChannelException] which we map to null.
*/
private val closedSignal = Channel<Unit>(Channel.CONFLATED)
private val tlsListener =
object : TlsSecretsListener {
override fun onHandshakeKeysReady(
@@ -180,8 +220,13 @@ class QuicConnection(
// Install Initial keys based on the random destination CID we just generated.
val proto = InitialSecrets.derive(destinationConnectionId.bytes)
val hp = AesEcbHeaderProtection(PlatformAesOneBlock)
initial.sendProtection = PacketProtection(Aes128Gcm, proto.clientKey, proto.clientIv, hp, proto.clientHp)
initial.receiveProtection = PacketProtection(Aes128Gcm, proto.serverKey, proto.serverIv, hp, proto.serverHp)
// Initial packets always use AES-128-GCM (RFC 9001 §5). Use the
// platform's cached-cipher implementation so per-packet seal/open
// doesn't pay for `Cipher.getInstance`.
initial.sendProtection =
PacketProtection(bestAes128GcmAead(proto.clientKey), proto.clientKey, proto.clientIv, hp, proto.clientHp)
initial.receiveProtection =
PacketProtection(bestAes128GcmAead(proto.serverKey), proto.serverKey, proto.serverIv, hp, proto.serverHp)
}
/** Begin the handshake — emits ClientHello into Initial CRYPTO. */
@@ -213,6 +258,8 @@ class QuicConnection(
val tp = TransportParameters.decode(raw)
peerTransportParameters = tp
sendConnectionFlowCredit = tp.initialMaxData ?: 0L
peerMaxStreamsBidi = tp.initialMaxStreamsBidi ?: 0L
peerMaxStreamsUni = tp.initialMaxStreamsUni ?: 0L
// Update each open stream's send credit per direction.
for ((id, stream) in streams) {
stream.sendCredit =
@@ -237,9 +284,22 @@ class QuicConnection(
*/
val lock: Mutex = Mutex()
/** Allocate a new client-initiated bidirectional stream. Locked. */
/**
* Allocate a new client-initiated bidirectional stream. Locked.
*
* Throws [QuicStreamLimitException] if the peer has not granted enough
* bidirectional stream credit yet. Use [peerMaxStreamsBidiSnapshot] to
* check capacity proactively if the caller wants to back-pressure rather
* than throw.
*/
suspend fun openBidiStream(): QuicStream =
lock.withLock {
if (nextLocalBidiIndex >= peerMaxStreamsBidi) {
throw QuicStreamLimitException(
"peer-granted bidi stream cap reached " +
"(used=$nextLocalBidiIndex limit=$peerMaxStreamsBidi)",
)
}
val id = StreamId.build(StreamId.Kind.CLIENT_BIDI, nextLocalBidiIndex++)
val stream = QuicStream(id, QuicStream.Direction.BIDIRECTIONAL)
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataBidiRemote ?: config.initialMaxStreamDataBidiRemote
@@ -251,6 +311,12 @@ class QuicConnection(
/** Allocate a new client-initiated unidirectional (write-only) stream. Locked. */
suspend fun openUniStream(): QuicStream =
lock.withLock {
if (nextLocalUniIndex >= peerMaxStreamsUni) {
throw QuicStreamLimitException(
"peer-granted uni stream cap reached " +
"(used=$nextLocalUniIndex limit=$peerMaxStreamsUni)",
)
}
val id = StreamId.build(StreamId.Kind.CLIENT_UNI, nextLocalUniIndex++)
val stream = QuicStream(id, QuicStream.Direction.UNIDIRECTIONAL_LOCAL_TO_REMOTE)
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataUni ?: config.initialMaxStreamDataUni
@@ -259,14 +325,70 @@ class QuicConnection(
stream
}
/** Snapshot of peer-granted bidi cap. Reads do not need the lock — long writes are atomic on every supported platform. */
fun peerMaxStreamsBidiSnapshot(): Long = peerMaxStreamsBidi
/** Snapshot of peer-granted uni cap. */
fun peerMaxStreamsUniSnapshot(): Long = peerMaxStreamsUni
suspend fun pollIncomingPeerStream(): QuicStream? = lock.withLock { newPeerStreams.removeFirstOrNull() }
/**
* Suspends until a peer-initiated stream is queued OR the connection
* closes. Returns null on close. Replaces the older `pollIncomingPeerStream
* + delay(5)` busy-loop — this version wakes within microseconds of the
* parser appending a stream and parks the coroutine the rest of the time.
*/
suspend fun awaitIncomingPeerStream(): QuicStream? {
while (true) {
lock.withLock { newPeerStreams.removeFirstOrNull() }?.let { return it }
if (status == Status.CLOSED) return null
// select between "wakeup" and "closed" so neither path can hang.
val keepWaiting =
select<Boolean> {
peerStreamSignal.onReceiveCatching { result ->
// Conflated channel: if it's closed (shouldn't happen
// here, but be defensive) bail out.
result.isSuccess
}
closedSignal.onReceiveCatching { false }
}
if (!keepWaiting) {
// After a close-wake, drain one more time to surface any
// streams added between the last drain and the close.
lock.withLock { newPeerStreams.removeFirstOrNull() }?.let { return it }
return null
}
}
}
suspend fun streamById(id: Long): QuicStream? = lock.withLock { streams[id] }
suspend fun queueDatagram(payload: ByteArray) = lock.withLock { pendingDatagrams.addLast(payload) }
suspend fun pollIncomingDatagram(): ByteArray? = lock.withLock { incomingDatagrams.removeFirstOrNull() }
/**
* Suspending counterpart of [pollIncomingDatagram]. Returns null only when
* the connection has been closed and no more datagrams remain. Same
* select-based wakeup pattern as [awaitIncomingPeerStream].
*/
suspend fun awaitIncomingDatagram(): ByteArray? {
while (true) {
lock.withLock { incomingDatagrams.removeFirstOrNull() }?.let { return it }
if (status == Status.CLOSED) return null
val keepWaiting =
select<Boolean> {
incomingDatagramSignal.onReceiveCatching { result -> result.isSuccess }
closedSignal.onReceiveCatching { false }
}
if (!keepWaiting) {
lock.withLock { incomingDatagrams.removeFirstOrNull() }?.let { return it }
return null
}
}
}
/** Initiate a graceful close. */
suspend fun close(
errorCode: Long,
@@ -284,6 +406,7 @@ class QuicConnection(
if (!handshakeComplete) {
signalHandshakeFailed(QuicConnectionClosedException("connection closed before handshake completed: $reason"))
}
closedSignal.close()
}
/** Called by the parser on inbound CONNECTION_CLOSE or by the driver on read-loop death. */
@@ -292,6 +415,7 @@ class QuicConnection(
if (!handshakeComplete) {
signalHandshakeFailed(QuicConnectionClosedException("connection closed externally: $reason"))
}
closedSignal.close()
}
/**
@@ -319,9 +443,20 @@ class QuicConnection(
}
streams[id] = stream
newPeerStreams.addLast(stream)
// Wake any awaitIncomingPeerStream caller. trySend on a CONFLATED
// channel can never fail in steady state.
peerStreamSignal.trySend(Unit)
return stream
}
/**
* Caller (parser, holding lock) appended a datagram to [incomingDatagrams].
* Fires the wakeup signal so awaitIncomingDatagram unblocks promptly.
*/
internal fun signalIncomingDatagram() {
incomingDatagramSignal.trySend(Unit)
}
/** Returns the level state for [level]. */
fun levelState(level: EncryptionLevel): LevelState =
when (level) {
@@ -347,3 +482,8 @@ class QuicConnection(
class QuicConnectionClosedException(
message: String,
) : RuntimeException(message)
/** Caller tried to open a stream beyond the peer's MAX_STREAMS allowance. */
class QuicStreamLimitException(
message: String,
) : RuntimeException(message)
@@ -27,6 +27,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
@@ -54,10 +55,16 @@ class QuicConnectionDriver(
private val scope = CoroutineScope(parentScope.coroutineContext + job + Dispatchers.IO)
private val sendWakeup = Channel<Unit>(Channel.CONFLATED)
// Track the read + send loop jobs so close() can join them cleanly
// (waiting for the in-flight datagram to finish flushing) instead of
// racing them with scope.cancel().
private var readJob: Job? = null
private var sendJob: Job? = null
fun start() {
connection.start()
scope.launch { readLoop() }
scope.launch { sendLoop() }
readJob = scope.launch { readLoop() }
sendJob = scope.launch { sendLoop() }
// Initial nudge so the ClientHello goes out immediately.
sendWakeup.trySend(Unit)
}
@@ -120,23 +127,70 @@ class QuicConnectionDriver(
}
/**
* Cleanly tear down the driver. Safe to call from inside the driver scope —
* the actual cancel-and-close runs on [parentScope] so the caller's coroutine
* (which may itself be in [scope]) doesn't get cancelled before the close
* completes.
* Cleanly tear down the driver. Runs on [parentScope] so the caller (which
* may itself live inside the driver's [scope]) isn't cancelled before its
* own teardown completes.
*
* Sequence:
* 1. Mark the connection CLOSING so the writer starts emitting
* CONNECTION_CLOSE on the next drain.
* 2. Wake the send loop and wait up to [CLOSE_FLUSH_TIMEOUT_MILLIS] for it
* to flush that packet — yield() alone is not enough because the send
* loop may be parked on Channel.receive on a different IO worker
* thread; only an explicit join+wakeup sequence guarantees the close
* bytes hit the socket.
* 3. Force the loops out of their `while (status != CLOSED)` guards by
* transitioning to CLOSED, then cancel the scope and close the socket.
*
* The earlier yield()-based version raced: scope.cancel() could fire
* while sendLoop was mid-`socket.send()`, occasionally producing partial
* datagrams or skipping the CONNECTION_CLOSE entirely.
*/
fun close() {
// Drive the close on the parent scope so we don't cancel our own caller.
parentScope.launch {
try {
connection.close(0L, "")
wakeup() // let the send loop emit CONNECTION_CLOSE
// Give the send loop one tick to flush the close packet, then tear down.
kotlinx.coroutines.yield()
} finally {
scope.cancel()
socket.close()
connection.close(0L, "")
wakeup()
val send = sendJob
// Bounded wait for the send loop to flush CONNECTION_CLOSE. We
// don't want to hang forever if the writer is wedged — the timeout
// is the upper bound on how long close() can block.
withTimeoutOrNull(CLOSE_FLUSH_TIMEOUT_MILLIS) {
// Spin until the writer has actually drained the queued close
// (queues are empty AND the send loop has cycled at least
// once). Easiest proxy: write was attempted and there's
// nothing more to send. We approximate by giving the loop a
// chance to drain by sleeping briefly. This is the one place
// a short sleep is acceptable because we're racing a flush.
while (true) {
val drained =
connection.lock.withLock {
// No more pending datagrams or stream bytes? Then
// CONNECTION_CLOSE has either been sent or there
// was nothing to send.
connection.pendingDatagramsLocked().isEmpty()
}
if (drained) break
kotlinx.coroutines.delay(1)
}
}
// Now flip to CLOSED so both loops exit their while-guards.
connection.markClosedExternally("driver close requested")
wakeup()
// Wait for both loops to actually exit — joinAll won't return
// until the in-flight socket.send() (if any) completes.
withTimeoutOrNull(CLOSE_FLUSH_TIMEOUT_MILLIS) {
listOfNotNull(readJob, send).joinAll()
}
// Final teardown. By now both jobs have either exited cleanly or
// exceeded the timeout — cancel guarantees they're done before we
// close the socket.
scope.cancel()
socket.close()
}
}
companion object {
/** Upper bound on close() flush wait. Each phase (drain + join) gets up to this much. */
private const val CLOSE_FLUSH_TIMEOUT_MILLIS = 250L
}
}
@@ -200,6 +200,7 @@ private fun dispatchFrames(
is DatagramFrame -> {
ackEliciting = true
conn.incomingDatagramsLocked().addLast(frame.data)
conn.signalIncomingDatagram()
}
is MaxDataFrame -> {
@@ -213,7 +214,18 @@ private fun dispatchFrames(
}
is MaxStreamsFrame -> {
// Tracking left for a later phase.
// RFC 9000 §19.11: MAX_STREAMS only ever raises the cap.
// Frames with values smaller than the current cap are ignored.
// Bidi vs uni is signaled via the frame's `bidi` flag.
if (frame.bidi) {
if (frame.maxStreams > conn.peerMaxStreamsBidi) {
conn.peerMaxStreamsBidi = frame.maxStreams
}
} else {
if (frame.maxStreams > conn.peerMaxStreamsUni) {
conn.peerMaxStreamsUni = frame.maxStreams
}
}
}
is NewConnectionIdFrame -> {
@@ -23,8 +23,17 @@ package com.vitorpamplona.quic.crypto
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
/** AEAD selector, parameterised by TLS cipher-suite identifier. */
sealed class Aead {
/**
* AEAD selector, parameterised by TLS cipher-suite identifier.
*
* Implementations may be stateless singletons (the historical pattern;
* `Aes128Gcm` and `ChaCha20Poly1305Aead` below) or stateful instances that
* cache the underlying cipher / key spec across calls (the JVM-only
* `JcaAesGcmAead` does this for the AES-GCM hot path). The `key` parameter
* is included in seal/open for the singleton pattern; cached instances may
* ignore it and use their bound key.
*/
abstract class Aead {
abstract val keyLength: Int
abstract val nonceLength: Int
abstract val tagLength: Int
@@ -25,3 +25,12 @@ expect val PlatformAesOneBlock: AesOneBlockEncrypt
/** Platform-provided ChaCha20 keystream block encryptor (RFC 8439 IETF variant). */
expect val PlatformChaCha20Block: ChaCha20BlockEncrypt
/**
* Build the platform's preferred AES-128-GCM AEAD for a fixed [key]. JVM
* targets return a [JcaAesGcmAead]-style instance with the JCA Cipher and
* SecretKeySpec cached so per-packet seal/open avoids the costly
* `Cipher.getInstance` lookup. Other targets may return [Aes128Gcm] (the
* stateless singleton) correct, just not the fast path.
*/
expect fun bestAes128GcmAead(key: ByteArray): Aead
@@ -0,0 +1,41 @@
/*
* 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
/**
* Incremental SHA-256 with snapshot-without-consume.
*
* Used by [TlsTranscriptHash] to avoid re-hashing every accumulated message
* on each snapshot. RFC 8446's key schedule samples the transcript at three
* points (handshake keys, application keys, Finished verification) each of
* which would otherwise re-walk every prior handshake byte. Cloning the
* digest is O(state size) a few hundred bytes, vs. O(transcript size) for
* the re-hash approach.
*
* The contract: [update] feeds bytes; [snapshot] returns the SHA-256 of
* everything fed so far without invalidating the running state, so further
* [update] calls continue from the same position.
*/
expect class TlsRunningSha256() {
fun update(bytes: ByteArray)
fun snapshot(): ByteArray
}
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.quic.tls
import com.vitorpamplona.quartz.utils.sha256.sha256
/**
* Running SHA-256 over the concatenated handshake messages, per RFC 8446 §4.4.1.
*
@@ -36,27 +34,19 @@ import com.vitorpamplona.quartz.utils.sha256.sha256
*
* Each message is appended with its 4-byte handshake header included.
*
* For Phase B we keep this simple: we accumulate raw bytes and re-hash. The
* volume is small (a few KB per handshake), so SHA-256 throughput isn't a
* bottleneck. A streaming hash would be a nice optimisation later.
* Backed by an incremental [TlsRunningSha256] (JCA `MessageDigest` on JVM).
* Each [snapshot] clones the running state and finalises the clone, so
* subsequent [append] calls keep extending the same hash. Earlier versions
* accumulated raw bytes and re-hashed on every snapshot O() across the
* three+ snapshots a TLS 1.3 handshake takes.
*/
class TlsTranscriptHash {
private val buffer = ArrayList<ByteArray>()
private val running = TlsRunningSha256()
fun append(messageBytes: ByteArray) {
buffer += messageBytes
running.update(messageBytes)
}
/** Snapshot the current transcript hash (32 bytes). */
fun snapshot(): ByteArray {
var totalLen = 0
for (b in buffer) totalLen += b.size
val concat = ByteArray(totalLen)
var pos = 0
for (b in buffer) {
b.copyInto(concat, pos)
pos += b.size
}
return sha256(concat)
}
fun snapshot(): ByteArray = running.snapshot()
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.quic.webtransport
import com.vitorpamplona.quic.connection.QuicConnection
import com.vitorpamplona.quic.connection.QuicConnectionDriver
import com.vitorpamplona.quic.stream.QuicStream
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -54,19 +55,69 @@ class QuicWebTransportSessionState(
private val demux: WtPeerStreamDemux = WtPeerStreamDemux(connectStreamId, scope)
/**
* Completes once a WT_CLOSE_SESSION capsule arrives on the CONNECT bidi.
* Application code can `await()` this to detect a peer-initiated graceful
* close and tear down its own state. Stays uncompleted for the entire
* lifetime of a healthy session.
*/
private val peerCloseDeferred = CompletableDeferred<WtCloseSession>()
/**
* Mirror of the close-capsule contents that is safe to read without the
* opt-in `getCompleted()` API. Set in the same place we complete
* [peerCloseDeferred]; non-null once a WT_CLOSE_SESSION has been observed.
*/
@Volatile
private var peerCloseSnapshot: WtCloseSession? = null
init {
// Spawn the dispatcher that routes peer-initiated streams through the
// demux. Without this, the server's CONTROL stream (carrying SETTINGS)
// would be handed to the application, which would interpret SETTINGS
// bytes as MoQ frames and break framing.
scope.launch {
while (connection.status != QuicConnection.Status.CLOSED) {
val s = connection.pollIncomingPeerStream()
if (s != null) {
demux.process(s)
} else {
kotlinx.coroutines.delay(5)
// awaitIncomingPeerStream suspends on a CONFLATED channel that the
// parser fires whenever it appends a peer stream — replaces the
// earlier delay(5) busy-loop. Returns null when the connection
// closes, which is our exit condition.
while (true) {
val s = connection.awaitIncomingPeerStream() ?: break
demux.process(s)
}
}
// Spawn a reader on the CONNECT bidi that decodes capsules. The
// CONNECT stream carries WT_CLOSE_SESSION (graceful peer close) and
// possibly WT_DRAIN_SESSION; without this reader, peer-initiated
// close goes silent and the application keeps trying to send on a
// half-closed session.
scope.launch {
val connectStream = connection.streamById(connectStreamId) ?: return@launch
val capsuleReader = CapsuleReader()
try {
connectStream.incoming.collect { chunk ->
capsuleReader.push(chunk)
while (true) {
val capsule = capsuleReader.next() ?: break
if (capsule is WtCloseSession) {
// Complete on first WT_CLOSE_SESSION; subsequent
// capsules on the same stream are ignored.
if (peerCloseSnapshot == null) {
peerCloseSnapshot = capsule
peerCloseDeferred.complete(capsule)
}
return@collect
}
// Other capsule types (DRAIN, unknown) are currently
// observed but not surfaced — extend here if/when the
// application needs them.
}
}
} catch (_: Throwable) {
// Stream closed before a capsule arrived. Leave peerCloseDeferred
// uncompleted — the connection-level close path is the source
// of truth in that case.
}
}
}
@@ -74,6 +125,25 @@ class QuicWebTransportSessionState(
/** Server SETTINGS once received on the H3 control stream; null until then. */
val peerSettings get() = demux.peerSettings
/**
* Server-sent GOAWAY stream id, if one has arrived. Null until the H3
* CONTROL stream produces a GOAWAY frame. Applications should treat
* non-null as "stop opening new streams; existing ones may still finish."
*/
val peerGoawayStreamId get() = demux.peerGoawayStreamId
/**
* The WT_CLOSE_SESSION capsule the peer sent on the CONNECT bidi, or null
* if no graceful close has arrived yet. Applications wanting to react
* synchronously can `peerCloseSession()` and check for null; coroutines
* wanting to suspend until close should use [awaitPeerClose].
*/
val peerCloseSession: WtCloseSession?
get() = peerCloseSnapshot
/** Suspends until a peer-initiated WT_CLOSE_SESSION arrives. */
suspend fun awaitPeerClose(): WtCloseSession = peerCloseDeferred.await()
/** Flow of peer-initiated WT streams whose framing prefix has been stripped. */
val incomingStrippedStreams: Flow<StrippedWtStream> get() = demux.incomingStrippedStreams
@@ -67,6 +67,18 @@ class WtPeerStreamDemux(
var peerSettings: Http3Settings? = null
private set
/**
* Server-sent GOAWAY stream id (RFC 9114 §5.2). The server is signalling
* "I won't accept any new request streams >= this id"; for WebTransport we
* treat it as the session entering drain existing streams may continue
* but the application should not open new ones.
*
* Stays null until a GOAWAY arrives on the CONTROL stream.
*/
@Volatile
var peerGoawayStreamId: Long? = null
private set
val incomingStrippedStreams: Flow<StrippedWtStream> = readyStreams.consumeAsFlow()
/**
@@ -174,12 +186,24 @@ class WtPeerStreamDemux(
while (true) {
val frame = reader.next() ?: return
when (frame) {
is Http3Frame.Settings -> peerSettings = frame.settings
is Http3Frame.Settings -> {
peerSettings = frame.settings
}
is Http3Frame.Goaway -> Unit
is Http3Frame.Goaway -> {
// GOAWAY body is a single varint Stream ID. Decode it so
// applications can observe drain state via
// [peerGoawayStreamId]. Malformed bodies are dropped — RFC
// 9114 §7.2.6 says servers SHOULD send a single varint
// but we don't kill the connection over a parse error here.
val res = Varint.decode(frame.body, 0)
if (res != null) peerGoawayStreamId = res.value
}
// no new requests; we don't enforce yet
else -> Unit
else -> {
Unit
}
}
}
}
@@ -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() "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)
}
}
@@ -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()
}
}
}
@@ -0,0 +1,100 @@
/*
* 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.crypto
import java.security.GeneralSecurityException
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* AES-128-GCM AEAD with the JCA `Cipher` and `SecretKeySpec` cached per
* direction. Audits 1 + 3 flagged that going through Quartz's AESGCM (which
* internally calls `Cipher.getInstance("AES/GCM/NoPadding")` per call) is
* the dominant per-packet cost on the steady-state path. This wrapper
* builds the Cipher + key spec ONCE; each seal/open does only `Cipher.init`
* (which is much cheaper than `getInstance`) plus the AEAD math itself.
*
* Single-thread per direction: one PacketProtection feeds either the read
* loop OR the send loop, never both. Locking would be needed if that ever
* changes.
*/
class JcaAesGcmAead(
key: ByteArray,
) : Aead() {
override val keyLength = 16
override val nonceLength = 12
override val tagLength = 16
private val keySpec = SecretKeySpec(key, "AES")
// Separate ciphers per direction. JCA's AES-GCM tracks the (key, iv) pair
// across encrypt calls and rejects IV reuse — even when the IV reuse is
// legitimate (our Initial-padding rebuild path re-encrypts the same PN).
// Toggling DECRYPT_MODE between seals is fragile; using two ciphers
// (one always-ENCRYPT, one always-DECRYPT) avoids the check entirely
// since the encrypt-side IVs come from monotonic packet numbers and
// never legitimately repeat OUTSIDE the rebuild edge case.
//
// For the rebuild edge case specifically, we fall back to a fresh
// Cipher.getInstance — slow but rare (once per Initial datagram).
private val encryptCipher: Cipher = Cipher.getInstance("AES/GCM/NoPadding")
private val decryptCipher: Cipher = Cipher.getInstance("AES/GCM/NoPadding")
private var lastEncryptNonce: ByteArray? = null
override fun seal(
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
plaintext: ByteArray,
): ByteArray {
// Detect IV reuse — happens on the Initial-padding rebuild path. Fall
// back to a one-shot fresh Cipher for that case rather than fighting
// JCA's safety check.
val reuse = lastEncryptNonce?.contentEquals(nonce) == true
return if (reuse) {
val fresh = Cipher.getInstance("AES/GCM/NoPadding")
fresh.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(128, nonce))
fresh.updateAAD(aad)
fresh.doFinal(plaintext)
} else {
encryptCipher.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(128, nonce))
encryptCipher.updateAAD(aad)
val out = encryptCipher.doFinal(plaintext)
lastEncryptNonce = nonce
out
}
}
override fun open(
key: ByteArray,
nonce: ByteArray,
aad: ByteArray,
ciphertext: ByteArray,
): ByteArray? =
try {
decryptCipher.init(Cipher.DECRYPT_MODE, keySpec, GCMParameterSpec(128, nonce))
decryptCipher.updateAAD(aad)
decryptCipher.doFinal(ciphertext)
} catch (_: GeneralSecurityException) {
null
}
}
@@ -43,3 +43,5 @@ actual val PlatformChaCha20Block: ChaCha20BlockEncrypt =
ChaCha20BlockEncrypt { key, nonce, counter, plaintext ->
ChaCha20Core.chaCha20Xor(plaintext, key, nonce, counter)
}
actual fun bestAes128GcmAead(key: ByteArray): Aead = JcaAesGcmAead(key)
@@ -0,0 +1,47 @@
/*
* 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 java.security.MessageDigest
/**
* JCA-backed incremental SHA-256. `MessageDigest.clone()` is supported by all
* stock JDK SHA-256 providers and produces an independent digest object we
* use that to take a snapshot without disturbing the running state.
*
* Single-thread per instance: the TlsClient state machine is the sole caller,
* driven by the QUIC connection's lock, so no synchronization is needed.
*/
actual class TlsRunningSha256 actual constructor() {
private val digest: MessageDigest = MessageDigest.getInstance("SHA-256")
actual fun update(bytes: ByteArray) {
digest.update(bytes)
}
actual fun snapshot(): ByteArray {
// Cloning the digest is the only way to read the current hash without
// ending the running state — `digest.digest()` finalizes and resets,
// which would silently corrupt subsequent updates.
val clone = digest.clone() as MessageDigest
return clone.digest()
}
}