diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt index a5d060a3b..111d9b85b 100644 --- a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt @@ -268,6 +268,25 @@ fun main() { ) } + // resumption: two sequential connections — the second + // resumes via PSK from the NewSessionTicket the first + // captured. Runner verifies the second handshake's pcap + // doesn't carry a Certificate (server skipped it because + // the PSK already authenticated us). Different shape from + // multiconnect (which never resumes) so it has its own + // dispatch. + "resumption" -> { + runResumptionTest( + requests = requests, + downloadsDir = downloadsDir, + cipherSuites = cipherSuites, + offeredAlpns = offeredAlpns, + initialVersion = initialVersion, + keyLogPath = keyLogPath, + qlogDir = qlogDir, + ) + } + // keyupdate: same transfer flow but the client initiates a // RFC 9001 §6 1-RTT key update once the handshake is // confirmed. Runner verifies pcap shows BOTH client and @@ -640,6 +659,223 @@ private fun runTransferTest( } } +/** + * Two QUIC connections, second resumed via PSK (resumption testcase). + * + * The runner's `resumption` testcase verifies the pcap contains exactly + * 2 handshakes — the first carrying the server's Certificate, the second + * NOT. We split the request URLs in half, fetch the first half on a + * fresh connection (capturing the NewSessionTicket the server issues + * post-handshake), then open a second connection with the cached + * [TlsResumptionState]; the second handshake is PSK-bound so the server + * skips Certificate / CertificateVerify entirely. + * + * Per-iteration qlog files at `$QLOGDIR/client-N.sqlog` so a stuck + * connection leaves a focused trace. + */ +private fun runResumptionTest( + requests: String, + downloadsDir: File, + cipherSuites: IntArray?, + offeredAlpns: List, + initialVersion: Int, + keyLogPath: String?, + qlogDir: File?, +): Int { + val urls = + requests + .split(Regex("\\s+")) + .filter { it.isNotBlank() } + .map { runCatching { URI(it) }.getOrNull() } + .filter { it != null && it.host != null } + .map { it!! } + if (urls.size < 2) { + System.err.println("resumption needs at least 2 URLs (got ${urls.size})") + return EXIT_FAIL + } + + downloadsDir.mkdirs() + qlogDir?.mkdirs() + val keyLogger = keyLogPath?.let { SslKeyLogger(File(it)) } + + // Split request list in half: first half on the full-handshake + // connection, second half on the resumed PSK connection. + val splitAt = urls.size / 2 + val firstUrls = urls.subList(0, splitAt.coerceAtLeast(1)) + val secondUrls = urls.subList(splitAt.coerceAtLeast(1), urls.size) + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val outcome = + runBlocking { + // Connection 1 — full handshake, capture session ticket. + var capturedTicket: com.vitorpamplona.quic.tls.TlsResumptionState? = null + val outcome1 = + runOneResumptionConnection( + iterIdx = 0, + urls = firstUrls, + downloadsDir = downloadsDir, + cipherSuites = cipherSuites, + offeredAlpns = offeredAlpns, + initialVersion = initialVersion, + keyLogger = keyLogger, + qlogDir = qlogDir, + scope = scope, + resumption = null, + onTicket = { capturedTicket = it }, + ) + if (outcome1 != "ok") return@runBlocking "resumption[0] $outcome1" + if (capturedTicket == null) return@runBlocking "resumption[0] no_ticket" + + // Brief pause so the server fully tears down its connection + // state before we try to resume — picoquic in particular + // races the close-flush against the next ClientHello. + delay(50) + + // Connection 2 — PSK-resumed handshake. + val outcome2 = + runOneResumptionConnection( + iterIdx = 1, + urls = secondUrls, + downloadsDir = downloadsDir, + cipherSuites = cipherSuites, + offeredAlpns = offeredAlpns, + initialVersion = initialVersion, + keyLogger = keyLogger, + qlogDir = qlogDir, + scope = scope, + resumption = capturedTicket, + onTicket = { /* could refresh, but the test is done */ }, + ) + if (outcome2 != "ok") return@runBlocking "resumption[1] $outcome2" + "ok" + } + scope.cancel() + + return if (outcome == "ok") { + EXIT_OK + } else { + System.err.println("resumption $outcome") + EXIT_FAIL + } +} + +private suspend fun runOneResumptionConnection( + iterIdx: Int, + urls: List, + downloadsDir: File, + cipherSuites: IntArray?, + offeredAlpns: List, + initialVersion: Int, + keyLogger: SslKeyLogger?, + qlogDir: File?, + scope: CoroutineScope, + resumption: com.vitorpamplona.quic.tls.TlsResumptionState?, + onTicket: (com.vitorpamplona.quic.tls.TlsResumptionState) -> Unit, +): String { + val first = urls[0] + val host = first.host + val port = first.port.takeIf { it > 0 } ?: 443 + val authority = if (port == 443) host else "$host:$port" + + val socket = + try { + UdpSocket.connect(host, port) + } catch (t: Throwable) { + return "udp_failed: ${t.message ?: t::class.simpleName}" + } + val qlogWriter = + qlogDir?.let { dir -> + QlogWriter(file = File(dir, "client-$iterIdx.sqlog"), odcidHex = "client$iterIdx") + } + val conn = + QuicConnection( + serverName = host, + config = + QuicConnectionConfig( + initialMaxData = 32L * 1024 * 1024, + initialMaxStreamDataBidiLocal = 32L * 1024 * 1024, + initialMaxStreamDataBidiRemote = 32L * 1024 * 1024, + initialMaxStreamDataUni = 32L * 1024 * 1024, + ), + tlsCertificateValidator = PermissiveCertificateValidator(), + alpnList = offeredAlpns.map { it.wireBytes }, + initialVersion = initialVersion, + cipherSuites = + cipherSuites + ?: intArrayOf( + TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256, + TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256, + ), + extraSecretsListener = keyLogger?.listener, + qlogObserver = qlogWriter ?: com.vitorpamplona.quic.observability.QlogObserver.NoOp, + resumption = resumption, + onResumptionTicket = onTicket, + ) + val driver = QuicConnectionDriver(conn, socket, scope) + driver.start() + + val handshake = + withTimeoutOrNull(HANDSHAKE_TIMEOUT_SEC * 1_000L) { + runCatching { conn.awaitHandshake() } + } + if (handshake == null || handshake.isFailure) { + runCatching { driver.close() } + conn.tls.clientRandom?.let { keyLogger?.flush(it) } + runCatching { qlogWriter?.close() } + return "handshake_failed" + } + + val negotiated = + conn.tls.negotiatedAlpn + ?.decodeToString() + .orEmpty() + val client: GetClient = + when (negotiated) { + "h3" -> { + Http3GetClient(conn, driver).also { it.init(scope) } + } + + "hq-interop" -> { + HqInteropGetClient(conn, driver) + } + + else -> { + System.err.println("[resumption:$iterIdx] unrecognized ALPN '$negotiated'; defaulting to hq-interop") + HqInteropGetClient(conn, driver) + } + } + + var anyFailed = false + for (url in urls) { + val resp = + withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) { + client.get(authority, url.path) + } + if (resp == null) { + anyFailed = true + System.err.println("[resumption:$iterIdx] GET ${url.path} → timeout") + break + } + if (resp.status != 200) { + System.err.println("[resumption:$iterIdx] GET ${url.path} → status ${resp.status}") + anyFailed = true + continue + } + val name = url.path.substringAfterLast('/').ifBlank { "index" } + File(downloadsDir, name).writeBytes(resp.body) + } + + // Give the server a chance to send its NewSessionTicket — picoquic + // in particular emits the ticket a few hundred ms post-handshake, + // sometimes alongside the response stream's FIN. + delay(200) + + runCatching { driver.close() } + conn.tls.clientRandom?.let { keyLogger?.flush(it) } + runCatching { qlogWriter?.close() } + return if (anyFailed) "request_failed" else "ok" +} + /** * One QUIC connection per URL (handshakeloss / handshakecorruption). * diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt index 32d33a91d..8def4a514 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -106,6 +106,35 @@ class QuicConnection( * produces a `client.sqlog` consumable by qvis. */ val qlogObserver: QlogObserver = QlogObserver.NoOp, + /** + * Resumption state from a prior connection — when non-null, this + * connection's ClientHello will offer a `pre_shared_key` extension + * referencing the cached ticket, and the key schedule will seed + * the early secret from the cached PSK rather than zeros (RFC 8446 + * §7.1). On a successful PSK negotiation the server skips + * Certificate / CertificateVerify and we save a round-trip plus + * ~1 KB of cert bytes. + * + * Caller produces this from [onResumptionTicket] on a previous + * connection. The interop runner's `resumption` testcase exercises + * exactly this: connection 1 receives a NewSessionTicket and + * stashes the state, connection 2 reuses it. + */ + val resumption: com.vitorpamplona.quic.tls.TlsResumptionState? = null, + /** + * Hook invoked when the server issues a NewSessionTicket. The TLS + * layer derives the per-ticket PSK and surfaces a fully-formed + * [com.vitorpamplona.quic.tls.TlsResumptionState]; the QUIC + * connection passes it through here so the application can stash it + * (e.g. in a per-host resumption cache) for a future reconnect. + * + * Default no-op so existing callers compile unchanged. Servers + * routinely issue 1-2 tickets per connection — the callback may + * fire more than once, and the application is free to keep all of + * them (a small per-host LRU is the typical shape) or just the + * latest. + */ + val onResumptionTicket: ((com.vitorpamplona.quic.tls.TlsResumptionState) -> Unit)? = null, ) { val sourceConnectionId: ConnectionId = ConnectionId.random(8) var destinationConnectionId: ConnectionId = ConnectionId.random(8) @@ -500,6 +529,11 @@ class QuicConnection( handshakeDoneSignal.complete(Unit) extraSecretsListener?.onHandshakeComplete() } + + override fun onNewSessionTicket(state: com.vitorpamplona.quic.tls.TlsResumptionState) { + onResumptionTicket?.invoke(state) + extraSecretsListener?.onNewSessionTicket(state) + } } private val handshakeDoneSignal = CompletableDeferred() @@ -525,6 +559,7 @@ class QuicConnection( certificateValidator = tlsCertificateValidator, offeredAlpns = alpnList, cipherSuites = cipherSuites, + resumption = resumption, ) init { diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt index 910e55255..d5bdac7b6 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt @@ -81,6 +81,32 @@ class TlsClient( TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256, TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256, ), + /** + * Wall-clock provider for the NewSessionTicket [issuedAtMillis] stamp. + * Tests inject a fixed clock; production uses System.currentTimeMillis(). + * Stored at issue-time only — the obfuscated_ticket_age the next + * connection emits is `(now_at_resume - issuedAtMillis + ticketAgeAdd) + * mod 2^32`, so a slightly stale clock skew is harmless (the server + * de-obfuscates and only cares about its own tracked age window). + */ + val nowMillisSource: () -> Long = { System.currentTimeMillis() }, + /** + * If non-null, this connection resumes a prior session via PSK rather + * than running a full handshake. The TLS layer: + * - Seeds the early secret from [TlsResumptionState.psk] instead of + * zeros (RFC 8446 §7.1). + * - Adds `pre_shared_key` as the last ClientHello extension, with + * the cached ticket as the identity and a binder computed over + * the partial ClientHello. + * - Tolerates the server skipping Certificate / CertificateVerify + * when it accepts the PSK (server only sends them on full + * handshakes). + * + * Caller's responsibility to ensure the resumption state is fresh + * (within `ticket_lifetime` seconds of issue) and bound to a cipher + * suite this client offers. + */ + val resumption: TlsResumptionState? = null, ) { enum class State { INITIAL, @@ -131,6 +157,15 @@ class TlsClient( private var sharedSecret: ByteArray? = null private var negotiatedCipherSuite: Int = -1 + /** + * True after ServerHello carries a `pre_shared_key` extension with the + * `selected_identity` we offered. When set, the state machine accepts + * Finished immediately after EncryptedExtensions (no Certificate / + * CertificateVerify path) and the application-traffic secrets are + * computed off the PSK-seeded early secret rather than zeros. + */ + private var pskAccepted: Boolean = false + /** The 32-byte ClientHello random, available after [start]. Exposed so * observers (e.g. SSLKEYLOGFILE writer) can correlate secrets with * this connection. */ @@ -142,24 +177,72 @@ class TlsClient( check(state == State.INITIAL) { "TlsClient already started" } keyPair = fixedKeyPair ?: X25519.generateKeyPair() - keySchedule.deriveEarly() + // Seed the early secret. Resumption path uses the cached PSK as + // IKM (RFC 8446 §7.1) so the binder for the pre_shared_key + // extension can be derived from the same early secret the server + // will use to validate it. Non-resumption path uses zero IKM. + if (resumption != null) { + keySchedule.deriveEarlyFromPsk(resumption.psk) + } else { + keySchedule.deriveEarly() + } val random = fixedRandom ?: com.vitorpamplona.quartz.utils.RandomInstance .bytes(32) clientRandom = random - val ch = - buildQuicClientHello( - serverName = serverName, - x25519PublicKey = keyPair!!.publicKey, - quicTransportParams = transportParameters, - alpns = offeredAlpns, - random = random, - cipherSuites = cipherSuites, - ) + val chBytes = + if (resumption != null) { + // Resumption ClientHello carries pre_shared_key (last + // extension per spec) with binder bound to the partial CH + // hash. obfuscated_ticket_age = (current_age + ticket_age_add) + // mod 2^32. We use the local clock — server's de-obfuscation + // only cares about its own tracked age window. + val ageMillis = (nowMillisSource() - resumption.issuedAtMillis).coerceAtLeast(0L) + val obfuscatedAge = (ageMillis + resumption.ticketAgeAdd) and 0xFFFFFFFFL + val binderFinishedKey = pskBinderFinishedKey(keySchedule.earlySecret!!) + buildResumptionClientHelloBytes( + serverName = serverName, + x25519PublicKey = keyPair!!.publicKey, + quicTransportParams = transportParameters, + alpns = offeredAlpns, + random = random, + cipherSuites = cipherSuites, + ticket = resumption.ticket, + obfuscatedTicketAge = obfuscatedAge, + binderFinishedKey = binderFinishedKey, + transcriptHashOfPartialCh = { partial -> + // Hash a one-shot copy of the running transcript + // would-be-state: an empty TlsRunningSha256 fed + // partial-CH-bytes is identical to running the + // shared transcript "as if" we'd appended + // partial-CH and snapshotted. + val h = TlsRunningSha256() + h.update(partial) + h.snapshot() + }, + binderHmac = { key, data -> + val mac = + com.vitorpamplona.quartz.utils.mac + .MacInstance("HmacSHA256", key) + mac.update(data) + mac.doFinal() + }, + ) + } else { + val ch = + buildQuicClientHello( + serverName = serverName, + x25519PublicKey = keyPair!!.publicKey, + quicTransportParams = transportParameters, + alpns = offeredAlpns, + random = random, + cipherSuites = cipherSuites, + ) + ch.encode() + } - val chBytes = ch.encode() transcript.append(chBytes) outboundQueues[Level.INITIAL]!!.addLast(chBytes) state = State.WAITING_SERVER_HELLO @@ -235,6 +318,36 @@ class TlsClient( } negotiatedCipherSuite = cipher serverKeyShare = sh.serverKeyShareX25519 + + // RFC 8446 §4.2.11 — server signals PSK acceptance with a + // pre_shared_key extension carrying selected_identity (uint16). + // We only ever offer one identity, so anything other than 0 + // is a protocol violation. + val pskExt = sh.extensions.firstOrNull { it.type == TlsConstants.EXT_PRE_SHARED_KEY } + if (resumption != null) { + if (pskExt == null) { + // We offered PSK but server picked full-handshake. + // Plumbing for the fallback path (clear early secret, + // re-run binder-less ClientHello transcript) is real + // work; for now hard-fail. In production we'd want + // to handle gracefully — for the runner's resumption + // testcase the server MUST accept or the test fails + // anyway, so this gate isn't load-bearing. + throw QuicCodecException( + "server rejected PSK; full-handshake fallback not implemented", + ) + } + 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") + } transcript.append(msg) val privKey = keyPair!!.privateKey @@ -282,16 +395,26 @@ class TlsClient( } TlsConstants.HS_FINISHED -> { - // Audit-4 #3: we never offer a `pre_shared_key` - // extension, so a server MUST send Certificate + - // CertificateVerify. A Finished here means a - // misbehaving server (or an MITM that stripped the - // cert messages). Hard-fail rather than completing - // a handshake with no peer authentication. - throw QuicCodecException( - "server skipped Certificate/CertificateVerify but we never offered PSK " + - "(unauthenticated handshake refused)", - ) + if (!pskAccepted) { + // Audit-4 #3: we never offered (or never had + // accepted) a `pre_shared_key` extension, so a + // server MUST send Certificate + + // CertificateVerify. A Finished here means a + // misbehaving server (or an MITM that stripped + // the cert messages). Hard-fail rather than + // completing a handshake with no peer + // authentication. + throw QuicCodecException( + "server skipped Certificate/CertificateVerify but we never offered PSK " + + "(unauthenticated handshake refused)", + ) + } + // Resumption path: server accepted our PSK so it + // skips Certificate / CertificateVerify (the PSK + // itself authenticates the server through the + // earlier full handshake that issued the ticket). + // Process Finished directly. + handleServerFinished(msg, bodyReader, len) } else -> { @@ -327,6 +450,25 @@ class TlsClient( TlsConstants.HS_NEW_SESSION_TICKET -> { // Don't append to transcript — NewSessionTicket is not // part of the handshake transcript per RFC 8446 §4.4.1. + // Parse the ticket, derive a PSK from it + + // resumption_master_secret, and surface a + // [TlsResumptionState] to the listener so the QUIC + // layer can stash it for the next connection. + val rms = keySchedule.resumptionMasterSecret + if (rms != null) { + val ticket = parseNewSessionTicketBody(bodyReader) + val psk = resumptionPsk(rms, ticket.nonce) + secretsListener.onNewSessionTicket( + TlsResumptionState( + ticket = ticket.ticket, + psk = psk, + cipherSuite = currentCipherSuite(), + ticketAgeAdd = ticket.ticketAgeAdd, + ticketLifetimeSec = ticket.ticketLifetimeSec, + issuedAtMillis = nowMillisSource(), + ), + ) + } } TlsConstants.HS_KEY_UPDATE -> { @@ -377,6 +519,12 @@ class TlsClient( transcript.append(cfBytes) outboundQueues[Level.HANDSHAKE]!!.addLast(cfBytes) + // Derive resumption_master_secret AFTER appending client Finished — + // RFC 8446 §7.1 binds it to H(CH..client_Finished). Future + // NewSessionTicket frames will derive their PSKs off this secret + // plus the server-supplied ticket_nonce. + keySchedule.deriveResumption(transcript.snapshot()) + state = State.SENT_CLIENT_FINISHED secretsListener.onHandshakeComplete() } @@ -402,8 +550,57 @@ interface TlsSecretsListener { ) fun onHandshakeComplete() + + /** + * Server issued a NewSessionTicket. The TLS layer hands off a + * ready-to-use [TlsResumptionState] capturing everything the next + * connection needs for PSK-based resumption: the opaque ticket, the + * derived PSK, the cipher suite the secret was bound to, and the + * obfuscation parameters. The QUIC layer's only job is to stash this + * somewhere the next [TlsClient] construction can read it from. + * + * Default no-op so existing callers (which don't care about + * resumption) compile unchanged. Multiple invocations are possible + * — RFC 8446 lets servers issue several tickets per connection. The + * caller may keep all of them or just the latest; for the + * quic-interop-runner `resumption` testcase keeping the latest + * suffices. + */ + fun onNewSessionTicket(state: TlsResumptionState) = Unit } +/** + * Self-contained state needed to resume a TLS 1.3 session via PSK on the + * next connection. Produced by the TLS layer when a NewSessionTicket + * arrives; consumed by a fresh [TlsClient] via its `resumption` + * constructor argument. + * + * Why store all this rather than just the ticket: the PSK derivation + * binds to a specific cipher suite (32-byte hash for our SHA-256 suites) + * so we can't re-derive on the fly without that suite, and the + * obfuscation arithmetic on the next connection needs both + * [ticketAgeAdd] and [issuedAtMillis] to compute the obfuscated_ticket_age + * the server expects. + */ +data 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. */ + val psk: ByteArray, + /** + * Cipher suite this PSK is bound to. The next connection MUST offer + * (at least) this suite or the server will fall back to a full + * handshake. + */ + val cipherSuite: Int, + /** Server-supplied obfuscation factor (RFC 8446 §4.6.1) — added to the elapsed-since-issue ticket age. */ + val ticketAgeAdd: Long, + /** Server-supplied lifetime hint in seconds. Tickets expire after this; the client SHOULD discard. */ + val ticketLifetimeSec: Long, + /** Wall-clock millis when the ticket was issued (server time, but we use ours — the obfuscation makes the absolute clock irrelevant). */ + val issuedAtMillis: Long, +) + /** Pluggable certificate validator. Decoupled so we can stub it in tests. */ interface CertificateValidator { fun validateChain( diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt index 9e5da10fa..df77a3361 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt @@ -119,3 +119,75 @@ fun buildQuicClientHello( ) return TlsClientHello(random = random, cipherSuites = cipherSuites, extensions = exts) } + +/** + * Build a resumption ClientHello (PSK-bound) and return the encoded bytes + * with the binder field already substituted in. + * + * Layout differs from [buildQuicClientHello] in two ways: + * - The pre_shared_key extension MUST be the LAST extension per + * RFC 8446 §4.2.11. Reorder the list accordingly. + * - The binder is HMAC-finished_key over the partial ClientHello + * (everything BEFORE the binders field). Two-pass: encode with + * binder=zeros, hash partial CH, compute binder, splice it in. + * + * The caller provides: + * - [resumption]: ticket + obfuscation factor + cipher suite from the + * prior connection's NewSessionTicket. + * - [binderFinishedKey]: derived from `early_secret` via + * [pskBinderFinishedKey] (the QUIC layer's responsibility). + * - [transcriptHashOfPartialCh]: a function (bytesUpToBinder) -> ByteArray + * that hashes the partial ClientHello using the negotiated suite's + * hash. We don't know the hash inside this function (no transcript + * state); the caller injects it. + * + * Returns the FULL encoded handshake message bytes (handshake header + * included) ready to feed into a CRYPTO frame. Caller appends to its + * own transcript. + */ +fun buildResumptionClientHelloBytes( + serverName: String, + x25519PublicKey: ByteArray, + quicTransportParams: ByteArray, + alpns: List, + random: ByteArray, + cipherSuites: IntArray, + ticket: ByteArray, + obfuscatedTicketAge: Long, + binderFinishedKey: ByteArray, + transcriptHashOfPartialCh: (ByteArray) -> ByteArray, + binderHmac: (key: ByteArray, data: ByteArray) -> ByteArray, +): ByteArray { + val exts = + listOf( + TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName)), + TlsExtension(TlsConstants.EXT_SUPPORTED_VERSIONS, encodeSupportedVersionsExtensionClient()), + TlsExtension(TlsConstants.EXT_SUPPORTED_GROUPS, encodeSupportedGroupsX25519()), + TlsExtension(TlsConstants.EXT_SIGNATURE_ALGORITHMS, encodeSignatureAlgorithms()), + TlsExtension(TlsConstants.EXT_KEY_SHARE, encodeKeyShareClientX25519(x25519PublicKey)), + TlsExtension(TlsConstants.EXT_PSK_KEY_EXCHANGE_MODES, encodePskKeyExchangeModesDhe()), + TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpns)), + TlsExtension(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS, quicTransportParams), + // pre_shared_key MUST be last (RFC 8446 §4.2.11). + TlsExtension( + TlsConstants.EXT_PRE_SHARED_KEY, + encodePreSharedKeyPlaceholder(ticket, obfuscatedTicketAge), + ), + ) + val ch = TlsClientHello(random = random, cipherSuites = cipherSuites, extensions = exts) + val withPlaceholder = ch.encode() + // PartialClientHello = encoded bytes minus the trailing binders block + // (uint16 outer length + uint8 inner length + 32 binder bytes for one + // SHA-256 PSK). + val partialCh = withPlaceholder.copyOfRange(0, withPlaceholder.size - BINDERS_TRAILING_BYTES) + val transcriptHash = transcriptHashOfPartialCh(partialCh) + val binder = binderHmac(binderFinishedKey, transcriptHash) + require(binder.size == BINDER_BYTES) { + "binder size ${binder.size} != expected $BINDER_BYTES" + } + // Splice the binder bytes into the placeholder zeros: the binder + // sits at the very end of the encoded message, after the + // 3-byte trailer (uint16 outer + uint8 inner length). + binder.copyInto(withPlaceholder, withPlaceholder.size - BINDER_BYTES) + return withPlaceholder +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsConstants.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsConstants.kt index a78ebf3d2..fa9e87506 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsConstants.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsConstants.kt @@ -54,6 +54,8 @@ object TlsConstants { const val EXT_SIGNATURE_ALGORITHMS: Int = 13 const val EXT_ALPN: Int = 16 const val EXT_SUPPORTED_VERSIONS: Int = 43 + const val EXT_PRE_SHARED_KEY: Int = 41 + const val EXT_EARLY_DATA: Int = 42 const val EXT_PSK_KEY_EXCHANGE_MODES: Int = 45 const val EXT_KEY_SHARE: Int = 51 diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt index 4e6a7070b..1f66ef0a4 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt @@ -145,3 +145,46 @@ fun encodeAlpn(protocols: List): ByteArray { } return w.toByteArray() } + +/** + * Encode the `pre_shared_key` extension body with a single PSK identity + * and a placeholder binder (zeros). RFC 8446 §4.2.11. The caller MUST + * substitute the real binder bytes into the trailing 32 bytes of the + * encoded ClientHello AFTER hashing the partial CH up to the binder + * field — see [pskBinderHashRangeEnd] for the offset. + * + * One identity, one binder, SHA-256 (binder is 32 bytes). Wire layout: + * + * identities<7..2^16-1>: + * opaque identity<1..2^16-1>; // ticket bytes + * uint32 obfuscated_ticket_age; + * binders<33..2^16-1>: + * opaque PskBinderEntry<32..255>; // 32 zero bytes for now + */ +fun encodePreSharedKeyPlaceholder( + ticket: ByteArray, + obfuscatedTicketAge: Long, +): ByteArray { + val w = QuicWriter() + w.withUint16Length { + // identities list + writeTlsOpaque2(ticket) + writeUint32(obfuscatedTicketAge.toInt()) + } + w.withUint16Length { + // binders list — one PskBinderEntry of 32 zero bytes + writeTlsOpaque1(ByteArray(BINDER_BYTES)) + } + return w.toByteArray() +} + +/** RFC 8446 §4.2.11.2 — SHA-256 binder size for our cipher suites. */ +const val BINDER_BYTES: Int = 32 + +/** + * Trailing-byte count of the encoded binders field in a one-PSK-identity + * ClientHello: `[uint16 outer_length][uint8 inner_length][32 binder bytes]` + * = 35. The transcript hash for binder computation is the ClientHello + * bytes excluding this trailing region. + */ +const val BINDERS_TRAILING_BYTES: Int = 2 + 1 + BINDER_BYTES diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsHandshakeMessages.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsHandshakeMessages.kt index c4aba7c10..1a2e45ada 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsHandshakeMessages.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsHandshakeMessages.kt @@ -153,3 +153,49 @@ data class TlsFinished( ): TlsFinished = TlsFinished(r.readBytes(length)) } } + +/** + * Parsed NewSessionTicket message body (RFC 8446 §4.6.1). Wire layout: + * + * uint32 ticket_lifetime; + * uint32 ticket_age_add; + * opaque ticket_nonce<0..255>; + * opaque ticket<1..2^16-1>; + * Extension extensions<0..2^16-2>; + * + * The QUIC layer derives the per-ticket PSK from + * [com.vitorpamplona.quic.tls.resumptionPsk] using `resumption_master_secret` + * + [nonce] and stashes the [ticket] verbatim as the identity for the + * pre_shared_key extension on a future resumed connection. + * + * Extensions are decoded but not yet acted on. The two interesting ones for + * a 0-RTT-capable client would be `early_data` (signals the server is + * willing to accept 0-RTT data with this PSK) and (in HTTP/3) `max_early_data`; + * we surface raw extensions for callers that want to inspect them. + */ +data class TlsNewSessionTicket( + val ticketLifetimeSec: Long, + val ticketAgeAdd: Long, + val nonce: ByteArray, + val ticket: ByteArray, + val extensions: List, +) + +/** + * Parse a NewSessionTicket body (the bytes after the 4-byte handshake + * header has already been consumed by the caller's framing loop). + */ +fun parseNewSessionTicketBody(r: QuicReader): TlsNewSessionTicket { + val lifetime = r.readUint32().toLong() and 0xFFFFFFFFL + val ageAdd = r.readUint32().toLong() and 0xFFFFFFFFL + val nonce = r.readTlsOpaque1() + val ticket = r.readTlsOpaque2() + val extensions = TlsExtension.decodeList(r) + return TlsNewSessionTicket( + ticketLifetimeSec = lifetime, + ticketAgeAdd = ageAdd, + nonce = nonce, + ticket = ticket, + extensions = extensions, + ) +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsKeySchedule.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsKeySchedule.kt index 8dc626ee5..b69504a96 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsKeySchedule.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsKeySchedule.kt @@ -59,12 +59,46 @@ class TlsKeySchedule( var serverApplicationSecret: ByteArray? = null private set + /** + * RFC 8446 §7.1 resumption master secret. Derived AFTER client Finished + * is sent (transcript = CH..client.Finished). Used as the input keying + * material for the [resumptionPsk] computation when the server later + * issues a NewSessionTicket — that PSK is the value the client offers + * back via the pre_shared_key extension on the next connection. + * + * Derived lazily by [deriveResumption] which the QUIC layer calls right + * after handing the client Finished bytes off to the writer. The TLS + * layer caches the secret here so multiple NewSessionTickets (servers + * routinely send a few) all derive PSKs from the same base. + */ + var resumptionMasterSecret: ByteArray? = null + private set + /** Step 1: derive the Early Secret. PSK is all-zeros for non-resumption. */ fun deriveEarly() { val zeros = ByteArray(32) earlySecret = HKDF.extract(zeros, zeros) } + /** + * Step 1' (resumption path): derive the Early Secret from a PSK rather + * than zeros. The QUIC layer calls this on resumed connections when + * the caller passes in a [TlsResumptionState] from a prior connection. + * For non-resumption [deriveEarly] is the equivalent zero-keyed call. + * + * RFC 8446 §7.1: `Early Secret = HKDF-Extract(0, PSK)` — salt is + * zeros, IKM is the PSK. [HKDF.extract]'s signature is + * `extract(IKM, salt)` (despite the misleading first-parameter name + * in the Quartz Hkdf class — the IMPLEMENTATION uses the second + * arg as the MAC key per RFC 5869), so the call is + * `extract(psk, zeros)`. The non-PSK [deriveEarly] passes zeros for + * both so the order didn't matter there. + */ + fun deriveEarlyFromPsk(psk: ByteArray) { + val zeros = ByteArray(32) + earlySecret = HKDF.extract(psk, zeros) + } + /** Step 2: derive Handshake Secret using ECDHE shared secret. */ fun deriveHandshake(ecdheSharedSecret: ByteArray) { val early = earlySecret ?: error("call deriveEarly first") @@ -94,6 +128,55 @@ class TlsKeySchedule( clientApplicationSecret = deriveSecret(ms, "c ap traffic", transcriptHash) serverApplicationSecret = deriveSecret(ms, "s ap traffic", transcriptHash) } + + /** + * Step 6 (resumption path): derive [resumptionMasterSecret] from the + * Master Secret + transcript-up-to-client-Finished. RFC 8446 §7.1: + * + * resumption_master_secret = Derive-Secret(Master, "res master", + * H(CH..client_Finished)) + * + * Caller passes the post-Finished transcript hash explicitly so the + * key schedule doesn't have to track which transcript snapshot it + * needs (the schedule's [transcript] holds the latest, but only if + * the caller appended client Finished before calling — which the + * TlsClient does in its [TlsClient.handleServerFinished] just-after- + * Finished branch). + */ + fun deriveResumption(transcriptAfterClientFinished: ByteArray) { + val ms = masterSecret ?: error("call deriveMaster first") + resumptionMasterSecret = deriveSecret(ms, "res master", transcriptAfterClientFinished) + } +} + +/** + * RFC 8446 §4.6.1 — derive the per-ticket PSK from the + * [TlsKeySchedule.resumptionMasterSecret] plus the server-supplied + * `ticket_nonce`. The same `resumption_master_secret` can issue many + * PSKs (one per NewSessionTicket); each one is keyed off its nonce. + * + * PSK = HKDF-Expand-Label(resumption_master_secret, "resumption", + * ticket_nonce, Hash.length) + * + * Hash.length is 32 for SHA-256 — the only hash the QUIC v1 cipher + * suites use. + */ +fun resumptionPsk( + resumptionMasterSecret: ByteArray, + ticketNonce: ByteArray, +): ByteArray = HKDF.expandLabel(resumptionMasterSecret, "resumption", ticketNonce, 32) + +/** + * RFC 8446 §4.2.11.2 — the binder finished_key, derived from the early + * secret. The PSK extension's binder is HMAC(finished_key, + * transcript_hash_up_to_partial_CH). + * + * binder_key = Derive-Secret(early_secret, "res binder", H("")) + * finished_key = HKDF-Expand-Label(binder_key, "finished", "", 32) + */ +fun pskBinderFinishedKey(earlySecret: ByteArray): ByteArray { + val binderKey = deriveSecret(earlySecret, "res binder", EMPTY_SHA256) + return expandLabel(binderKey, "finished", 32) } /**