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
|
// keyupdate: same transfer flow but the client initiates a
|
||||||
// RFC 9001 §6 1-RTT key update once the handshake is
|
// RFC 9001 §6 1-RTT key update once the handshake is
|
||||||
// confirmed. Runner verifies pcap shows BOTH client and
|
// 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).
|
* One QUIC connection per URL (handshakeloss / handshakecorruption).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -106,6 +106,35 @@ class QuicConnection(
|
|||||||
* produces a `client.sqlog` consumable by qvis.
|
* produces a `client.sqlog` consumable by qvis.
|
||||||
*/
|
*/
|
||||||
val qlogObserver: QlogObserver = QlogObserver.NoOp,
|
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)
|
val sourceConnectionId: ConnectionId = ConnectionId.random(8)
|
||||||
var destinationConnectionId: ConnectionId = ConnectionId.random(8)
|
var destinationConnectionId: ConnectionId = ConnectionId.random(8)
|
||||||
@@ -500,6 +529,11 @@ class QuicConnection(
|
|||||||
handshakeDoneSignal.complete(Unit)
|
handshakeDoneSignal.complete(Unit)
|
||||||
extraSecretsListener?.onHandshakeComplete()
|
extraSecretsListener?.onHandshakeComplete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewSessionTicket(state: com.vitorpamplona.quic.tls.TlsResumptionState) {
|
||||||
|
onResumptionTicket?.invoke(state)
|
||||||
|
extraSecretsListener?.onNewSessionTicket(state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val handshakeDoneSignal = CompletableDeferred<Unit>()
|
private val handshakeDoneSignal = CompletableDeferred<Unit>()
|
||||||
@@ -525,6 +559,7 @@ class QuicConnection(
|
|||||||
certificateValidator = tlsCertificateValidator,
|
certificateValidator = tlsCertificateValidator,
|
||||||
offeredAlpns = alpnList,
|
offeredAlpns = alpnList,
|
||||||
cipherSuites = cipherSuites,
|
cipherSuites = cipherSuites,
|
||||||
|
resumption = resumption,
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|||||||
@@ -81,6 +81,32 @@ class TlsClient(
|
|||||||
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256,
|
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256,
|
||||||
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_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 {
|
enum class State {
|
||||||
INITIAL,
|
INITIAL,
|
||||||
@@ -131,6 +157,15 @@ class TlsClient(
|
|||||||
private var sharedSecret: ByteArray? = null
|
private var sharedSecret: ByteArray? = null
|
||||||
private var negotiatedCipherSuite: Int = -1
|
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
|
/** The 32-byte ClientHello random, available after [start]. Exposed so
|
||||||
* observers (e.g. SSLKEYLOGFILE writer) can correlate secrets with
|
* observers (e.g. SSLKEYLOGFILE writer) can correlate secrets with
|
||||||
* this connection. */
|
* this connection. */
|
||||||
@@ -142,24 +177,72 @@ class TlsClient(
|
|||||||
check(state == State.INITIAL) { "TlsClient already started" }
|
check(state == State.INITIAL) { "TlsClient already started" }
|
||||||
keyPair = fixedKeyPair ?: X25519.generateKeyPair()
|
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 =
|
val random =
|
||||||
fixedRandom ?: com.vitorpamplona.quartz.utils.RandomInstance
|
fixedRandom ?: com.vitorpamplona.quartz.utils.RandomInstance
|
||||||
.bytes(32)
|
.bytes(32)
|
||||||
clientRandom = random
|
clientRandom = random
|
||||||
|
|
||||||
val ch =
|
val chBytes =
|
||||||
buildQuicClientHello(
|
if (resumption != null) {
|
||||||
serverName = serverName,
|
// Resumption ClientHello carries pre_shared_key (last
|
||||||
x25519PublicKey = keyPair!!.publicKey,
|
// extension per spec) with binder bound to the partial CH
|
||||||
quicTransportParams = transportParameters,
|
// hash. obfuscated_ticket_age = (current_age + ticket_age_add)
|
||||||
alpns = offeredAlpns,
|
// mod 2^32. We use the local clock — server's de-obfuscation
|
||||||
random = random,
|
// only cares about its own tracked age window.
|
||||||
cipherSuites = cipherSuites,
|
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)
|
transcript.append(chBytes)
|
||||||
outboundQueues[Level.INITIAL]!!.addLast(chBytes)
|
outboundQueues[Level.INITIAL]!!.addLast(chBytes)
|
||||||
state = State.WAITING_SERVER_HELLO
|
state = State.WAITING_SERVER_HELLO
|
||||||
@@ -235,6 +318,36 @@ class TlsClient(
|
|||||||
}
|
}
|
||||||
negotiatedCipherSuite = cipher
|
negotiatedCipherSuite = cipher
|
||||||
serverKeyShare = sh.serverKeyShareX25519
|
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)
|
transcript.append(msg)
|
||||||
|
|
||||||
val privKey = keyPair!!.privateKey
|
val privKey = keyPair!!.privateKey
|
||||||
@@ -282,16 +395,26 @@ class TlsClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
TlsConstants.HS_FINISHED -> {
|
TlsConstants.HS_FINISHED -> {
|
||||||
// Audit-4 #3: we never offer a `pre_shared_key`
|
if (!pskAccepted) {
|
||||||
// extension, so a server MUST send Certificate +
|
// Audit-4 #3: we never offered (or never had
|
||||||
// CertificateVerify. A Finished here means a
|
// accepted) a `pre_shared_key` extension, so a
|
||||||
// misbehaving server (or an MITM that stripped the
|
// server MUST send Certificate +
|
||||||
// cert messages). Hard-fail rather than completing
|
// CertificateVerify. A Finished here means a
|
||||||
// a handshake with no peer authentication.
|
// misbehaving server (or an MITM that stripped
|
||||||
throw QuicCodecException(
|
// the cert messages). Hard-fail rather than
|
||||||
"server skipped Certificate/CertificateVerify but we never offered PSK " +
|
// completing a handshake with no peer
|
||||||
"(unauthenticated handshake refused)",
|
// 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 -> {
|
else -> {
|
||||||
@@ -327,6 +450,25 @@ class TlsClient(
|
|||||||
TlsConstants.HS_NEW_SESSION_TICKET -> {
|
TlsConstants.HS_NEW_SESSION_TICKET -> {
|
||||||
// Don't append to transcript — NewSessionTicket is not
|
// Don't append to transcript — NewSessionTicket is not
|
||||||
// part of the handshake transcript per RFC 8446 §4.4.1.
|
// 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 -> {
|
TlsConstants.HS_KEY_UPDATE -> {
|
||||||
@@ -377,6 +519,12 @@ class TlsClient(
|
|||||||
transcript.append(cfBytes)
|
transcript.append(cfBytes)
|
||||||
outboundQueues[Level.HANDSHAKE]!!.addLast(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
|
state = State.SENT_CLIENT_FINISHED
|
||||||
secretsListener.onHandshakeComplete()
|
secretsListener.onHandshakeComplete()
|
||||||
}
|
}
|
||||||
@@ -402,8 +550,57 @@ interface TlsSecretsListener {
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun onHandshakeComplete()
|
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. */
|
/** Pluggable certificate validator. Decoupled so we can stub it in tests. */
|
||||||
interface CertificateValidator {
|
interface CertificateValidator {
|
||||||
fun validateChain(
|
fun validateChain(
|
||||||
|
|||||||
@@ -119,3 +119,75 @@ fun buildQuicClientHello(
|
|||||||
)
|
)
|
||||||
return TlsClientHello(random = random, cipherSuites = cipherSuites, extensions = exts)
|
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_SIGNATURE_ALGORITHMS: Int = 13
|
||||||
const val EXT_ALPN: Int = 16
|
const val EXT_ALPN: Int = 16
|
||||||
const val EXT_SUPPORTED_VERSIONS: Int = 43
|
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_PSK_KEY_EXCHANGE_MODES: Int = 45
|
||||||
const val EXT_KEY_SHARE: Int = 51
|
const val EXT_KEY_SHARE: Int = 51
|
||||||
|
|
||||||
|
|||||||
@@ -145,3 +145,46 @@ fun encodeAlpn(protocols: List<ByteArray>): ByteArray {
|
|||||||
}
|
}
|
||||||
return w.toByteArray()
|
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))
|
): 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
|
var serverApplicationSecret: ByteArray? = null
|
||||||
private set
|
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. */
|
/** Step 1: derive the Early Secret. PSK is all-zeros for non-resumption. */
|
||||||
fun deriveEarly() {
|
fun deriveEarly() {
|
||||||
val zeros = ByteArray(32)
|
val zeros = ByteArray(32)
|
||||||
earlySecret = HKDF.extract(zeros, zeros)
|
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. */
|
/** Step 2: derive Handshake Secret using ECDHE shared secret. */
|
||||||
fun deriveHandshake(ecdheSharedSecret: ByteArray) {
|
fun deriveHandshake(ecdheSharedSecret: ByteArray) {
|
||||||
val early = earlySecret ?: error("call deriveEarly first")
|
val early = earlySecret ?: error("call deriveEarly first")
|
||||||
@@ -94,6 +128,55 @@ class TlsKeySchedule(
|
|||||||
clientApplicationSecret = deriveSecret(ms, "c ap traffic", transcriptHash)
|
clientApplicationSecret = deriveSecret(ms, "c ap traffic", transcriptHash)
|
||||||
serverApplicationSecret = deriveSecret(ms, "s 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