feat(quic): TLS 1.3 session resumption (PSK)

Closes the gap that left the runner's `resumption` testcase as the
last unsupported standard test. The TLS layer now:

- Derives `resumption_master_secret` (RFC 8446 §7.1) right after
  appending client Finished to the transcript. Cached on the key
  schedule so it can seed PSK derivations from any subsequent
  NewSessionTicket the server emits.

- Parses NewSessionTicket bodies (RFC 8446 §4.6.1) when they arrive
  post-handshake at Application level. For each ticket: derive the
  per-ticket PSK via `HKDF-Expand-Label(resumption_master_secret,
  "resumption", ticket_nonce, 32)` and surface a self-contained
  TlsResumptionState (ticket bytes + PSK + cipher suite + age-add +
  issued-at) through a new TlsSecretsListener.onNewSessionTicket
  callback. QuicConnection's tlsListener forwards to a public
  onResumptionTicket lambda the application sets.

- On a fresh TlsClient construction with a non-null `resumption`
  argument: seed the early secret from the cached PSK
  (`HKDF-Extract(IKM=PSK, salt=0)` — the Quartz Hkdf.extract
  signature is `(IKM, salt)` despite the misleading first-parameter
  name; non-PSK deriveEarly passes zeros for both so the order
  didn't matter and the bug only surfaced now), build the resumption
  ClientHello with `pre_shared_key` as the LAST extension carrying a
  single identity (the cached ticket) and a binder over the
  PartialClientHello, splice the binder bytes into the encoded
  message after a one-shot SHA-256 hash of bytes 0..len-35.

- State machine: when ServerHello carries `pre_shared_key` with the
  selected_identity we offered (we only ever send identity index 0,
  any other value is a hard fail), latch `pskAccepted = true`.
  WAITING_CERTIFICATE_OR_FINISHED then accepts Finished without the
  Certificate/CertificateVerify pair the full-handshake path
  requires — the PSK itself transitively authenticates the server
  via the prior issuing connection.

- If we offered PSK but the server didn't pick it (full-handshake
  fallback), hard-fail. The fallback path needs to clear the
  PSK-seeded early secret and re-run derivation against zeros, which
  is real work; the runner's resumption testcase requires server
  acceptance anyway, so this gate isn't load-bearing for matrix
  green. Production callers that care about the fallback can wire
  it later.

InteropClient adds a `runResumptionTest` that splits the runner's
URL list in half across two sequential connections — first runs a
full handshake and captures the NewSessionTicket via
onResumptionTicket, second runs the PSK handshake with the cached
state. ✓ R against aioquic, picoquic, quic-go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-07 18:14:58 -04:00
parent 3b3f735da7
commit fec917d27b
8 changed files with 735 additions and 21 deletions
@@ -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<Alpn>,
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<URI>,
downloadsDir: File,
cipherSuites: IntArray?,
offeredAlpns: List<Alpn>,
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).
*
@@ -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<Unit>()
@@ -525,6 +559,7 @@ class QuicConnection(
certificateValidator = tlsCertificateValidator,
offeredAlpns = alpnList,
cipherSuites = cipherSuites,
resumption = resumption,
)
init {
@@ -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(
@@ -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<ByteArray>,
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
}
@@ -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
@@ -145,3 +145,46 @@ fun encodeAlpn(protocols: List<ByteArray>): 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
@@ -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<TlsExtension>,
)
/**
* 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,
)
}
@@ -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)
}
/**