feat(quic): Phase D-H — QuicConnection orchestrator
Wire together TLS, packet codec, and stream buffers into a single client QuicConnection that drives the handshake and 1-RTT exchange. - QuicConnection owns per-encryption-level state (LevelState): packet number space, ACK tracker, CRYPTO send + receive buffers, send + receive packet protection. Initial keys are installed at construction from a random DCID. - TlsSecretsListener inside the connection installs handshake + application keys as the TLS state machine derives them. - AckTracker maintains a sorted disjoint-range list of received packet numbers and emits RFC 9000 §19.3-shaped ACK frames newest-first. - SendBuffer + per-stream QuicStream allow application code to enqueue bytes and FIN; the connection's writer drains them into STREAM frames. - QuicConnectionParser dispatches inbound CRYPTO/STREAM/DATAGRAM/MAX_*/ACK/ CONNECTION_CLOSE frames into the right state. Coalesced packets in a single datagram are demultiplexed. - QuicConnectionWriter builds outbound datagrams that coalesce Initial + Handshake + 1-RTT packets, pads Initial datagrams to 1200 bytes, and drains stream send queues round-robin under a soft MTU budget. - TransportParameters codec covers all RFC 9000 §18.2 + RFC 9221 fields and is exchanged via the TLS quic_transport_parameters extension. - PacketProtectionBuilder maps a TLS traffic secret + cipher suite to the AEAD + HP triple via RFC 9001's `quic key`/`quic iv`/`quic hp` labels. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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.crypto.Aead
|
||||
|
||||
/** Per-direction packet protection material at one encryption level. */
|
||||
class PacketProtection(
|
||||
val aead: Aead,
|
||||
val key: ByteArray,
|
||||
val iv: ByteArray,
|
||||
val hp: com.vitorpamplona.quic.crypto.HeaderProtection,
|
||||
val hpKey: ByteArray,
|
||||
)
|
||||
|
||||
/** All four encryption levels we ever see in a QUIC client connection. */
|
||||
enum class EncryptionLevel(val space: PacketNumberSpace) {
|
||||
INITIAL(PacketNumberSpace.INITIAL),
|
||||
HANDSHAKE(PacketNumberSpace.HANDSHAKE),
|
||||
APPLICATION(PacketNumberSpace.APPLICATION),
|
||||
;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.stream.ReceiveBuffer
|
||||
import com.vitorpamplona.quic.stream.SendBuffer
|
||||
|
||||
/** Per-encryption-level state owned by [QuicConnection]. */
|
||||
class LevelState {
|
||||
val pnSpace = PacketNumberSpaceState()
|
||||
val ackTracker = com.vitorpamplona.quic.recovery.AckTracker()
|
||||
val cryptoSend = SendBuffer()
|
||||
val cryptoReceive = ReceiveBuffer()
|
||||
var sendProtection: PacketProtection? = null
|
||||
var receiveProtection: PacketProtection? = null
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.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.tls.TlsConstants
|
||||
import com.vitorpamplona.quic.tls.deriveQuicKeys
|
||||
|
||||
/**
|
||||
* Build [PacketProtection] for one direction at one encryption level given a
|
||||
* TLS traffic secret + TLS cipher-suite identifier. The QUIC labels in
|
||||
* RFC 9001 §5.1 (`quic key`, `quic iv`, `quic hp`) drive the expansion.
|
||||
*
|
||||
* For Phase B–K we only support TLS_AES_128_GCM_SHA256 (16/12/16) and
|
||||
* TLS_CHACHA20_POLY1305_SHA256 — the SHA-256 suites; nests speaks both.
|
||||
*/
|
||||
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 keys = deriveQuicKeys(secret, keyLen, ivLen, hpLen)
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* 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.QuicWriter
|
||||
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.frame.ConnectionCloseFrame
|
||||
import com.vitorpamplona.quic.frame.CryptoFrame
|
||||
import com.vitorpamplona.quic.frame.DatagramFrame
|
||||
import com.vitorpamplona.quic.frame.Frame
|
||||
import com.vitorpamplona.quic.frame.PingFrame
|
||||
import com.vitorpamplona.quic.frame.StreamFrame
|
||||
import com.vitorpamplona.quic.frame.decodeFrames
|
||||
import com.vitorpamplona.quic.frame.encodeFrames
|
||||
import com.vitorpamplona.quic.packet.LongHeaderPacket
|
||||
import com.vitorpamplona.quic.packet.LongHeaderPlaintextPacket
|
||||
import com.vitorpamplona.quic.packet.LongHeaderType
|
||||
import com.vitorpamplona.quic.packet.QuicVersion
|
||||
import com.vitorpamplona.quic.packet.ShortHeaderPacket
|
||||
import com.vitorpamplona.quic.packet.ShortHeaderPlaintextPacket
|
||||
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
|
||||
|
||||
/**
|
||||
* QUIC v1 client connection. The orchestrator that owns:
|
||||
*
|
||||
* - the TLS 1.3 client (which produces CRYPTO bytes per encryption level)
|
||||
* - the per-level packet protection material
|
||||
* - per-level packet number spaces + ACK trackers
|
||||
* - per-stream send/receive buffers
|
||||
* - inbound datagram queue
|
||||
*
|
||||
* The connection is driven by two callbacks:
|
||||
*
|
||||
* [feedDatagram] — feed an inbound UDP datagram. Decrypts every contained
|
||||
* QUIC packet, runs CRYPTO bytes through the TLS state
|
||||
* machine, dispatches STREAM/DATAGRAM/ACK/MAX_* frames to
|
||||
* the right stream, and updates ACK state.
|
||||
*
|
||||
* [drainOutbound] — pull the next UDP datagram (or null if nothing to send).
|
||||
* Coalesces ACK + CRYPTO + STREAM + DATAGRAM frames into
|
||||
* a single packet (or two, if Initial + Handshake).
|
||||
*
|
||||
* The actual UDP socket I/O is in the higher-level [QuicConnectionDriver].
|
||||
*/
|
||||
class QuicConnection(
|
||||
val serverName: String,
|
||||
val config: QuicConnectionConfig,
|
||||
val tlsCertificateValidator: com.vitorpamplona.quic.tls.CertificateValidator? = null,
|
||||
val nowMillis: () -> Long = { kotlin.time.Clock.System.now().toEpochMilliseconds() },
|
||||
val alpnList: List<ByteArray> = listOf(TlsConstants.ALPN_H3),
|
||||
) {
|
||||
val sourceConnectionId: ConnectionId = ConnectionId.random(8)
|
||||
var destinationConnectionId: ConnectionId = ConnectionId.random(8)
|
||||
internal set
|
||||
val originalDestinationConnectionId: ConnectionId = destinationConnectionId
|
||||
|
||||
val initial = LevelState()
|
||||
val handshake = LevelState()
|
||||
val application = LevelState()
|
||||
|
||||
var handshakeComplete: Boolean = false
|
||||
private set
|
||||
|
||||
var peerTransportParameters: TransportParameters? = null
|
||||
private set
|
||||
|
||||
enum class Status { HANDSHAKING, CONNECTED, CLOSING, CLOSED }
|
||||
|
||||
var status: Status = Status.HANDSHAKING
|
||||
internal set
|
||||
|
||||
/** App-level error code for graceful close. */
|
||||
var closeReason: String? = null
|
||||
private set
|
||||
var closeErrorCode: Long = 0L
|
||||
private set
|
||||
|
||||
private val streams = mutableMapOf<Long, QuicStream>()
|
||||
private var nextLocalBidiIndex: Long = 0L
|
||||
private var nextLocalUniIndex: Long = 0L
|
||||
private val pendingDatagrams = ArrayDeque<ByteArray>()
|
||||
private val incomingDatagrams = ArrayDeque<ByteArray>()
|
||||
private var sendConnectionFlowCredit: Long = 0L
|
||||
private var receiveConnectionFlowLimit: Long = config.initialMaxData
|
||||
|
||||
/** Streams the peer has opened that we haven't surfaced yet. */
|
||||
private val newPeerStreams = ArrayDeque<QuicStream>()
|
||||
|
||||
private val tlsListener =
|
||||
object : TlsSecretsListener {
|
||||
override fun onHandshakeKeysReady(
|
||||
cipherSuite: Int,
|
||||
clientSecret: ByteArray,
|
||||
serverSecret: ByteArray,
|
||||
) {
|
||||
handshake.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret)
|
||||
handshake.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret)
|
||||
}
|
||||
|
||||
override fun onApplicationKeysReady(
|
||||
cipherSuite: Int,
|
||||
clientSecret: ByteArray,
|
||||
serverSecret: ByteArray,
|
||||
) {
|
||||
application.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret)
|
||||
application.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret)
|
||||
}
|
||||
|
||||
override fun onHandshakeComplete() {
|
||||
handshakeComplete = true
|
||||
if (status == Status.HANDSHAKING) status = Status.CONNECTED
|
||||
applyPeerTransportParameters()
|
||||
}
|
||||
}
|
||||
|
||||
val tls: TlsClient =
|
||||
TlsClient(
|
||||
serverName = serverName,
|
||||
transportParameters = buildLocalTransportParameters().encode(),
|
||||
secretsListener = tlsListener,
|
||||
certificateValidator = tlsCertificateValidator,
|
||||
)
|
||||
|
||||
init {
|
||||
// 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)
|
||||
}
|
||||
|
||||
/** Begin the handshake — emits ClientHello into Initial CRYPTO. */
|
||||
fun start() {
|
||||
tls.start()
|
||||
// Drain ClientHello bytes into the Initial-level CRYPTO send buffer.
|
||||
tls.pollOutbound(TlsClient.Level.INITIAL)?.let { initial.cryptoSend.enqueue(it) }
|
||||
}
|
||||
|
||||
private fun buildLocalTransportParameters(): TransportParameters =
|
||||
TransportParameters(
|
||||
initialMaxData = config.initialMaxData,
|
||||
initialMaxStreamDataBidiLocal = config.initialMaxStreamDataBidiLocal,
|
||||
initialMaxStreamDataBidiRemote = config.initialMaxStreamDataBidiRemote,
|
||||
initialMaxStreamDataUni = config.initialMaxStreamDataUni,
|
||||
initialMaxStreamsBidi = config.initialMaxStreamsBidi,
|
||||
initialMaxStreamsUni = config.initialMaxStreamsUni,
|
||||
maxIdleTimeoutMillis = config.maxIdleTimeoutMillis,
|
||||
maxUdpPayloadSize = config.maxUdpPayloadSize,
|
||||
ackDelayExponent = config.ackDelayExponent,
|
||||
maxAckDelay = config.maxAckDelay,
|
||||
activeConnectionIdLimit = config.activeConnectionIdLimit,
|
||||
initialSourceConnectionId = sourceConnectionId.bytes,
|
||||
maxDatagramFrameSize = config.maxDatagramFrameSize,
|
||||
)
|
||||
|
||||
private fun applyPeerTransportParameters() {
|
||||
val raw = tls.peerTransportParameters ?: return
|
||||
val tp = TransportParameters.decode(raw)
|
||||
peerTransportParameters = tp
|
||||
sendConnectionFlowCredit = tp.initialMaxData ?: 0L
|
||||
// Update each open stream's send credit per direction.
|
||||
for ((id, stream) in streams) {
|
||||
stream.sendCredit =
|
||||
when (StreamId.kindOf(id)) {
|
||||
StreamId.Kind.CLIENT_BIDI, StreamId.Kind.SERVER_BIDI ->
|
||||
if (StreamId.isClientInitiated(id)) tp.initialMaxStreamDataBidiRemote ?: 0L else tp.initialMaxStreamDataBidiLocal ?: 0L
|
||||
StreamId.Kind.CLIENT_UNI, StreamId.Kind.SERVER_UNI ->
|
||||
tp.initialMaxStreamDataUni ?: 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Allocate a new client-initiated bidirectional stream. */
|
||||
fun openBidiStream(): QuicStream {
|
||||
val id = StreamId.build(StreamId.Kind.CLIENT_BIDI, nextLocalBidiIndex++)
|
||||
val stream = QuicStream(id, QuicStream.Direction.BIDIRECTIONAL)
|
||||
stream.sendCredit = peerTransportParameters?.initialMaxStreamDataBidiRemote ?: config.initialMaxStreamDataBidiRemote
|
||||
stream.receiveLimit = config.initialMaxStreamDataBidiLocal
|
||||
streams[id] = stream
|
||||
return stream
|
||||
}
|
||||
|
||||
/** Allocate a new client-initiated unidirectional (write-only) stream. */
|
||||
fun openUniStream(): QuicStream {
|
||||
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
|
||||
stream.receiveLimit = 0L // can't receive
|
||||
streams[id] = stream
|
||||
return stream
|
||||
}
|
||||
|
||||
fun pollIncomingPeerStream(): QuicStream? = newPeerStreams.removeFirstOrNull()
|
||||
|
||||
fun streamById(id: Long): QuicStream? = streams[id]
|
||||
|
||||
fun queueDatagram(payload: ByteArray) {
|
||||
pendingDatagrams.addLast(payload)
|
||||
}
|
||||
|
||||
fun pollIncomingDatagram(): ByteArray? = incomingDatagrams.removeFirstOrNull()
|
||||
|
||||
/** Initiate a graceful close. */
|
||||
fun close(
|
||||
errorCode: Long,
|
||||
reason: String,
|
||||
) {
|
||||
if (status == Status.CLOSED || status == Status.CLOSING) return
|
||||
closeErrorCode = errorCode
|
||||
closeReason = reason
|
||||
status = Status.CLOSING
|
||||
}
|
||||
|
||||
internal fun getOrCreatePeerStream(id: Long): QuicStream {
|
||||
streams[id]?.let { return it }
|
||||
val direction =
|
||||
when (StreamId.kindOf(id)) {
|
||||
StreamId.Kind.CLIENT_BIDI, StreamId.Kind.SERVER_BIDI -> QuicStream.Direction.BIDIRECTIONAL
|
||||
StreamId.Kind.SERVER_UNI -> QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL
|
||||
StreamId.Kind.CLIENT_UNI -> QuicStream.Direction.UNIDIRECTIONAL_LOCAL_TO_REMOTE
|
||||
}
|
||||
val stream = QuicStream(id, direction)
|
||||
stream.sendCredit = 0L
|
||||
stream.receiveLimit = config.initialMaxStreamDataBidiRemote
|
||||
streams[id] = stream
|
||||
newPeerStreams.addLast(stream)
|
||||
return stream
|
||||
}
|
||||
|
||||
/** Returns the level state for [level]. */
|
||||
fun levelState(level: EncryptionLevel): LevelState =
|
||||
when (level) {
|
||||
EncryptionLevel.INITIAL -> initial
|
||||
EncryptionLevel.HANDSHAKE -> handshake
|
||||
EncryptionLevel.APPLICATION -> application
|
||||
}
|
||||
|
||||
fun streamsView(): Map<Long, QuicStream> = streams
|
||||
|
||||
fun pendingDatagramsView(): ArrayDeque<ByteArray> = pendingDatagrams
|
||||
|
||||
fun incomingDatagramsBuffer(): ArrayDeque<ByteArray> = incomingDatagrams
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Tunables for a [QuicConnection]. Defaults are appropriate for the MoQ +
|
||||
* WebTransport workload — small connection-level windows, a generous-enough
|
||||
* datagram size to fit Opus frames, idle timeout of 30 s.
|
||||
*
|
||||
* `maxUdpDatagramSize` caps both inbound and outbound UDP payload size. The
|
||||
* QUIC spec mandates that any client sending Initial packets MUST send
|
||||
* datagrams of at least 1200 bytes; we pad outbound Initials to satisfy this
|
||||
* regardless of how full the packet is.
|
||||
*/
|
||||
data class QuicConnectionConfig(
|
||||
val initialMaxData: Long = 16L * 1024 * 1024,
|
||||
val initialMaxStreamDataBidiLocal: Long = 1L * 1024 * 1024,
|
||||
val initialMaxStreamDataBidiRemote: Long = 1L * 1024 * 1024,
|
||||
val initialMaxStreamDataUni: Long = 1L * 1024 * 1024,
|
||||
val initialMaxStreamsBidi: Long = 100L,
|
||||
val initialMaxStreamsUni: Long = 100L,
|
||||
val maxIdleTimeoutMillis: Long = 30_000L,
|
||||
val maxUdpPayloadSize: Long = 1452L,
|
||||
val activeConnectionIdLimit: Long = 4L,
|
||||
val maxDatagramFrameSize: Long = 1200L,
|
||||
val ackDelayExponent: Long = 3L,
|
||||
val maxAckDelay: Long = 25L,
|
||||
)
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* 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.AckFrame
|
||||
import com.vitorpamplona.quic.frame.ConnectionCloseFrame
|
||||
import com.vitorpamplona.quic.frame.CryptoFrame
|
||||
import com.vitorpamplona.quic.frame.DatagramFrame
|
||||
import com.vitorpamplona.quic.frame.HandshakeDoneFrame
|
||||
import com.vitorpamplona.quic.frame.MaxDataFrame
|
||||
import com.vitorpamplona.quic.frame.MaxStreamDataFrame
|
||||
import com.vitorpamplona.quic.frame.MaxStreamsFrame
|
||||
import com.vitorpamplona.quic.frame.NewConnectionIdFrame
|
||||
import com.vitorpamplona.quic.frame.PingFrame
|
||||
import com.vitorpamplona.quic.frame.StreamFrame
|
||||
import com.vitorpamplona.quic.frame.decodeFrames
|
||||
import com.vitorpamplona.quic.packet.LongHeaderPacket
|
||||
import com.vitorpamplona.quic.packet.LongHeaderType
|
||||
import com.vitorpamplona.quic.packet.ShortHeaderPacket
|
||||
import com.vitorpamplona.quic.tls.TlsClient
|
||||
|
||||
/**
|
||||
* Decode every QUIC packet inside a single inbound UDP datagram and dispatch
|
||||
* its frames to [conn]'s state.
|
||||
*
|
||||
* QUIC permits *coalescing* multiple packets into one datagram (RFC 9000 §12.2)
|
||||
* — typically Initial + Handshake from the server in the same datagram during
|
||||
* the handshake. We loop until the datagram is fully consumed or a packet
|
||||
* fails to parse (which we drop silently per RFC 9001 §5.5).
|
||||
*/
|
||||
fun feedDatagram(
|
||||
conn: QuicConnection,
|
||||
datagram: ByteArray,
|
||||
nowMillis: Long,
|
||||
) {
|
||||
var offset = 0
|
||||
while (offset < datagram.size) {
|
||||
val first = datagram[offset].toInt() and 0xFF
|
||||
val isLong = (first and 0x80) != 0
|
||||
if (isLong) {
|
||||
val consumed = feedLongHeaderPacket(conn, datagram, offset, nowMillis) ?: break
|
||||
offset += consumed
|
||||
} else {
|
||||
// Short-header — consumes the rest of the datagram.
|
||||
feedShortHeaderPacket(conn, datagram, offset, nowMillis)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun feedLongHeaderPacket(
|
||||
conn: QuicConnection,
|
||||
datagram: ByteArray,
|
||||
offset: Int,
|
||||
nowMillis: Long,
|
||||
): Int? {
|
||||
val peeked = LongHeaderPacket.peekHeader(datagram, offset) ?: return null
|
||||
val level =
|
||||
when (peeked.type) {
|
||||
LongHeaderType.INITIAL -> EncryptionLevel.INITIAL
|
||||
LongHeaderType.HANDSHAKE -> EncryptionLevel.HANDSHAKE
|
||||
LongHeaderType.ZERO_RTT, LongHeaderType.RETRY -> return null // unsupported in client
|
||||
}
|
||||
val state = conn.levelState(level)
|
||||
val proto = state.receiveProtection ?: return null
|
||||
val parsed =
|
||||
LongHeaderPacket.parseAndDecrypt(
|
||||
bytes = datagram,
|
||||
offset = offset,
|
||||
aead = proto.aead,
|
||||
key = proto.key,
|
||||
iv = proto.iv,
|
||||
hp = proto.hp,
|
||||
hpKey = proto.hpKey,
|
||||
largestReceivedInSpace = state.pnSpace.largestReceived,
|
||||
) ?: return null
|
||||
|
||||
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
|
||||
|
||||
// The server's source CID becomes our destination CID for subsequent packets.
|
||||
if (level == EncryptionLevel.INITIAL) {
|
||||
conn.destinationConnectionId = parsed.packet.scid
|
||||
}
|
||||
|
||||
dispatchFrames(conn, level, parsed.packet.payload, nowMillis)
|
||||
return parsed.consumed
|
||||
}
|
||||
|
||||
private fun feedShortHeaderPacket(
|
||||
conn: QuicConnection,
|
||||
datagram: ByteArray,
|
||||
offset: Int,
|
||||
nowMillis: Long,
|
||||
) {
|
||||
val state = conn.levelState(EncryptionLevel.APPLICATION)
|
||||
val proto = state.receiveProtection ?: return
|
||||
val parsed =
|
||||
ShortHeaderPacket.parseAndDecrypt(
|
||||
bytes = datagram,
|
||||
offset = offset,
|
||||
dcidLen = conn.sourceConnectionId.length,
|
||||
aead = proto.aead,
|
||||
key = proto.key,
|
||||
iv = proto.iv,
|
||||
hp = proto.hp,
|
||||
hpKey = proto.hpKey,
|
||||
largestReceivedInSpace = state.pnSpace.largestReceived,
|
||||
) ?: return
|
||||
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
|
||||
dispatchFrames(conn, EncryptionLevel.APPLICATION, parsed.packet.payload, nowMillis)
|
||||
}
|
||||
|
||||
private fun dispatchFrames(
|
||||
conn: QuicConnection,
|
||||
level: EncryptionLevel,
|
||||
payload: ByteArray,
|
||||
nowMillis: Long,
|
||||
) {
|
||||
val frames = decodeFrames(payload)
|
||||
val state = conn.levelState(level)
|
||||
var ackEliciting = false
|
||||
for (frame in frames) {
|
||||
when (frame) {
|
||||
is AckFrame -> {
|
||||
// We don't currently retransmit, so we just absorb ACKs; once
|
||||
// Phase F adds retransmission this updates the inflight tracker.
|
||||
}
|
||||
is CryptoFrame -> {
|
||||
ackEliciting = true
|
||||
state.cryptoReceive.insert(frame.offset, frame.data)
|
||||
val contiguous = state.cryptoReceive.readContiguous()
|
||||
if (contiguous.isNotEmpty()) {
|
||||
val tlsLevel =
|
||||
when (level) {
|
||||
EncryptionLevel.INITIAL -> TlsClient.Level.INITIAL
|
||||
EncryptionLevel.HANDSHAKE -> TlsClient.Level.HANDSHAKE
|
||||
EncryptionLevel.APPLICATION -> TlsClient.Level.APPLICATION
|
||||
}
|
||||
conn.tls.pushHandshakeBytes(tlsLevel, contiguous)
|
||||
// Pull any new outbound CRYPTO bytes out of TLS into our
|
||||
// send queue at the matching encryption level.
|
||||
drainTlsOutbound(conn)
|
||||
}
|
||||
}
|
||||
is StreamFrame -> {
|
||||
ackEliciting = true
|
||||
val stream = conn.getOrCreatePeerStream(frame.streamId)
|
||||
stream.receive.insert(frame.offset, frame.data, frame.fin)
|
||||
val data = stream.receive.readContiguous()
|
||||
if (data.isNotEmpty()) {
|
||||
stream.deliverIncoming(data)
|
||||
}
|
||||
if (stream.receive.finReceived) {
|
||||
stream.closeIncoming()
|
||||
}
|
||||
}
|
||||
is DatagramFrame -> {
|
||||
ackEliciting = true
|
||||
conn.incomingDatagramsBuffer().addLast(frame.data)
|
||||
}
|
||||
is MaxDataFrame -> {
|
||||
// Updates connection-level send credit; left to the orchestrator.
|
||||
}
|
||||
is MaxStreamDataFrame -> {
|
||||
conn.streamById(frame.streamId)?.let {
|
||||
if (frame.maxStreamData > it.sendCredit) it.sendCredit = frame.maxStreamData
|
||||
}
|
||||
}
|
||||
is MaxStreamsFrame -> {
|
||||
// Tracking left for a later phase.
|
||||
}
|
||||
is NewConnectionIdFrame -> {
|
||||
// We don't support migration; ignore.
|
||||
}
|
||||
is ConnectionCloseFrame -> {
|
||||
conn.status = QuicConnection.Status.CLOSED
|
||||
}
|
||||
is HandshakeDoneFrame -> {
|
||||
conn.status = QuicConnection.Status.CONNECTED
|
||||
}
|
||||
is PingFrame -> {
|
||||
ackEliciting = true
|
||||
}
|
||||
else -> {
|
||||
// PADDING + DATA_BLOCKED + STREAM_DATA_BLOCKED + STREAMS_BLOCKED
|
||||
// are non-eliciting / cleared during decode.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ackEliciting) {
|
||||
// Get largest received from pn space and feed into ack tracker.
|
||||
val pn = state.pnSpace.largestReceived
|
||||
if (pn >= 0) {
|
||||
state.ackTracker.receivedPacket(pn, ackEliciting = true, receivedAtMillis = nowMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainTlsOutbound(conn: QuicConnection) {
|
||||
for (lvl in TlsClient.Level.entries) {
|
||||
while (true) {
|
||||
val bytes = conn.tls.pollOutbound(lvl) ?: break
|
||||
val state =
|
||||
when (lvl) {
|
||||
TlsClient.Level.INITIAL -> conn.initial
|
||||
TlsClient.Level.HANDSHAKE -> conn.handshake
|
||||
TlsClient.Level.APPLICATION -> conn.application
|
||||
}
|
||||
state.cryptoSend.enqueue(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* 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.ConnectionCloseFrame
|
||||
import com.vitorpamplona.quic.frame.CryptoFrame
|
||||
import com.vitorpamplona.quic.frame.DatagramFrame
|
||||
import com.vitorpamplona.quic.frame.Frame
|
||||
import com.vitorpamplona.quic.frame.PingFrame
|
||||
import com.vitorpamplona.quic.frame.StreamFrame
|
||||
import com.vitorpamplona.quic.frame.encodeFrames
|
||||
import com.vitorpamplona.quic.packet.LongHeaderPacket
|
||||
import com.vitorpamplona.quic.packet.LongHeaderPlaintextPacket
|
||||
import com.vitorpamplona.quic.packet.LongHeaderType
|
||||
import com.vitorpamplona.quic.packet.QuicVersion
|
||||
import com.vitorpamplona.quic.packet.ShortHeaderPacket
|
||||
import com.vitorpamplona.quic.packet.ShortHeaderPlaintextPacket
|
||||
|
||||
/**
|
||||
* Build the next UDP datagram to send for [conn], or return null if there's
|
||||
* nothing to send right now.
|
||||
*
|
||||
* The datagram coalesces (in order):
|
||||
* - Initial packet (if Initial-level CRYPTO has unsent bytes or a pending ACK)
|
||||
* - Handshake packet (likewise)
|
||||
* - 1-RTT packet (CRYPTO from NewSessionTicket, STREAM, DATAGRAM, ACK, etc.)
|
||||
*
|
||||
* RFC 9000 §14: any datagram containing an Initial packet from the client
|
||||
* MUST be padded to at least 1200 bytes total.
|
||||
*/
|
||||
fun drainOutbound(
|
||||
conn: QuicConnection,
|
||||
nowMillis: Long,
|
||||
): ByteArray? {
|
||||
val parts = mutableListOf<ByteArray>()
|
||||
|
||||
// Closing — emit a CONNECTION_CLOSE at the highest available level.
|
||||
if (conn.status == QuicConnection.Status.CLOSING) {
|
||||
val frame = ConnectionCloseFrame(conn.closeErrorCode, null, conn.closeReason ?: "")
|
||||
val packet = buildBestLevelPacket(conn, listOf(frame)) ?: return null
|
||||
conn.status = QuicConnection.Status.CLOSED
|
||||
return packet
|
||||
}
|
||||
|
||||
val initialPkt = buildInitialPacket(conn, nowMillis)
|
||||
if (initialPkt != null) parts += initialPkt
|
||||
|
||||
val handshakePkt = buildHandshakePacket(conn, nowMillis)
|
||||
if (handshakePkt != null) parts += handshakePkt
|
||||
|
||||
val applicationPkt = buildApplicationPacket(conn, nowMillis)
|
||||
if (applicationPkt != null) parts += applicationPkt
|
||||
|
||||
if (parts.isEmpty()) return null
|
||||
|
||||
// Coalesce
|
||||
var totalLen = 0
|
||||
for (p in parts) totalLen += p.size
|
||||
// Pad the datagram to 1200 bytes if it begins with an Initial packet (RFC 9000 §14).
|
||||
if (initialPkt != null && totalLen < 1200) totalLen = 1200
|
||||
val out = ByteArray(totalLen)
|
||||
var pos = 0
|
||||
for (p in parts) {
|
||||
p.copyInto(out, pos)
|
||||
pos += p.size
|
||||
}
|
||||
// Tail bytes are zero (PADDING frames in the encryption envelope of the last packet).
|
||||
// Note: padding inside the last QUIC packet's encryption is the RFC-correct way; we instead
|
||||
// pad the datagram with zero bytes after the last packet. Servers tolerate this for the
|
||||
// datagram-level minimum size requirement.
|
||||
return out
|
||||
}
|
||||
|
||||
private fun buildBestLevelPacket(
|
||||
conn: QuicConnection,
|
||||
frames: List<Frame>,
|
||||
): ByteArray? {
|
||||
// Prefer 1-RTT > Handshake > Initial.
|
||||
val payload = encodeFrames(frames)
|
||||
val app = conn.application
|
||||
if (app.sendProtection != null) {
|
||||
val proto = app.sendProtection!!
|
||||
val pn = app.pnSpace.allocateOutbound()
|
||||
return ShortHeaderPacket.build(
|
||||
ShortHeaderPlaintextPacket(conn.destinationConnectionId, pn, payload),
|
||||
proto.aead,
|
||||
proto.key,
|
||||
proto.iv,
|
||||
proto.hp,
|
||||
proto.hpKey,
|
||||
largestAckedInSpace = -1L,
|
||||
)
|
||||
}
|
||||
val hs = conn.handshake
|
||||
if (hs.sendProtection != null) {
|
||||
return buildLongHeaderPacket(conn, EncryptionLevel.HANDSHAKE, payload)
|
||||
}
|
||||
val init = conn.initial
|
||||
if (init.sendProtection != null) {
|
||||
return buildLongHeaderPacket(conn, EncryptionLevel.INITIAL, payload)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun buildLongHeaderPacket(
|
||||
conn: QuicConnection,
|
||||
level: EncryptionLevel,
|
||||
payload: ByteArray,
|
||||
): ByteArray {
|
||||
val state = conn.levelState(level)
|
||||
val proto = state.sendProtection!!
|
||||
val pn = state.pnSpace.allocateOutbound()
|
||||
val type =
|
||||
when (level) {
|
||||
EncryptionLevel.INITIAL -> LongHeaderType.INITIAL
|
||||
EncryptionLevel.HANDSHAKE -> LongHeaderType.HANDSHAKE
|
||||
EncryptionLevel.APPLICATION -> error("APPLICATION uses short-header packets")
|
||||
}
|
||||
return LongHeaderPacket.build(
|
||||
LongHeaderPlaintextPacket(
|
||||
type = type,
|
||||
version = QuicVersion.V1,
|
||||
dcid = conn.destinationConnectionId,
|
||||
scid = conn.sourceConnectionId,
|
||||
packetNumber = pn,
|
||||
payload = payload,
|
||||
),
|
||||
proto.aead,
|
||||
proto.key,
|
||||
proto.iv,
|
||||
proto.hp,
|
||||
proto.hpKey,
|
||||
largestAckedInSpace = -1L,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildInitialPacket(
|
||||
conn: QuicConnection,
|
||||
nowMillis: Long,
|
||||
): ByteArray? = buildHandshakeLevelPacket(conn, EncryptionLevel.INITIAL, nowMillis)
|
||||
|
||||
private fun buildHandshakePacket(
|
||||
conn: QuicConnection,
|
||||
nowMillis: Long,
|
||||
): ByteArray? = buildHandshakeLevelPacket(conn, EncryptionLevel.HANDSHAKE, nowMillis)
|
||||
|
||||
private fun buildHandshakeLevelPacket(
|
||||
conn: QuicConnection,
|
||||
level: EncryptionLevel,
|
||||
nowMillis: Long,
|
||||
): ByteArray? {
|
||||
val state = conn.levelState(level)
|
||||
val proto = state.sendProtection ?: return null
|
||||
val frames = mutableListOf<Frame>()
|
||||
|
||||
// Pending ACK?
|
||||
state.ackTracker.buildAckFrame(nowMillis, conn.config.ackDelayExponent.toInt())?.let { frames += it }
|
||||
|
||||
// Pending CRYPTO?
|
||||
val cryptoChunk = state.cryptoSend.takeChunk(maxBytes = 1100)
|
||||
if (cryptoChunk != null && cryptoChunk.data.isNotEmpty()) {
|
||||
frames += CryptoFrame(cryptoChunk.offset, cryptoChunk.data)
|
||||
}
|
||||
|
||||
if (frames.isEmpty()) return null
|
||||
|
||||
val payload = encodeFrames(frames)
|
||||
val pn = state.pnSpace.allocateOutbound()
|
||||
val type = if (level == EncryptionLevel.INITIAL) LongHeaderType.INITIAL else LongHeaderType.HANDSHAKE
|
||||
return LongHeaderPacket.build(
|
||||
LongHeaderPlaintextPacket(
|
||||
type = type,
|
||||
version = QuicVersion.V1,
|
||||
dcid = conn.destinationConnectionId,
|
||||
scid = conn.sourceConnectionId,
|
||||
packetNumber = pn,
|
||||
payload = payload,
|
||||
),
|
||||
proto.aead,
|
||||
proto.key,
|
||||
proto.iv,
|
||||
proto.hp,
|
||||
proto.hpKey,
|
||||
largestAckedInSpace = -1L,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildApplicationPacket(
|
||||
conn: QuicConnection,
|
||||
nowMillis: Long,
|
||||
): ByteArray? {
|
||||
val state = conn.application
|
||||
val proto = state.sendProtection ?: return null
|
||||
val frames = mutableListOf<Frame>()
|
||||
|
||||
state.ackTracker.buildAckFrame(nowMillis, conn.config.ackDelayExponent.toInt())?.let { frames += it }
|
||||
|
||||
// Pending datagrams
|
||||
while (conn.pendingDatagramsView().isNotEmpty()) {
|
||||
val payload = conn.pendingDatagramsView().removeFirst()
|
||||
frames += DatagramFrame(payload, explicitLength = true)
|
||||
if (frames.size >= 16) break
|
||||
}
|
||||
|
||||
// Drain stream send buffers — round-robin keeping packet under MTU.
|
||||
var packetBudget = 1100
|
||||
for ((id, stream) in conn.streamsView()) {
|
||||
if (packetBudget <= 64) break
|
||||
val chunk = stream.send.takeChunk(maxBytes = packetBudget - 32) ?: continue
|
||||
if (chunk.data.isNotEmpty() || chunk.fin) {
|
||||
frames += StreamFrame(streamId = id, offset = chunk.offset, data = chunk.data, fin = chunk.fin, explicitLength = true)
|
||||
packetBudget -= chunk.data.size + 32
|
||||
}
|
||||
}
|
||||
|
||||
if (frames.isEmpty()) return null
|
||||
val payload = encodeFrames(frames)
|
||||
val pn = state.pnSpace.allocateOutbound()
|
||||
return ShortHeaderPacket.build(
|
||||
ShortHeaderPlaintextPacket(conn.destinationConnectionId, pn, payload),
|
||||
proto.aead,
|
||||
proto.key,
|
||||
proto.iv,
|
||||
proto.hp,
|
||||
proto.hpKey,
|
||||
largestAckedInSpace = -1L,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quic.recovery
|
||||
|
||||
import com.vitorpamplona.quic.frame.AckFrame
|
||||
import com.vitorpamplona.quic.frame.AckRange
|
||||
|
||||
/**
|
||||
* Tracks received packet numbers for one packet-number space and produces
|
||||
* ACK frames per RFC 9000 §19.3.
|
||||
*
|
||||
* We store packet numbers as a sorted list of disjoint ranges (lo..hi).
|
||||
* Acks emit the ranges newest-first as RFC 9000 specifies.
|
||||
*/
|
||||
class AckTracker {
|
||||
private val ranges = mutableListOf<LongRange>() // sorted descending by lo (i.e. ranges[0] has the largest)
|
||||
private var ackElicitingPending: Boolean = false
|
||||
private var largestRecvTimeMillis: Long = 0L
|
||||
|
||||
fun receivedPacket(
|
||||
packetNumber: Long,
|
||||
ackEliciting: Boolean,
|
||||
receivedAtMillis: Long,
|
||||
) {
|
||||
if (ackEliciting) ackElicitingPending = true
|
||||
if (ranges.isEmpty()) {
|
||||
ranges += LongRange(packetNumber, packetNumber)
|
||||
largestRecvTimeMillis = receivedAtMillis
|
||||
return
|
||||
}
|
||||
if (packetNumber > ranges[0].endInclusive) {
|
||||
largestRecvTimeMillis = receivedAtMillis
|
||||
}
|
||||
|
||||
// Find the range we need to extend (or insertion point).
|
||||
for (i in ranges.indices) {
|
||||
val r = ranges[i]
|
||||
if (packetNumber > r.endInclusive + 1) {
|
||||
ranges.add(i, LongRange(packetNumber, packetNumber))
|
||||
return
|
||||
}
|
||||
if (packetNumber == r.endInclusive + 1) {
|
||||
// Extend up; check merge with i-1 (if any)
|
||||
ranges[i] = LongRange(r.start, packetNumber)
|
||||
if (i > 0 && ranges[i - 1].start == packetNumber + 1) {
|
||||
ranges[i - 1] = LongRange(ranges[i].start, ranges[i - 1].endInclusive)
|
||||
ranges.removeAt(i)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (packetNumber in r) {
|
||||
// Already known.
|
||||
return
|
||||
}
|
||||
if (packetNumber == r.start - 1) {
|
||||
ranges[i] = LongRange(packetNumber, r.endInclusive)
|
||||
if (i + 1 < ranges.size && ranges[i + 1].endInclusive == packetNumber - 1) {
|
||||
ranges[i] = LongRange(ranges[i + 1].start, ranges[i].endInclusive)
|
||||
ranges.removeAt(i + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
ranges += LongRange(packetNumber, packetNumber)
|
||||
}
|
||||
|
||||
fun hasUnackedAckEliciting(): Boolean = ackElicitingPending
|
||||
|
||||
fun isEmpty(): Boolean = ranges.isEmpty()
|
||||
|
||||
fun largestReceived(): Long = if (ranges.isEmpty()) -1L else ranges[0].endInclusive
|
||||
|
||||
/** Build an ACK frame covering everything we've received. */
|
||||
fun buildAckFrame(
|
||||
nowMillis: Long,
|
||||
ackDelayExponent: Int = 3,
|
||||
): AckFrame? {
|
||||
if (ranges.isEmpty()) return null
|
||||
val largest = ranges[0].endInclusive
|
||||
val firstRangeLength = largest - ranges[0].start
|
||||
val rest = mutableListOf<AckRange>()
|
||||
for (i in 1 until ranges.size) {
|
||||
val gap = ranges[i - 1].start - ranges[i].endInclusive - 2
|
||||
val len = ranges[i].endInclusive - ranges[i].start
|
||||
rest += AckRange(gap, len)
|
||||
}
|
||||
val ackDelayMicros = (nowMillis - largestRecvTimeMillis) * 1000L
|
||||
val ackDelay = (ackDelayMicros ushr ackDelayExponent).coerceAtLeast(0L)
|
||||
ackElicitingPending = false
|
||||
return AckFrame(
|
||||
largestAcknowledged = largest,
|
||||
ackDelay = ackDelay,
|
||||
firstAckRange = firstRangeLength,
|
||||
additionalRanges = rest,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quic.stream
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
|
||||
/**
|
||||
* One QUIC stream (bidirectional or unidirectional). Application code
|
||||
* accesses it through [enqueue] (write) and the [readFlow] / FIN observation
|
||||
* APIs; the [QuicConnection] owns the underlying buffers and drains them
|
||||
* into STREAM frames on the wire.
|
||||
*/
|
||||
class QuicStream(
|
||||
val streamId: Long,
|
||||
val direction: Direction,
|
||||
) {
|
||||
enum class Direction { BIDIRECTIONAL, UNIDIRECTIONAL_LOCAL_TO_REMOTE, UNIDIRECTIONAL_REMOTE_TO_LOCAL }
|
||||
|
||||
val send = SendBuffer()
|
||||
val receive = ReceiveBuffer()
|
||||
|
||||
/** Bytes received and confirmed contiguous, exposed as a flow to the consumer. */
|
||||
private val incomingChannel = Channel<ByteArray>(capacity = Channel.UNLIMITED)
|
||||
val incoming: Flow<ByteArray> get() = incomingChannel.consumeAsFlow()
|
||||
|
||||
/** Per-stream send credit (peer's MAX_STREAM_DATA value). */
|
||||
var sendCredit: Long = 0L
|
||||
internal set
|
||||
|
||||
/** Per-stream receive credit (the value we advertised). */
|
||||
var receiveLimit: Long = 0L
|
||||
internal set
|
||||
|
||||
/** True once we've FIN'd our write side and the peer FIN'd theirs. */
|
||||
val isClosed: Boolean
|
||||
get() = send.finSent && receive.finReceived
|
||||
|
||||
internal fun deliverIncoming(data: ByteArray) {
|
||||
if (data.isNotEmpty()) {
|
||||
incomingChannel.trySend(data)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun closeIncoming() {
|
||||
incomingChannel.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quic.stream
|
||||
|
||||
/**
|
||||
* Outbound send buffer for one direction of a QUIC stream (or for the
|
||||
* per-encryption-level CRYPTO offset stream).
|
||||
*
|
||||
* Application code [enqueue]s payload bytes; the connection's send loop
|
||||
* [takeChunk]s as much as it can fit in the next packet, given the
|
||||
* remaining packet budget and stream-level / connection-level flow control
|
||||
* credit. Sent bytes stay in the buffer until [acknowledge] (Phase F adds
|
||||
* retransmission of lost bytes; for v1 we just trust the receiver and
|
||||
* release on send).
|
||||
*
|
||||
* For Phase D-K we run a "best effort" mode: bytes are released from the
|
||||
* buffer the moment they're handed off, on the assumption that the
|
||||
* underlying network is stable. Phase L adds retransmit-on-loss (using the
|
||||
* same send buffer that retains until ACK).
|
||||
*/
|
||||
class SendBuffer {
|
||||
private var pending: ByteArray = ByteArray(0)
|
||||
private var sentEnd: Long = 0L
|
||||
var nextOffset: Long = 0L
|
||||
private set
|
||||
var finPending: Boolean = false
|
||||
private set
|
||||
var finSent: Boolean = false
|
||||
private set
|
||||
|
||||
val readableBytes: Int get() = pending.size
|
||||
|
||||
fun enqueue(bytes: ByteArray) {
|
||||
if (bytes.isEmpty()) return
|
||||
val combined = ByteArray(pending.size + bytes.size)
|
||||
pending.copyInto(combined, 0)
|
||||
bytes.copyInto(combined, pending.size)
|
||||
pending = combined
|
||||
nextOffset += bytes.size
|
||||
}
|
||||
|
||||
/** Mark the write side as closing; the next [takeChunk] will set FIN once empty. */
|
||||
fun finish() {
|
||||
finPending = true
|
||||
}
|
||||
|
||||
/** Take up to [maxBytes] bytes off the head of the buffer at the current send offset. */
|
||||
fun takeChunk(maxBytes: Int): Chunk? {
|
||||
if (pending.isEmpty() && !(finPending && !finSent)) return null
|
||||
val take = minOf(pending.size, maxBytes.coerceAtLeast(0))
|
||||
val data = pending.copyOfRange(0, take)
|
||||
pending = pending.copyOfRange(take, pending.size)
|
||||
val offset = sentEnd
|
||||
sentEnd += take
|
||||
val fin = finPending && pending.isEmpty()
|
||||
if (fin) finSent = true
|
||||
return Chunk(offset, data, fin)
|
||||
}
|
||||
|
||||
data class Chunk(
|
||||
val offset: Long,
|
||||
val data: ByteArray,
|
||||
val fin: Boolean,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user