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:
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user