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:
+33
-36
@@ -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)
|
||||
|
||||
+69
-15
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -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(n²) 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()
|
||||
}
|
||||
|
||||
+76
-6
@@ -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
|
||||
|
||||
|
||||
+27
-3
@@ -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(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user