fix(quic): PSL subset, JCA ChaCha20-Poly1305, truthful ECN reporting

Round 13 — three architectural follow-ups + a closeout note.

* JdkCertificateValidator: ship a hand-picked Public Suffix List
  subset covering the high-volume multi-label effective-TLDs
  (multi-tenant ccTLDs and major hosting platforms). Pre-fix the
  dot-count heuristic accepted `*.co.uk`, `*.s3.amazonaws.com`,
  `*.github.io`, etc. — wildcards spanning these would impersonate
  every co-tenant. The new MULTI_LABEL_PUBLIC_SUFFIXES set adds a
  layer above the dot-count check; combined with the WebPKI / CT
  ecosystem already requiring CAs to consult the full PSL when
  issuing, this closes the practical attack surfaces. Full
  ~9000-entry PSL data shipping is still deferred (data-shipping
  ask, doc'd); a domain not in the subset that's also a multi-label
  ETLD remains a gap.

* JcaChaCha20Poly1305Aead: new JCA-backed implementation mirroring
  JcaAesGcmAead's shape (cached Cipher + SecretKeySpec, range
  overloads via Cipher.doFinal(input, off, len, output, outOff),
  recent-nonce history for legitimate IV reuse on the
  Initial-padding rebuild path). bestChaCha20Poly1305Aead(key)
  expect/actual factory tries the JCA path first (Java 11+ /
  Android API 28+) and falls back to the pure-Kotlin
  ChaCha20Poly1305Aead singleton if the algorithm isn't available
  (older Android, headless GraalVM native-image without the
  standard providers). PacketProtectionBuilder routes the
  ChaCha20-Poly1305 cipher suite through the factory instead of
  the singleton. On supporting platforms this gives the same
  outbound-allocation savings as round 8's AES-GCM range overload.

* QuicConnectionWriter: stop emitting fake ECN counts on ACK
  frames. Pre-fix every 1-RTT ACK carried `AckEcnCounts(0, 0, 0)`
  — claiming to track ECN while actually never reading inbound TOS
  bits. RFC 9000 §13.4.2: "An endpoint that uses ECN MUST report
  accurate ECN counts." Hardcoded zeros could be flagged as a
  PROTOCOL_VIOLATION by strict peers cross-validating against
  outbound packet counts; aioquic / picoquic / quic-go tolerate
  it but other stacks may not. With ecnCounts = null we honestly
  advertise "this endpoint isn't reporting ECN", peer 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); RFC 9000 §13.4 allows the asymmetry.

* MutableSharedFlow migration for QuicStream.incoming declined as
  obviated. The audit's suggestion was a workaround for the
  cancel-coupling specifically (collector cancel → channel cancel
  → INTERNAL_ERROR) — round 11's `flow { for (c in
  incomingChannel) emit(c) }` wrapper solved that. Switching to
  MutableSharedFlow would change the semantics from "each byte to
  exactly one consumer" (correct for stream bytes) to fan-out
  (every emission to all collectors), which is wrong for QUIC
  stream data.

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 40s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
This commit is contained in:
Claude
2026-05-09 01:06:32 +00:00
parent 953869714b
commit df6103ffdd
6 changed files with 500 additions and 44 deletions
@@ -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)
}
@@ -622,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)
}
}
@@ -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
@@ -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?)