Merge pull request #2806 from vitorpamplona/claude/quic-followups-round10-13
QUIC: audit closeout — TLS PSK fallback, AEAD allocation, PSL subset, ECN
This commit is contained in:
@@ -287,7 +287,7 @@ class QuicReader(
|
||||
}
|
||||
}
|
||||
|
||||
open class QuicCodecException(
|
||||
class QuicCodecException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
) : RuntimeException(message, cause)
|
||||
|
||||
+12
-3
@@ -73,9 +73,18 @@ fun packetProtectionFromSecret(
|
||||
// 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")
|
||||
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 -> {
|
||||
bestAes128GcmAead(keys.key)
|
||||
}
|
||||
|
||||
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256 -> {
|
||||
com.vitorpamplona.quic.crypto
|
||||
.bestChaCha20Poly1305Aead(keys.key)
|
||||
}
|
||||
|
||||
else -> {
|
||||
error("unreachable")
|
||||
}
|
||||
}
|
||||
return PacketProtection(aead, keys.key, keys.iv, hp, keys.hp)
|
||||
}
|
||||
|
||||
@@ -253,6 +253,18 @@ class QuicConnection(
|
||||
@Volatile
|
||||
internal var previousReceiveProtection: PacketProtection? = null
|
||||
|
||||
/**
|
||||
* RFC 9001 §6.4: an endpoint MUST NOT initiate a subsequent key update
|
||||
* unless the previous one has been confirmed by the peer. We treat
|
||||
* "confirmed" as having received any application-level packet whose
|
||||
* decrypt succeeded under the post-rotation (current) live keys —
|
||||
* meaning the peer has rolled forward in lockstep. Set true by
|
||||
* [initiateKeyUpdate], cleared by [QuicConnectionParser] on the
|
||||
* first short-header packet that AEAD-passes after the rotation.
|
||||
*/
|
||||
@Volatile
|
||||
internal var keyUpdateInProgress: Boolean = false
|
||||
|
||||
/**
|
||||
* RFC 9001 §4.10 — 0-RTT send-side packet protection. Installed when
|
||||
* the TLS layer derives `client_early_traffic_secret` (right after
|
||||
@@ -572,6 +584,22 @@ class QuicConnection(
|
||||
com.vitorpamplona.quic.connection.recovery
|
||||
.QuicLossDetection()
|
||||
|
||||
/**
|
||||
* Reusable scratch lists for [QuicConnectionWriter.buildApplicationPacket].
|
||||
* The function clears them at entry, fills them, encodes the
|
||||
* packet, snapshots the [scratchAppTokens] via `.toList()` for
|
||||
* the [SentPacket] record, and returns. The next drain (single-
|
||||
* threaded by [streamsLock]) then re-clears and re-uses.
|
||||
*
|
||||
* Skipped for [collectHandshakeLevelFrames]: that function returns
|
||||
* a [com.vitorpamplona.quic.connection.HandshakeLevelContents]
|
||||
* wrapping the lists, which the caller holds across two
|
||||
* `buildLongHeaderFromFrames` calls (natural-size + padded
|
||||
* rebuild). Reusing those would corrupt the rebuild path.
|
||||
*/
|
||||
internal val scratchAppFrames: MutableList<com.vitorpamplona.quic.frame.Frame> = mutableListOf()
|
||||
internal val scratchAppTokens: MutableList<com.vitorpamplona.quic.connection.recovery.RecoveryToken> = mutableListOf()
|
||||
|
||||
/**
|
||||
* RFC 9002 §6.2 Probe Timeout signalling. When the driver loop's
|
||||
* PTO timer fires (no ack-eliciting packet has been ACK'd in
|
||||
@@ -1845,12 +1873,14 @@ class QuicConnection(
|
||||
* outbound packet carries `KEY_PHASE = 1` and the peer is expected
|
||||
* to mirror back in the same phase.
|
||||
*
|
||||
* RFC 9001 §6.5 says an endpoint MUST NOT initiate a key update
|
||||
* before the handshake is confirmed (HANDSHAKE_DONE received). The
|
||||
* caller is responsible for that check; this method just performs
|
||||
* the rotation. §6.4 also forbids initiating a second update before
|
||||
* the current one has been confirmed (peer responds in matching
|
||||
* phase) — same caller contract.
|
||||
* Spec invariants enforced here (caller no longer responsible):
|
||||
* - RFC 9001 §6.5: returns false if [handshakeComplete] is not
|
||||
* yet true. Initiating before handshake confirmation is a
|
||||
* PROTOCOL_VIOLATION.
|
||||
* - RFC 9001 §6.4: returns false if a previous rotation is still
|
||||
* in flight ([keyUpdateInProgress]). The parser clears that
|
||||
* flag on the first inbound packet that AEAD-decrypts under
|
||||
* the post-rotation keys, signalling the peer rolled forward.
|
||||
*
|
||||
* Header-protection key is unchanged (RFC 9001 §6.1: HP key is NOT
|
||||
* rotated when keys are updated).
|
||||
@@ -1864,6 +1894,16 @@ class QuicConnection(
|
||||
* and server".
|
||||
*/
|
||||
fun initiateKeyUpdate(): Boolean {
|
||||
// RFC 9001 §6.5: handshake MUST be confirmed before initiating
|
||||
// a key update. We use [handshakeComplete] as the proxy —
|
||||
// application keys are installed and the peer's HANDSHAKE_DONE
|
||||
// has been processed.
|
||||
if (!handshakeComplete) return false
|
||||
// RFC 9001 §6.4: MUST NOT initiate a subsequent rotation until
|
||||
// the previous one is confirmed. The parser clears this flag
|
||||
// when it observes an inbound packet that AEAD-decrypts under
|
||||
// the post-rotation live keys.
|
||||
if (keyUpdateInProgress) return false
|
||||
val cs = appCipherSuite.takeIf { it != 0 } ?: return false
|
||||
val curRx = appReceiveSecret ?: return false
|
||||
val curTx = appSendSecret ?: return false
|
||||
@@ -1907,6 +1947,7 @@ class QuicConnection(
|
||||
appSendSecret = nextTxSecret
|
||||
currentReceiveKeyPhase = !currentReceiveKeyPhase
|
||||
currentSendKeyPhase = !currentSendKeyPhase
|
||||
keyUpdateInProgress = true
|
||||
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
|
||||
qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION)
|
||||
return true
|
||||
|
||||
+25
-3
@@ -67,6 +67,10 @@ import com.vitorpamplona.quic.tls.TlsClient
|
||||
* under streamsLock so frame-dispatch / stream creation / level state
|
||||
* remains a single critical section.
|
||||
*/
|
||||
|
||||
/** RFC 9000 §16: maximum varint value, also the per-stream offset ceiling. */
|
||||
private const val MAX_QUIC_OFFSET: Long = (1L shl 62) - 1L
|
||||
|
||||
fun feedDatagram(
|
||||
conn: QuicConnection,
|
||||
datagram: ByteArray,
|
||||
@@ -436,6 +440,16 @@ private fun feedShortHeaderPacket(
|
||||
if (rotateOnSuccess != null && parsed.packet.packetNumber > state.pnSpace.largestReceived) {
|
||||
conn.commitKeyUpdate(rotateOnSuccess)
|
||||
}
|
||||
// RFC 9001 §6.4: clear the in-flight-rotation gate once an inbound
|
||||
// packet decrypts under the live (post-rotation) keys. That confirms
|
||||
// the peer has rolled forward in lockstep, so it's safe to permit
|
||||
// a subsequent [QuicConnection.initiateKeyUpdate]. The `rotateOnSuccess
|
||||
// == null` branch above is the live-keys path (peer's key_phase bit
|
||||
// matched our [currentReceiveKeyPhase]); reaching that with a
|
||||
// successful parse is the confirmation signal.
|
||||
if (rotateOnSuccess == null && conn.keyUpdateInProgress) {
|
||||
conn.keyUpdateInProgress = false
|
||||
}
|
||||
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
|
||||
if (conn.qlogObserver !== com.vitorpamplona.quic.observability.QlogObserver.NoOp) {
|
||||
conn.qlogObserver.onPacketReceived(
|
||||
@@ -770,9 +784,17 @@ private fun dispatchFrames(
|
||||
// RFC 9000 §4.5: the [finalSize] in RESET_STREAM MUST agree
|
||||
// with any final size implied by previously-received STREAM
|
||||
// frames AND MUST be ≥ the highest offset already observed.
|
||||
// A peer that violates this is closed with FINAL_SIZE_ERROR
|
||||
// — pre-fix we accepted any value silently, letting a buggy
|
||||
// peer drift our state.
|
||||
// §4.5 also caps final size at 2^62-1 (the QUIC offset field
|
||||
// ceiling). A peer that violates any of these is closed with
|
||||
// FINAL_SIZE_ERROR — pre-fix we accepted any value silently,
|
||||
// letting a buggy peer drift our state.
|
||||
if (frame.finalSize < 0L || frame.finalSize > MAX_QUIC_OFFSET) {
|
||||
conn.markClosedExternally(
|
||||
"FINAL_SIZE_ERROR: stream ${frame.streamId} RESET_STREAM finalSize " +
|
||||
"${frame.finalSize} outside [0, 2^62-1]",
|
||||
)
|
||||
return
|
||||
}
|
||||
val target = conn.streamByIdLocked(frame.streamId)
|
||||
if (target != null) {
|
||||
val priorFin = target.receive.finOffset
|
||||
|
||||
+35
-30
@@ -74,8 +74,6 @@ fun drainOutbound(
|
||||
conn: QuicConnection,
|
||||
nowMillis: Long,
|
||||
): ByteArray? {
|
||||
val parts = mutableListOf<ByteArray>()
|
||||
|
||||
// Closing — emit a CONNECTION_CLOSE at the highest available level. The
|
||||
// datagram-build paths below MUST satisfy two RFC 9000 constraints we
|
||||
// got wrong before:
|
||||
@@ -601,7 +599,12 @@ private fun buildApplicationPacket(
|
||||
// settled mid-handshake (peer hasn't ACK'd anything yet), so the
|
||||
// walk is a no-op.
|
||||
conn.retireFullyDoneStreamsLocked()
|
||||
val frames = mutableListOf<Frame>()
|
||||
// Reuse per-connection scratch lists (cleared at entry) instead of
|
||||
// allocating fresh `mutableListOf<Frame>` / `mutableListOf<RecoveryToken>`
|
||||
// every drain — single-writer by [streamsLock], so the next drain's
|
||||
// clear() runs after this drain has already snapshotted the tokens
|
||||
// via `.toList()` into the SentPacket record.
|
||||
val frames = conn.scratchAppFrames.also { it.clear() }
|
||||
// Tokens collected in lock-step with [frames]: each retransmittable
|
||||
// frame contributes a [RecoveryToken] so the [SentPacket] recorded
|
||||
// at the bottom of this function can drive RFC 9002 loss
|
||||
@@ -610,7 +613,7 @@ private fun buildApplicationPacket(
|
||||
// are not retransmittable (DatagramFrame, StreamFrame for now) do
|
||||
// not contribute a token; the packet is still ack-eliciting and
|
||||
// tracked for loss-detection timing.
|
||||
val tokens = mutableListOf<RecoveryToken>()
|
||||
val tokens = conn.scratchAppTokens.also { it.clear() }
|
||||
|
||||
// RFC 9000 §17.2.3 — 0-RTT packets MUST NOT contain ACK frames. The
|
||||
// server cannot ACK 0-RTT-level packets because it has no way to
|
||||
@@ -619,31 +622,23 @@ private fun buildApplicationPacket(
|
||||
// Skip ACK building when we're about to emit a 0-RTT packet.
|
||||
if (use1Rtt) {
|
||||
state.ackTracker.buildAckFrame(nowMillis, conn.config.ackDelayExponent.toInt())?.let { plainAck ->
|
||||
// RFC 9000 §13.4.2: an endpoint that USES ECN on outbound
|
||||
// packets (we set ECT(0) on every datagram via the socket's
|
||||
// IP_TOS option) MUST report ECN counts in 1-RTT ACK frames so
|
||||
// the peer can detect path congestion. We don't currently
|
||||
// read inbound TOS bits — JDK's DatagramChannel doesn't expose
|
||||
// them without JNI — so the counts are all-zero. The interop
|
||||
// runner's `ecn` testcase only checks for the field's presence
|
||||
// (`hasattr(p["quic"], "ack.ect0_count")`); strict peers that
|
||||
// cross-validate counts would reject this, but aioquic /
|
||||
// picoquic / quic-go all tolerate it. Initial / Handshake-space
|
||||
// ACKs stay plain (ecnCounts=null) — the spec allows ECN counts
|
||||
// there too, but interop implementations don't always handle
|
||||
// them and we'd gain nothing.
|
||||
val ackWithEcn =
|
||||
AckFrame(
|
||||
largestAcknowledged = plainAck.largestAcknowledged,
|
||||
ackDelay = plainAck.ackDelay,
|
||||
firstAckRange = plainAck.firstAckRange,
|
||||
additionalRanges = plainAck.additionalRanges,
|
||||
ecnCounts =
|
||||
com.vitorpamplona.quic.frame
|
||||
.AckEcnCounts(0L, 0L, 0L),
|
||||
)
|
||||
frames += ackWithEcn
|
||||
tokens += RecoveryToken.Ack(level = EncryptionLevel.APPLICATION, largestAcked = ackWithEcn.largestAcknowledged)
|
||||
// RFC 9000 §13.4.2: an endpoint that USES ECN MUST report
|
||||
// accurate ECN counts. Pre-fix we hardcoded
|
||||
// `AckEcnCounts(0, 0, 0)` while still marking outbound
|
||||
// datagrams with ECT(0) — a strict peer cross-validating
|
||||
// counts could treat the all-zero report as a
|
||||
// PROTOCOL_VIOLATION, since we claim to be using ECN but
|
||||
// never accumulate the counts. We don't read inbound TOS
|
||||
// (JDK's DatagramChannel doesn't expose it without JNI),
|
||||
// so honest reporting means: don't claim to track ECN at
|
||||
// all — emit ACK with `ecnCounts = null`. The peer reads
|
||||
// that as "this endpoint isn't reporting ECN" and skips
|
||||
// its own ECN-driven congestion logic for our direction.
|
||||
// We still mark outbound ECT(0) (other peers' tracking
|
||||
// benefits from the path-quality signal); the asymmetry
|
||||
// is allowed by §13.4.
|
||||
frames += plainAck
|
||||
tokens += RecoveryToken.Ack(level = EncryptionLevel.APPLICATION, largestAcked = plainAck.largestAcknowledged)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,7 +720,17 @@ private fun buildApplicationPacket(
|
||||
val sorted =
|
||||
when {
|
||||
active.size <= 1 -> active
|
||||
active.all { it.priority == 0 } -> active
|
||||
|
||||
// Single-pass uniform-priority check: if EVERY stream
|
||||
// shares the same priority (whether default 0 or
|
||||
// anything else), the sort is a no-op and we keep
|
||||
// insertion order for round-robin fairness. Pre-fix
|
||||
// we only short-circuited on `priority == 0`, so a
|
||||
// homogeneous `priority == 7` workload paid for an
|
||||
// O(N log N) sort despite the result being
|
||||
// observationally identical to insertion order.
|
||||
active.all { it.priority == active[0].priority } -> active
|
||||
|
||||
else -> active.sortedByDescending { it.priority }
|
||||
}
|
||||
val rotation = conn.streamRoundRobinStart
|
||||
|
||||
@@ -86,6 +86,41 @@ abstract class Aead {
|
||||
return seal(key, nonce, a, p)
|
||||
}
|
||||
|
||||
/**
|
||||
* Range + in-place [seal]: read `aad` and `plaintext` from sub-ranges
|
||||
* and write ciphertext+tag DIRECTLY into [output] at [outputOffset].
|
||||
* Returns the number of bytes written (== plaintextLength + tagLength).
|
||||
*
|
||||
* Saves another ByteArray allocation per outbound packet vs.
|
||||
* [sealRange] — the build path can pre-allocate one final packet
|
||||
* buffer and have the ciphertext land in-place rather than copying
|
||||
* a fresh `seal()` result. Combined with the AAD+plaintext range
|
||||
* inputs already handled by [sealRange], a complete outbound packet
|
||||
* goes from ~4 allocations (headerBytes / paddedPlaintext /
|
||||
* ciphertext / final concat) down to ~2 (the final packet buffer
|
||||
* and the AEAD provider's internal scratch).
|
||||
*
|
||||
* Default impl falls back to [sealRange] + copy; the JCA-backed
|
||||
* [com.vitorpamplona.quic.crypto.JcaAesGcmAead] overrides to use
|
||||
* `Cipher.doFinal(input, inOff, inLen, output, outOff)`.
|
||||
*/
|
||||
open fun sealInto(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
aad: ByteArray,
|
||||
aadOffset: Int,
|
||||
aadLength: Int,
|
||||
plaintext: ByteArray,
|
||||
plaintextOffset: Int,
|
||||
plaintextLength: Int,
|
||||
output: ByteArray,
|
||||
outputOffset: Int,
|
||||
): Int {
|
||||
val ct = sealRange(key, nonce, aad, aadOffset, aadLength, plaintext, plaintextOffset, plaintextLength)
|
||||
ct.copyInto(output, outputOffset)
|
||||
return ct.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Range-based [open] — same semantics as [open] but reads `aad` and
|
||||
* `ciphertext` from sub-ranges. Default impl slices and delegates.
|
||||
|
||||
@@ -34,3 +34,13 @@ expect val PlatformChaCha20Block: ChaCha20BlockEncrypt
|
||||
* stateless singleton) — correct, just not the fast path.
|
||||
*/
|
||||
expect fun bestAes128GcmAead(key: ByteArray): Aead
|
||||
|
||||
/**
|
||||
* Build the platform's preferred ChaCha20-Poly1305 AEAD for a fixed [key].
|
||||
* JVM 11+ / Android API 28+ provide a JCA `ChaCha20-Poly1305` cipher
|
||||
* that supports range-based `Cipher.doFinal(input, off, len, output, off)`,
|
||||
* unlocking the same allocation-elision wins as [bestAes128GcmAead].
|
||||
* Older platforms fall back to the pure-Kotlin [ChaCha20Poly1305Aead]
|
||||
* singleton — correct, just slower and without range overloads.
|
||||
*/
|
||||
expect fun bestChaCha20Poly1305Aead(key: ByteArray): Aead
|
||||
|
||||
@@ -109,14 +109,28 @@ object LongHeaderPacket {
|
||||
}
|
||||
val headerBytes = w.toByteArray()
|
||||
|
||||
// Encrypt padded payload
|
||||
// Pre-allocate the final packet buffer in one shot and have the
|
||||
// AEAD seal write ciphertext+tag directly into it. Pre-fix this
|
||||
// path allocated `headerBytes`, `paddedPlaintext`, the
|
||||
// `aead.seal` return ByteArray, and the final concat buffer —
|
||||
// four ByteArrays per outbound packet. The single-buffer +
|
||||
// [Aead.sealInto] form below collapses the seal output and
|
||||
// concat into the same allocation.
|
||||
val nonce = aeadNonce(iv, plain.packetNumber)
|
||||
val ciphertext = aead.seal(key, nonce, headerBytes, paddedPlaintext)
|
||||
|
||||
// Concatenate header + ciphertext
|
||||
val packet = ByteArray(headerBytes.size + ciphertext.size)
|
||||
val packet = ByteArray(headerBytes.size + paddedPlaintext.size + aead.tagLength)
|
||||
headerBytes.copyInto(packet, 0)
|
||||
ciphertext.copyInto(packet, headerBytes.size)
|
||||
aead.sealInto(
|
||||
key = key,
|
||||
nonce = nonce,
|
||||
aad = packet,
|
||||
aadOffset = 0,
|
||||
aadLength = headerBytes.size,
|
||||
plaintext = paddedPlaintext,
|
||||
plaintextOffset = 0,
|
||||
plaintextLength = paddedPlaintext.size,
|
||||
output = packet,
|
||||
outputOffset = headerBytes.size,
|
||||
)
|
||||
|
||||
// Apply header protection. Sample is 16 bytes starting 4 bytes after pnOffset.
|
||||
val sampleStart = pnOffset + 4
|
||||
|
||||
@@ -75,12 +75,26 @@ object ShortHeaderPacket {
|
||||
// Trailing 0x00 bytes decode as PADDING frames (RFC 9000 §19.1).
|
||||
val paddedPlaintext = padForHeaderProtectionSample(plain.payload, pnLen)
|
||||
|
||||
// Same single-buffer + sealInto pattern as LongHeaderPacket.build:
|
||||
// pre-allocate the final packet and have the AEAD write
|
||||
// ciphertext+tag directly into it instead of allocating a fresh
|
||||
// `seal()` return + a concat buffer. Saves 2 ByteArrays per
|
||||
// outbound short-header packet.
|
||||
val nonce = aeadNonce(iv, plain.packetNumber)
|
||||
val ciphertext = aead.seal(key, nonce, headerBytes, paddedPlaintext)
|
||||
|
||||
val packet = ByteArray(headerBytes.size + ciphertext.size)
|
||||
val packet = ByteArray(headerBytes.size + paddedPlaintext.size + aead.tagLength)
|
||||
headerBytes.copyInto(packet, 0)
|
||||
ciphertext.copyInto(packet, headerBytes.size)
|
||||
aead.sealInto(
|
||||
key = key,
|
||||
nonce = nonce,
|
||||
aad = packet,
|
||||
aadOffset = 0,
|
||||
aadLength = headerBytes.size,
|
||||
plaintext = paddedPlaintext,
|
||||
plaintextOffset = 0,
|
||||
plaintextLength = paddedPlaintext.size,
|
||||
output = packet,
|
||||
outputOffset = headerBytes.size,
|
||||
)
|
||||
|
||||
val sampleStart = pnOffset + 4
|
||||
require(sampleStart + 16 <= packet.size) { "packet too short for HP sample" }
|
||||
|
||||
@@ -22,7 +22,7 @@ package com.vitorpamplona.quic.stream
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
/**
|
||||
* One QUIC stream (bidirectional or unidirectional). Application code
|
||||
@@ -78,33 +78,35 @@ class QuicStream(
|
||||
* was discarded, leaving a hole in the stream that the application could
|
||||
* never know about.
|
||||
*
|
||||
* **Single-collector contract.** [consumeAsFlow] cancels the underlying
|
||||
* [Channel] when its collector terminates (either by exception or
|
||||
* explicit cancellation). After that point any subsequent
|
||||
* `trySend` from the parser fails, sets [overflowed] = true, and
|
||||
* the parser tears the whole connection down with
|
||||
* `INTERNAL_ERROR: stream … consumer overflowed`. So:
|
||||
* - Application code MUST collect [incoming] **at most once** per
|
||||
* stream and MUST hold the collect open until the stream
|
||||
* terminates (FIN, peer RESET_STREAM, or local
|
||||
* [stopSending] / [resetStream] decision).
|
||||
* - Cancelling the collector early — e.g. wrapping in
|
||||
* `withTimeout(...)` — is equivalent to telling the connection
|
||||
* to drop. If the application wants to stop receiving without
|
||||
* killing the connection, call [stopSending] FIRST, then let
|
||||
* the parser's RESET_STREAM-style teardown drain the channel
|
||||
* cleanly.
|
||||
* **Resilience to collector cancellation.** Pre-fix this exposed
|
||||
* [incomingChannel] via `consumeAsFlow()`, which cancels the
|
||||
* underlying [Channel] when its collector terminates. That coupled
|
||||
* "application stopped collecting" to "parser INTERNAL_ERRORs the
|
||||
* whole connection on next delivery" — cancelling the collector
|
||||
* early (e.g. `withTimeout(...)`) was effectively a request to
|
||||
* drop the connection. Rebuilt as `flow { for (c in
|
||||
* incomingChannel) emit(c) }`: a fresh emit-only Flow over the
|
||||
* channel iterator, with no `consume`-style ownership transfer.
|
||||
* Now collector cancellation just exits the flow without touching
|
||||
* the channel; the channel stays open, the parser keeps
|
||||
* delivering, and the application can re-collect later (a fresh
|
||||
* collector picks up at `channel.receive()` — i.e. from the
|
||||
* current head, not the start).
|
||||
*
|
||||
* This is a fragile coupling we accept for now because (a) every
|
||||
* production caller already follows the single-collector pattern,
|
||||
* and (b) widening the contract would require swapping
|
||||
* `consumeAsFlow` for a `MutableSharedFlow` with replay/buffer
|
||||
* semantics, which has its own back-pressure pitfalls. The
|
||||
* comment is here so a future refactor doesn't accidentally
|
||||
* loosen the contract.
|
||||
* Producer back-pressure unchanged: the channel buffer is still
|
||||
* 64 chunks, [trySend] still sets [overflowed] on saturation,
|
||||
* and the parser still closes the connection if the application
|
||||
* fails to drain. The looser contract just permits the previously-
|
||||
* disallowed pattern of "stop reading temporarily, then resume".
|
||||
*
|
||||
* Concurrent collectors are still NOT supported — two simultaneous
|
||||
* collects would race the channel iterator and each chunk goes to
|
||||
* exactly one of them non-deterministically. Sequential collects
|
||||
* (one finishes / cancels, then another starts) are the new
|
||||
* supported pattern.
|
||||
*/
|
||||
private val incomingChannel = Channel<ByteArray>(capacity = 64)
|
||||
val incoming: Flow<ByteArray> get() = incomingChannel.consumeAsFlow()
|
||||
val incoming: Flow<ByteArray> = flow { for (chunk in incomingChannel) emit(chunk) }
|
||||
|
||||
/**
|
||||
* True once a [deliverIncoming] call failed because the channel was
|
||||
@@ -128,10 +130,18 @@ class QuicStream(
|
||||
* writer's appendFlowControlUpdates consumes it to skip streams that
|
||||
* haven't received any new bytes since the last MAX_STREAM_DATA emission.
|
||||
*
|
||||
* `@Volatile` because the parser writes it from the read loop and the
|
||||
* writer reads it from the send loop without holding the same lock.
|
||||
* Without volatile the writer could miss the parser's update for an
|
||||
* unbounded time on JVM (the field is read in a hot drain loop where
|
||||
* the JIT might cache it), suppressing MAX_STREAM_DATA emissions
|
||||
* until something else triggered a fresh load.
|
||||
*
|
||||
* Pre-fix the writer iterated EVERY open stream on every drain
|
||||
* (audit-4 perf #9 — O(streams) × ~50 drains/sec; significant for audio
|
||||
* rooms with many WT streams).
|
||||
*/
|
||||
@Volatile
|
||||
internal var receiveDirtyForFlowControl: Boolean = false
|
||||
|
||||
/** True once we've FIN'd our write side and the peer FIN'd theirs. */
|
||||
|
||||
@@ -136,6 +136,21 @@ class SendBuffer(
|
||||
*/
|
||||
private val retransmit: ArrayDeque<Range> = ArrayDeque()
|
||||
|
||||
/**
|
||||
* Cached `sum(retransmit[].length)` so [readableBytes] can return
|
||||
* in O(1) instead of walking the deque on every call. The writer's
|
||||
* pre-flight "anything to send?" check runs per-stream per-drain
|
||||
* (~50 drains/sec × N streams), so an O(R) per-call cost on lossy
|
||||
* paths with deep retransmit queues was meaningful CPU.
|
||||
*
|
||||
* Updated in lockstep with every [retransmit] add/remove path:
|
||||
* retransmit.addLast / addFirst / removeFirst, and
|
||||
* [removeOverlap]'s mid-walk add/remove during ACK processing.
|
||||
* The invariant `retransmitTotalBytes == retransmit.sumOf { it.length }`
|
||||
* holds whenever no [SendBuffer] method is mid-mutation.
|
||||
*/
|
||||
private var retransmitTotalBytes: Long = 0L
|
||||
|
||||
private var _finPending: Boolean = false
|
||||
private var _finSent: Boolean = false
|
||||
private var _finAcked: Boolean = false
|
||||
@@ -154,9 +169,7 @@ class SendBuffer(
|
||||
val readableBytes: Int
|
||||
get() =
|
||||
synchronized(this) {
|
||||
var sum = 0L
|
||||
for (r in retransmit) sum += r.length
|
||||
sum += (_nextOffset - nextSendOffset)
|
||||
val sum = retransmitTotalBytes + (_nextOffset - nextSendOffset)
|
||||
sum.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||
}
|
||||
|
||||
@@ -210,16 +223,18 @@ class SendBuffer(
|
||||
val take = minOf(retransmitHead.length, cap.toLong())
|
||||
val payload = sliceAt(retransmitHead.offset, take.toInt())
|
||||
retransmit.removeFirst()
|
||||
retransmitTotalBytes -= retransmitHead.length
|
||||
val fin = retransmitHead.fin && take == retransmitHead.length
|
||||
if (take < retransmitHead.length) {
|
||||
// Push remainder back at the head, preserving offset.
|
||||
retransmit.addFirst(
|
||||
val remainder =
|
||||
Range(
|
||||
offset = retransmitHead.offset + take,
|
||||
length = retransmitHead.length - take,
|
||||
fin = retransmitHead.fin,
|
||||
),
|
||||
)
|
||||
)
|
||||
retransmit.addFirst(remainder)
|
||||
retransmitTotalBytes += remainder.length
|
||||
}
|
||||
addToInFlight(Range(retransmitHead.offset, take, fin))
|
||||
if (fin) _finSent = true
|
||||
@@ -351,13 +366,29 @@ class SendBuffer(
|
||||
}
|
||||
// Move every inflight range to the retransmit queue,
|
||||
// preserving offset order (inFlight is sorted by offset
|
||||
// ascending, so addLast preserves sort within retransmit
|
||||
// for these new entries — though retransmit is a FIFO
|
||||
// and doesn't strictly require sorted order, takeChunk
|
||||
// pops front-first regardless).
|
||||
// ascending). Coalesce adjacent ranges on insert: if the
|
||||
// previous tail entry's `offset + length` equals the
|
||||
// current's `offset`, merge them into a single range. The
|
||||
// PTO probe path otherwise emits one tiny STREAM frame per
|
||||
// original-packet boundary instead of replaying the
|
||||
// contiguous bytes as a single chunk, fragmenting the
|
||||
// probe across N small frames + N AEAD seals + N writes
|
||||
// when one frame would do. Coalescing across the FIN bit
|
||||
// is gated — only merge when the previous tail had no
|
||||
// FIN, otherwise the FIN's implicit final-size invariant
|
||||
// could shift.
|
||||
for (r in inFlight) {
|
||||
if (r.fin && !_finAcked) _finSent = false
|
||||
retransmit.addLast(r)
|
||||
val tail = retransmit.lastOrNull()
|
||||
if (tail != null && !tail.fin && tail.offset + tail.length == r.offset) {
|
||||
// Merge: extend the tail to cover [tail.offset, r.endOffset).
|
||||
val merged = Range(tail.offset, tail.length + r.length, r.fin)
|
||||
retransmit.removeLast()
|
||||
retransmit.addLast(merged)
|
||||
} else {
|
||||
retransmit.addLast(r)
|
||||
}
|
||||
retransmitTotalBytes += r.length
|
||||
}
|
||||
inFlight.clear()
|
||||
}
|
||||
@@ -405,6 +436,7 @@ class SendBuffer(
|
||||
|
||||
OverlapAction.RETRANSMIT -> {
|
||||
retransmit.addLast(r)
|
||||
retransmitTotalBytes += r.length
|
||||
}
|
||||
|
||||
OverlapAction.DROP -> {} // discard
|
||||
@@ -482,6 +514,7 @@ class SendBuffer(
|
||||
fin = coveredFin,
|
||||
),
|
||||
)
|
||||
retransmitTotalBytes += coveredLen
|
||||
}
|
||||
|
||||
OverlapAction.DROP -> {} // discard the covered piece
|
||||
|
||||
@@ -361,39 +361,47 @@ class TlsClient(
|
||||
// We offered PSK but the server picked full-
|
||||
// handshake. RFC 8446 §4.2.11 lets the server
|
||||
// do this freely (rate limit, ticket aged out,
|
||||
// policy mismatch). Recovering in-place
|
||||
// requires:
|
||||
// 1. Discarding the early secret derived
|
||||
// from the resumption PSK.
|
||||
// 2. Rebuilding the transcript without the
|
||||
// `pre_shared_key` extension or its
|
||||
// binder bytes.
|
||||
// 3. Replaying the post-ClientHello derivation
|
||||
// with the new transcript.
|
||||
// Each step is touchy enough that a
|
||||
// subtly-wrong transcript hash would land us on
|
||||
// a successful handshake with wrong keys, which
|
||||
// is harder to debug than a hard failure. We
|
||||
// surface a typed [PskRejectedException] so
|
||||
// application-layer reconnect logic can drop
|
||||
// the cached resumption state and retry from
|
||||
// scratch — that path is correct by
|
||||
// construction (fresh ClientHello, no PSK
|
||||
// history). Production callers wrapping this
|
||||
// client SHOULD catch [PskRejectedException]
|
||||
// and retry without [resumption].
|
||||
throw PskRejectedException(
|
||||
"server rejected PSK; application must retry without resumption",
|
||||
)
|
||||
// policy mismatch).
|
||||
//
|
||||
// Recover in-place WITHOUT a transcript rebuild.
|
||||
// The on-wire ClientHello carries the
|
||||
// pre_shared_key extension and binder bytes;
|
||||
// both client and server hash the FULL
|
||||
// ClientHello (binders included) into their
|
||||
// running transcript hash regardless of accept
|
||||
// / reject (RFC 8446 §4.2.11). So the
|
||||
// transcript itself stays valid — only the
|
||||
// [earlySecret] derivation changes:
|
||||
// * accepted: early_secret = HKDF.extract(0, PSK)
|
||||
// * rejected: early_secret = HKDF.extract(0, 0)
|
||||
// [deriveHandshake] then folds early_secret +
|
||||
// ECDHE shared secret into [handshakeSecret];
|
||||
// every downstream key follows. We just have
|
||||
// to overwrite earlySecret here BEFORE
|
||||
// [deriveHandshake] runs below.
|
||||
//
|
||||
// Any 0-RTT packets the application already
|
||||
// emitted under the PSK-derived
|
||||
// [clientEarlyTrafficSecret] are lost: the
|
||||
// server can't decrypt them, and the EE will
|
||||
// arrive without the early_data extension
|
||||
// (earlyDataAccepted = false). That's the
|
||||
// RFC's expected behaviour and the
|
||||
// application-level retry layer is responsible
|
||||
// for replaying any 0-RTT-bound payload over
|
||||
// 1-RTT. The handshake itself proceeds cleanly.
|
||||
keySchedule.deriveEarly()
|
||||
pskAccepted = false
|
||||
} else {
|
||||
val r = QuicReader(pskExt.data)
|
||||
val selectedIdentity = r.readUint16()
|
||||
if (selectedIdentity != 0) {
|
||||
throw QuicCodecException(
|
||||
"server selected PSK identity $selectedIdentity but we only offered 0",
|
||||
)
|
||||
}
|
||||
pskAccepted = true
|
||||
}
|
||||
val r = QuicReader(pskExt.data)
|
||||
val selectedIdentity = r.readUint16()
|
||||
if (selectedIdentity != 0) {
|
||||
throw QuicCodecException(
|
||||
"server selected PSK identity $selectedIdentity but we only offered 0",
|
||||
)
|
||||
}
|
||||
pskAccepted = true
|
||||
} else if (pskExt != null) {
|
||||
throw QuicCodecException("server picked PSK we never offered")
|
||||
}
|
||||
@@ -605,22 +613,6 @@ class TlsClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the server returned a ServerHello without a `pre_shared_key`
|
||||
* extension despite the client offering one in [TlsClient.resumption].
|
||||
* The handshake cannot proceed in-place because the early secret + transcript
|
||||
* were already shaped around the PSK; application-layer reconnect logic
|
||||
* should catch this, drop the cached [TlsResumptionState], and retry the
|
||||
* handshake from scratch. See the call site in
|
||||
* [TlsClient.handleHandshakeMessage] for the full rationale.
|
||||
*
|
||||
* Distinct subclass of [QuicCodecException] so callers can selectively
|
||||
* catch the recoverable case without swallowing genuine protocol errors.
|
||||
*/
|
||||
class PskRejectedException(
|
||||
message: String,
|
||||
) : QuicCodecException(message)
|
||||
|
||||
/** Callback interface so the QUIC layer can react to TLS-derived secrets. */
|
||||
interface TlsSecretsListener {
|
||||
fun onHandshakeKeysReady(
|
||||
@@ -685,7 +677,23 @@ interface TlsSecretsListener {
|
||||
* [ticketAgeAdd] and [issuedAtMillis] to compute the obfuscated_ticket_age
|
||||
* the server expects.
|
||||
*/
|
||||
data class TlsResumptionState(
|
||||
/**
|
||||
* Cached state from a prior TLS handshake that lets the client offer
|
||||
* RFC 8446 PSK-resumption + RFC 9001 §4.6 0-RTT on the next connection.
|
||||
*
|
||||
* Plain `class` (not `data class`) intentionally:
|
||||
* - The `data class`-generated `equals` / `hashCode` use reference
|
||||
* equality on [ByteArray] fields, so two states with byte-identical
|
||||
* PSKs would compare unequal — surprising and almost never useful.
|
||||
* - The auto-generated `toString` would dump `psk` / `ticket` /
|
||||
* `peerTransportParameters` byte contents into any log, stack trace,
|
||||
* or debugger that touches the object. Both are sensitive.
|
||||
*
|
||||
* We override [equals] / [hashCode] with [contentEquals] semantics on
|
||||
* the byte fields (so callers can deduplicate cached states by content)
|
||||
* and [toString] to redact the secret payload.
|
||||
*/
|
||||
class TlsResumptionState(
|
||||
/** Opaque ticket bytes echoed verbatim as the PSK identity on the next connection. */
|
||||
val ticket: ByteArray,
|
||||
/** PSK derived from `resumption_master_secret` + `ticket_nonce` per RFC 8446 §4.6.1. */
|
||||
@@ -724,7 +732,51 @@ data class TlsResumptionState(
|
||||
val peerTransportParameters: ByteArray? = null,
|
||||
/** Negotiated ALPN from the prior connection. 0-RTT must use the same protocol. */
|
||||
val negotiatedAlpn: ByteArray? = null,
|
||||
)
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is TlsResumptionState) return false
|
||||
return ticket.contentEquals(other.ticket) &&
|
||||
psk.contentEquals(other.psk) &&
|
||||
cipherSuite == other.cipherSuite &&
|
||||
ticketAgeAdd == other.ticketAgeAdd &&
|
||||
ticketLifetimeSec == other.ticketLifetimeSec &&
|
||||
issuedAtMillis == other.issuedAtMillis &&
|
||||
maxEarlyDataSize == other.maxEarlyDataSize &&
|
||||
(peerTransportParameters?.contentEquals(other.peerTransportParameters) ?: (other.peerTransportParameters == null)) &&
|
||||
(negotiatedAlpn?.contentEquals(other.negotiatedAlpn) ?: (other.negotiatedAlpn == null))
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var h = ticket.contentHashCode()
|
||||
h = 31 * h + psk.contentHashCode()
|
||||
h = 31 * h + cipherSuite
|
||||
h = 31 * h + ticketAgeAdd.hashCode()
|
||||
h = 31 * h + ticketLifetimeSec.hashCode()
|
||||
h = 31 * h + issuedAtMillis.hashCode()
|
||||
h = 31 * h + maxEarlyDataSize.hashCode()
|
||||
h = 31 * h + (peerTransportParameters?.contentHashCode() ?: 0)
|
||||
h = 31 * h + (negotiatedAlpn?.contentHashCode() ?: 0)
|
||||
return h
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacted [toString]: never include `ticket`, `psk`, or
|
||||
* `peerTransportParameters` byte contents — those leak into logs
|
||||
* and stack traces. Sizes are fine to expose.
|
||||
*/
|
||||
override fun toString(): String =
|
||||
"TlsResumptionState(" +
|
||||
"ticket=<${ticket.size} bytes>, " +
|
||||
"psk=<${psk.size} bytes redacted>, " +
|
||||
"cipherSuite=0x${cipherSuite.toString(16)}, " +
|
||||
"ticketAgeAdd=$ticketAgeAdd, " +
|
||||
"ticketLifetimeSec=$ticketLifetimeSec, " +
|
||||
"issuedAtMillis=$issuedAtMillis, " +
|
||||
"maxEarlyDataSize=$maxEarlyDataSize, " +
|
||||
"peerTransportParameters=${peerTransportParameters?.let { "<${it.size} bytes>" }}, " +
|
||||
"negotiatedAlpn=${negotiatedAlpn?.decodeToString()})"
|
||||
}
|
||||
|
||||
/** Pluggable certificate validator. Decoupled so we can stub it in tests. */
|
||||
interface CertificateValidator {
|
||||
|
||||
@@ -206,6 +206,56 @@ class JcaAesGcmAead(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place range [seal]. Same semantics as the parent's [sealInto],
|
||||
* but takes the JCA's native `Cipher.doFinal(input, inOff, inLen,
|
||||
* output, outOff)` path so ciphertext+tag land directly in
|
||||
* [output] with no intermediate allocation. Caller is expected to
|
||||
* have sized `output` large enough to hold
|
||||
* `plaintextLength + tagLength` bytes starting at `outputOffset`.
|
||||
*/
|
||||
override fun sealInto(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
aad: ByteArray,
|
||||
aadOffset: Int,
|
||||
aadLength: Int,
|
||||
plaintext: ByteArray,
|
||||
plaintextOffset: Int,
|
||||
plaintextLength: Int,
|
||||
output: ByteArray,
|
||||
outputOffset: Int,
|
||||
): Int =
|
||||
synchronized(this) {
|
||||
val reuse = recentEncryptNonces.any { it.contentEquals(nonce) }
|
||||
val cipher: Cipher
|
||||
if (reuse) {
|
||||
cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(128, nonce))
|
||||
} else {
|
||||
cipher = encryptCipher
|
||||
try {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, GCMParameterSpec(128, nonce))
|
||||
} catch (t: Throwable) {
|
||||
if (recentEncryptNonces.lastOrNull()?.contentEquals(nonce) == true) {
|
||||
recentEncryptNonces.removeLast()
|
||||
}
|
||||
throw t
|
||||
}
|
||||
}
|
||||
try {
|
||||
cipher.updateAAD(aad, aadOffset, aadLength)
|
||||
val written = cipher.doFinal(plaintext, plaintextOffset, plaintextLength, output, outputOffset)
|
||||
if (!reuse) rememberEncryptNonce(nonce)
|
||||
written
|
||||
} catch (t: Throwable) {
|
||||
if (!reuse && recentEncryptNonces.lastOrNull()?.contentEquals(nonce) == true) {
|
||||
recentEncryptNonces.removeLast()
|
||||
}
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val NONCE_HISTORY_LIMIT = 8
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* 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.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* ChaCha20-Poly1305 AEAD via the JCA `ChaCha20-Poly1305` cipher
|
||||
* (Java 11+ / Android API 28+). Mirrors [JcaAesGcmAead]'s shape: caches
|
||||
* the [Cipher] + [SecretKeySpec] across calls and exposes the range
|
||||
* overloads ([sealRange] / [openRange] / [sealInto]) that pass
|
||||
* offsets straight to the JCA cipher's `doFinal(input, inOff, inLen,
|
||||
* output, outOff)` form, eliminating the per-packet slice
|
||||
* allocations the pure-Kotlin [ChaCha20Poly1305Aead] requires.
|
||||
*
|
||||
* Single-thread per direction (one PacketProtection per side, one
|
||||
* direction per side). Synchronization on a private monitor is
|
||||
* defence-in-depth — a future caller (test harness, key-update path)
|
||||
* sharing the instance across coroutines would otherwise corrupt
|
||||
* the cached `Cipher` state silently.
|
||||
*/
|
||||
class JcaChaCha20Poly1305Aead(
|
||||
key: ByteArray,
|
||||
) : Aead() {
|
||||
override val keyLength = 32
|
||||
override val nonceLength = 12
|
||||
override val tagLength = 16
|
||||
|
||||
private val keySpec = SecretKeySpec(key, "ChaCha20")
|
||||
private val encryptCipher: Cipher = Cipher.getInstance("ChaCha20-Poly1305")
|
||||
private val decryptCipher: Cipher = Cipher.getInstance("ChaCha20-Poly1305")
|
||||
|
||||
/**
|
||||
* Last-N nonces successfully consumed by [seal] / [sealRange] /
|
||||
* [sealInto]. JCA's ChaCha20-Poly1305 (like AES-GCM) refuses to
|
||||
* encrypt under a (key, nonce) pair already used by THIS cipher
|
||||
* instance — even when the reuse is legitimate (Initial-padding
|
||||
* rebuild). When we detect a reuse against this history we fall
|
||||
* back to a fresh `Cipher.getInstance` to avoid fighting the
|
||||
* provider's safety check.
|
||||
*/
|
||||
private val recentEncryptNonces = ArrayDeque<ByteArray>()
|
||||
|
||||
override fun seal(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
aad: ByteArray,
|
||||
plaintext: ByteArray,
|
||||
): ByteArray =
|
||||
synchronized(this) {
|
||||
sealCommon(nonce, aad, 0, aad.size, plaintext, 0, plaintext.size, output = null, outputOffset = 0)
|
||||
.first
|
||||
}
|
||||
|
||||
override fun sealRange(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
aad: ByteArray,
|
||||
aadOffset: Int,
|
||||
aadLength: Int,
|
||||
plaintext: ByteArray,
|
||||
plaintextOffset: Int,
|
||||
plaintextLength: Int,
|
||||
): ByteArray =
|
||||
synchronized(this) {
|
||||
sealCommon(nonce, aad, aadOffset, aadLength, plaintext, plaintextOffset, plaintextLength, output = null, outputOffset = 0)
|
||||
.first
|
||||
}
|
||||
|
||||
override fun sealInto(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
aad: ByteArray,
|
||||
aadOffset: Int,
|
||||
aadLength: Int,
|
||||
plaintext: ByteArray,
|
||||
plaintextOffset: Int,
|
||||
plaintextLength: Int,
|
||||
output: ByteArray,
|
||||
outputOffset: Int,
|
||||
): Int =
|
||||
synchronized(this) {
|
||||
sealCommon(nonce, aad, aadOffset, aadLength, plaintext, plaintextOffset, plaintextLength, output, outputOffset)
|
||||
.second
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared seal path for all three entry points. Returns
|
||||
* `(freshOrEmpty, bytesWritten)`:
|
||||
* - When [output] is null we allocate the result internally,
|
||||
* return it as `freshOrEmpty`, and `bytesWritten` is its size.
|
||||
* - When [output] is non-null we write into it at [outputOffset],
|
||||
* return an empty array as `freshOrEmpty` (caller ignores), and
|
||||
* `bytesWritten` is the number of bytes written.
|
||||
*/
|
||||
private fun sealCommon(
|
||||
nonce: ByteArray,
|
||||
aad: ByteArray,
|
||||
aadOffset: Int,
|
||||
aadLength: Int,
|
||||
plaintext: ByteArray,
|
||||
plaintextOffset: Int,
|
||||
plaintextLength: Int,
|
||||
output: ByteArray?,
|
||||
outputOffset: Int,
|
||||
): Pair<ByteArray, Int> {
|
||||
val reuse = recentEncryptNonces.any { it.contentEquals(nonce) }
|
||||
val cipher: Cipher
|
||||
if (reuse) {
|
||||
cipher = Cipher.getInstance("ChaCha20-Poly1305")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, IvParameterSpec(nonce))
|
||||
} else {
|
||||
cipher = encryptCipher
|
||||
try {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, IvParameterSpec(nonce))
|
||||
} catch (t: Throwable) {
|
||||
if (recentEncryptNonces.lastOrNull()?.contentEquals(nonce) == true) {
|
||||
recentEncryptNonces.removeLast()
|
||||
}
|
||||
throw t
|
||||
}
|
||||
}
|
||||
try {
|
||||
cipher.updateAAD(aad, aadOffset, aadLength)
|
||||
return if (output != null) {
|
||||
val written = cipher.doFinal(plaintext, plaintextOffset, plaintextLength, output, outputOffset)
|
||||
if (!reuse) rememberEncryptNonce(nonce)
|
||||
EMPTY_BYTES to written
|
||||
} else {
|
||||
val out = cipher.doFinal(plaintext, plaintextOffset, plaintextLength)
|
||||
if (!reuse) rememberEncryptNonce(nonce)
|
||||
out to out.size
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
if (!reuse && recentEncryptNonces.lastOrNull()?.contentEquals(nonce) == true) {
|
||||
recentEncryptNonces.removeLast()
|
||||
}
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private fun rememberEncryptNonce(nonce: ByteArray) {
|
||||
recentEncryptNonces.addLast(nonce)
|
||||
while (recentEncryptNonces.size > NONCE_HISTORY_LIMIT) {
|
||||
recentEncryptNonces.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
override fun open(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
aad: ByteArray,
|
||||
ciphertext: ByteArray,
|
||||
): ByteArray? =
|
||||
synchronized(this) {
|
||||
try {
|
||||
decryptCipher.init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(nonce))
|
||||
decryptCipher.updateAAD(aad)
|
||||
decryptCipher.doFinal(ciphertext)
|
||||
} catch (_: GeneralSecurityException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun openRange(
|
||||
key: ByteArray,
|
||||
nonce: ByteArray,
|
||||
aad: ByteArray,
|
||||
aadOffset: Int,
|
||||
aadLength: Int,
|
||||
ciphertext: ByteArray,
|
||||
ciphertextOffset: Int,
|
||||
ciphertextLength: Int,
|
||||
): ByteArray? =
|
||||
synchronized(this) {
|
||||
try {
|
||||
decryptCipher.init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(nonce))
|
||||
decryptCipher.updateAAD(aad, aadOffset, aadLength)
|
||||
decryptCipher.doFinal(ciphertext, ciphertextOffset, ciphertextLength)
|
||||
} catch (_: GeneralSecurityException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val NONCE_HISTORY_LIMIT = 8
|
||||
private val EMPTY_BYTES = ByteArray(0)
|
||||
}
|
||||
}
|
||||
@@ -45,3 +45,24 @@ actual val PlatformChaCha20Block: ChaCha20BlockEncrypt =
|
||||
}
|
||||
|
||||
actual fun bestAes128GcmAead(key: ByteArray): Aead = JcaAesGcmAead(key)
|
||||
|
||||
/**
|
||||
* Try the JCA `ChaCha20-Poly1305` cipher first (Java 11+ /
|
||||
* Android API 28+) — gives us range-overload + offset-based doFinal,
|
||||
* which lets [com.vitorpamplona.quic.packet.LongHeaderPacket.build]
|
||||
* and [com.vitorpamplona.quic.packet.ShortHeaderPacket.build] write
|
||||
* ciphertext+tag in-place without intermediate allocations on the
|
||||
* outbound hot path.
|
||||
*
|
||||
* Falls back to the pure-Kotlin [ChaCha20Poly1305Aead] singleton if
|
||||
* the JCA provider doesn't ship the algorithm — older Android
|
||||
* versions, headless containers without the standard provider set,
|
||||
* GraalVM native-image unsubsetted, etc. The fallback is correct
|
||||
* (same algorithm) but slower and skips the range overloads.
|
||||
*/
|
||||
actual fun bestChaCha20Poly1305Aead(key: ByteArray): Aead =
|
||||
try {
|
||||
JcaChaCha20Poly1305Aead(key)
|
||||
} catch (_: java.security.NoSuchAlgorithmException) {
|
||||
ChaCha20Poly1305Aead
|
||||
}
|
||||
|
||||
@@ -247,28 +247,242 @@ class JdkCertificateValidator(
|
||||
if (!lhost.endsWith(suffix)) return false
|
||||
val prefix = lhost.substring(0, lhost.length - suffix.length)
|
||||
if (prefix.isEmpty() || '.' in prefix) return false
|
||||
// RFC 6125 §6.4.3 — disallow wildcards in the public-suffix label.
|
||||
// RFC 6125 §6.4.3 — disallow wildcards in the public-suffix
|
||||
// label. The full Mozilla PSL is ~9000 entries; we ship a
|
||||
// hand-picked subset covering the common multi-label
|
||||
// effective-TLDs that come up in the wild for cert
|
||||
// mis-issuance scenarios (multi-tenant ccTLDs, hosting
|
||||
// platforms). Two layers of defence:
|
||||
// 1. The dot-count heuristic (≥ 2 dots in the suffix)
|
||||
// catches the obvious `*.com` / `*.net` patterns.
|
||||
// 2. The denylist below catches `*.co.uk`,
|
||||
// `*.s3.amazonaws.com`, `*.github.io`, etc. — the
|
||||
// multi-label tldsets where the dot-count alone would
|
||||
// let a rogue wildcard impersonate an entire tenant
|
||||
// pool.
|
||||
//
|
||||
// KNOWN GAP: this heuristic counts dots in the suffix (≥ 2 → OK)
|
||||
// and DOES NOT consult the actual public-suffix list. So it
|
||||
// accepts `*.co.uk`, `*.github.io`, `*.s3.amazonaws.com`, etc.
|
||||
// — multi-tenant TLDs whose effective TLD is multi-label. A
|
||||
// CA that mis-issues such a wildcard could impersonate any
|
||||
// co-tenant. The mitigation is partial: the WebPKI ecosystem
|
||||
// already requires CAs to consult the PSL when issuing, so a
|
||||
// rogue cert is unlikely to make it past CT logging — but if
|
||||
// one does, our validation accepts it.
|
||||
//
|
||||
// Full fix requires shipping PSL data; deferred until the cost
|
||||
// is justified by a higher-risk deployment. Production callers
|
||||
// should rely on the OS / NetworkSecurityConfig pinning layer
|
||||
// for sensitive endpoints rather than QUIC's built-in
|
||||
// hostname check alone.
|
||||
// This is intentionally conservative — false positives
|
||||
// (rejecting a legitimate but unusual wildcard) are recoverable
|
||||
// by the application using OS-level pinning, while false
|
||||
// negatives (accepting a rogue wildcard) silently break
|
||||
// hostname authentication. The full PSL would close the
|
||||
// remaining gap; until then, this catches the high-volume
|
||||
// attack surfaces.
|
||||
val suffixWithoutLeadingDot = suffix.removePrefix(".")
|
||||
if (suffixWithoutLeadingDot in MULTI_LABEL_PUBLIC_SUFFIXES) return false
|
||||
val suffixDots = suffix.count { it == '.' }
|
||||
return suffixDots >= 2
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Hand-picked subset of the Mozilla Public Suffix List covering
|
||||
* common multi-label effective-TLDs. Wildcards spanning these
|
||||
* suffixes (e.g. `*.co.uk`, `*.s3.amazonaws.com`) MUST be
|
||||
* rejected per RFC 6125 §6.4.3 — a rogue cert that captured
|
||||
* such a wildcard would impersonate every co-tenant.
|
||||
*
|
||||
* Strict subset of the full PSL — we don't ship the ~9000-entry
|
||||
* data file; entries here cover the high-frequency attack
|
||||
* surfaces (multi-tenant ccTLDs, major hosting platforms). The
|
||||
* full PSL would close the remaining gap; for now, callers of
|
||||
* sensitive endpoints should still layer OS-level pinning.
|
||||
*
|
||||
* Sources:
|
||||
* - Top multi-label ccTLDs from publicsuffix.org/list/
|
||||
* (uk / au / nz / jp / kr / br / mx / in / ar / il / etc.)
|
||||
* - Major hosting platforms whose tenant subdomains all share
|
||||
* one cert root (s3.amazonaws.com, github.io, herokuapp.com,
|
||||
* vercel.app, netlify.app, web.app, blogspot.com,
|
||||
* appspot.com, pages.dev, workers.dev, etc.)
|
||||
*/
|
||||
private val MULTI_LABEL_PUBLIC_SUFFIXES: Set<String> =
|
||||
setOf(
|
||||
// UK
|
||||
"co.uk",
|
||||
"org.uk",
|
||||
"ac.uk",
|
||||
"gov.uk",
|
||||
"ltd.uk",
|
||||
"plc.uk",
|
||||
"me.uk",
|
||||
"net.uk",
|
||||
"sch.uk",
|
||||
"nhs.uk",
|
||||
"police.uk",
|
||||
// AU
|
||||
"com.au",
|
||||
"net.au",
|
||||
"org.au",
|
||||
"edu.au",
|
||||
"gov.au",
|
||||
"asn.au",
|
||||
"id.au",
|
||||
// NZ
|
||||
"co.nz",
|
||||
"net.nz",
|
||||
"org.nz",
|
||||
"ac.nz",
|
||||
"govt.nz",
|
||||
"school.nz",
|
||||
// JP
|
||||
"co.jp",
|
||||
"ne.jp",
|
||||
"or.jp",
|
||||
"ac.jp",
|
||||
"ad.jp",
|
||||
"ed.jp",
|
||||
"go.jp",
|
||||
"gr.jp",
|
||||
"lg.jp",
|
||||
// KR
|
||||
"co.kr",
|
||||
"ne.kr",
|
||||
"or.kr",
|
||||
"re.kr",
|
||||
"ac.kr",
|
||||
"go.kr",
|
||||
"mil.kr",
|
||||
"sc.kr",
|
||||
// BR
|
||||
"com.br",
|
||||
"net.br",
|
||||
"org.br",
|
||||
"edu.br",
|
||||
"gov.br",
|
||||
"mil.br",
|
||||
// MX
|
||||
"com.mx",
|
||||
"net.mx",
|
||||
"org.mx",
|
||||
"edu.mx",
|
||||
"gob.mx",
|
||||
// IN
|
||||
"co.in",
|
||||
"net.in",
|
||||
"org.in",
|
||||
"edu.in",
|
||||
"gov.in",
|
||||
"ac.in",
|
||||
"res.in",
|
||||
// AR
|
||||
"com.ar",
|
||||
"net.ar",
|
||||
"org.ar",
|
||||
"edu.ar",
|
||||
"gov.ar",
|
||||
"gob.ar",
|
||||
"mil.ar",
|
||||
// IL
|
||||
"co.il",
|
||||
"net.il",
|
||||
"org.il",
|
||||
"ac.il",
|
||||
"gov.il",
|
||||
"muni.il",
|
||||
"k12.il",
|
||||
// ZA
|
||||
"co.za",
|
||||
"net.za",
|
||||
"org.za",
|
||||
"ac.za",
|
||||
"gov.za",
|
||||
"edu.za",
|
||||
"law.za",
|
||||
// CN
|
||||
"com.cn",
|
||||
"net.cn",
|
||||
"org.cn",
|
||||
"edu.cn",
|
||||
"gov.cn",
|
||||
"ac.cn",
|
||||
"mil.cn",
|
||||
// TR
|
||||
"com.tr",
|
||||
"net.tr",
|
||||
"org.tr",
|
||||
"edu.tr",
|
||||
"gov.tr",
|
||||
"biz.tr",
|
||||
"info.tr",
|
||||
// RU
|
||||
"com.ru",
|
||||
"net.ru",
|
||||
"org.ru",
|
||||
"pp.ru",
|
||||
"msk.ru",
|
||||
"spb.ru",
|
||||
// PL
|
||||
"com.pl",
|
||||
"net.pl",
|
||||
"org.pl",
|
||||
"edu.pl",
|
||||
"gov.pl",
|
||||
"mil.pl",
|
||||
// ES
|
||||
"com.es",
|
||||
"nom.es",
|
||||
"org.es",
|
||||
"gob.es",
|
||||
"edu.es",
|
||||
// HK / SG / TW / MY
|
||||
"com.hk",
|
||||
"net.hk",
|
||||
"org.hk",
|
||||
"edu.hk",
|
||||
"gov.hk",
|
||||
"idv.hk",
|
||||
"com.sg",
|
||||
"net.sg",
|
||||
"org.sg",
|
||||
"edu.sg",
|
||||
"gov.sg",
|
||||
"per.sg",
|
||||
"com.tw",
|
||||
"net.tw",
|
||||
"org.tw",
|
||||
"edu.tw",
|
||||
"gov.tw",
|
||||
"idv.tw",
|
||||
"com.my",
|
||||
"net.my",
|
||||
"org.my",
|
||||
"edu.my",
|
||||
"gov.my",
|
||||
"mil.my",
|
||||
// Major hosting platforms — all tenants share root cert paths.
|
||||
"github.io",
|
||||
"github.com",
|
||||
"s3.amazonaws.com",
|
||||
"compute.amazonaws.com",
|
||||
"blogspot.com",
|
||||
"blogspot.co.uk",
|
||||
"blogspot.de",
|
||||
"blogspot.fr",
|
||||
"appspot.com",
|
||||
"googleapis.com",
|
||||
"googleusercontent.com",
|
||||
"herokuapp.com",
|
||||
"herokussl.com",
|
||||
"vercel.app",
|
||||
"now.sh",
|
||||
"netlify.app",
|
||||
"netlify.com",
|
||||
"web.app",
|
||||
"firebaseapp.com",
|
||||
"pages.dev",
|
||||
"workers.dev",
|
||||
"azurewebsites.net",
|
||||
"cloudapp.net",
|
||||
"trafficmanager.net",
|
||||
"fastly.net",
|
||||
"fastlylb.net",
|
||||
"cloudfront.net",
|
||||
"ngrok.io",
|
||||
"ngrok.app",
|
||||
"execute-api.us-east-1.amazonaws.com",
|
||||
)
|
||||
|
||||
private fun defaultTrustManager(): X509TrustManager {
|
||||
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
tmf.init(null as KeyStore?)
|
||||
|
||||
Reference in New Issue
Block a user