diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PacketProtectionBuilder.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PacketProtectionBuilder.kt index d841be5f9a..a3b01197e0 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PacketProtectionBuilder.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PacketProtectionBuilder.kt @@ -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) } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt index 1aeaf05fa9..20ec318212 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt @@ -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) } } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt index eef93d1562..e13c8e4af3 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt @@ -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 diff --git a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaChaCha20Poly1305Aead.kt b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaChaCha20Poly1305Aead.kt new file mode 100644 index 0000000000..a2c45705e1 --- /dev/null +++ b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaChaCha20Poly1305Aead.kt @@ -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() + + 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 { + 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) + } +} diff --git a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt index 691df086b4..1d593a3648 100644 --- a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt +++ b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/PlatformCrypto.kt @@ -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 + } diff --git a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/tls/JdkCertificateValidator.kt b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/tls/JdkCertificateValidator.kt index 5718ba443d..74ec4ed9b1 100644 --- a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/tls/JdkCertificateValidator.kt +++ b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/tls/JdkCertificateValidator.kt @@ -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 = + 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?)