Resume a prior TLS session in the QUIC handshake - #173
Conversation
Motivation: A QUIC client reconnecting to a server it has spoken to before can resume the prior TLS session instead of running a full handshake, an abbreviated exchange keyed on a pre-shared secret (RFC 8446 § 2.2, RFC 9001 § 4.6). The server issues session tickets after the handshake; the client keeps them and offers one on a later connection. The swift-nio-ssl fork now surfaces this through NIOSSLQUICHandshake (alta/swift-nio-ssl#19): a Resumption value to offer a session, drainNewSessions() to capture the tickets a server issues, and sessionReused to report whether resumption took. This stack's TLS adapter drove none of it. Modifications: TLSHandshake adopts the fork's resumption API. Its initializer takes a `resumption` value a client uses to offer a stored session; it captures the server's post-handshake tickets (takeNewSessions()); and it reports sessionReused. Adopting the fork also means its reordered initializer (the three required parameters lead) and its new earlyDataRejected state, which the adapter drives on to a full handshake — unreached until this stack offers 0-RTT. Package.swift and Package.resolved pin the fork to its `quic-session-resumption` branch pending #19's merge, after which they revert to `main`. Validation: TLSHandshakeTests.resumesACapturedSession runs a full handshake, captures the server's ticket, offers it on a second handshake over the same contexts, and asserts the second resumed (sessionReused). Result: The QUIC TLS adapter resumes a prior TLS session end to end, proving the fork's resumption API (alta/swift-nio-ssl#19) consumable before it merges. Wiring resumption into the public connection path — a client session store on QUICConfiguration, keyed by server identity like tokenStore — is the follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
Motivation: The TLS adapter can resume a session, but nothing above it drives that: a real QUICConnection ran a full handshake every time, and the runner's resumption case stayed unsupported. Modifications: A client session store, mirroring the NEW_TOKEN tokenStore. QUICClientSessionStore (protocol + the built-in bounded-LRU QUICInMemoryClientSessionStore, single-use popSession per RFC 8446 § 4.2.11) hangs off QUICConfiguration.clientSessionStore, keyed by the server dialed — the SNI hostname or the address. The offer is popped on connect and threaded through makeCore into the handshake at construction, since the session must reach TLS before the ClientHello; the capture side mirrors tokenStore — the server's post-handshake ticket is drained from the core (takeNewSessionToStore) and cached in DatagramHandler.deliver (storeSessionIfReceived). The quic-interop client sets a shared in-memory store for the resumption case and dials a fresh connection per request; the case is added to supportedTestCases and the CI matrix, and the negative 127-guard that asserted it unsupported is removed. Validation: TransportSimulatorTests.sessionResumption runs a first connection to capture a ticket and a second that resumes, asserting the second's handshake was abbreviated (didResumeSession); the resumption interop leg proves it against a real peer. docs/PLAN.md and TRACEABILITY.md record it. Result: Client TLS session resumption works end to end through the public API, opt-in via QUICConfiguration.clientSessionStore, and the resumption interop case is enabled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
| /// out of band rather than writing to disk inline. The store is `Sendable` and | ||
| /// may be shared across transports on different loops, so an implementation must | ||
| /// be safe for concurrent use. | ||
| public protocol QUICClientSessionStore: Sendable { |
There was a problem hiding this comment.
Would there ever be a QUICServerSessionStore type? If not, can we trim the Client part of the name?
| /// ``init(maximumServers:)``’s bound is reached, so a client dialing many servers | ||
| /// cannot grow it without limit. For sessions that should outlive the process, | ||
| /// supply a persistent ``QUICClientSessionStore`` instead. | ||
| public final class QUICInMemoryClientSessionStore: QUICClientSessionStore, Sendable { |
There was a problem hiding this comment.
Similarly if there will never be a QUICInMemoryServerSessionStore, then let’s omit the Client part of the name.
Motivation: The TLS adapter can resume a session, but nothing above it drives that: a real QUICConnection ran a full handshake every time, and the runner's resumption case stayed unsupported. Modifications: A client session store, mirroring the NEW_TOKEN tokenStore. QUICSessionStore (protocol + the built-in bounded-LRU QUICInMemorySessionStore, single-use popSession per RFC 8446 § 4.2.11) hangs off QUICConfiguration.clientSessionStore, keyed by the server dialed — the SNI hostname or the address. The offer is popped on connect and threaded through makeCore into the handshake at construction, since the session must reach TLS before the ClientHello; the capture side mirrors tokenStore — the server's post-handshake ticket is drained from the core (takeNewSessionToStore) and cached in DatagramHandler.deliver (storeSessionIfReceived). The quic-interop client sets a shared in-memory store for the resumption case and dials a fresh connection per request; the case is added to supportedTestCases and the CI matrix, and the negative 127-guard that asserted it unsupported is removed. Validation: TransportSimulatorTests.sessionResumption runs a first connection to capture a ticket and a second that resumes, asserting the second's handshake was abbreviated (didResumeSession); the resumption interop leg proves it against a real peer. docs/PLAN.md and TRACEABILITY.md record it. Result: Client TLS session resumption works end to end through the public API, opt-in via QUICConfiguration.clientSessionStore, and the resumption interop case is enabled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
|
Thanks — all addressed in
Generated by Claude Code |
QUICTokenStoreKey and QUICSessionStoreKey were identical — a server identified by its SNI hostname or its socket address. Merge them into one shared QUICServerKey, now the Key typealias of both QUICTokenStore and QUICSessionStore. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
|
Done in Generated by Claude Code |
Combining the store keys into QUICServerKey moved the only public use of SocketAddress out of QUICTokenStore and QUICSessionStore, so their `public import NIOCore` no longer backs any public declaration — which the package's InternalImportsByDefault feature rejects under warnings-as-errors. NIOCore is unused in both now, so drop the import; QUICServerKey.swift keeps its own. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
alta/swift-nio-ssl#19 landed the QUIC-TLS resumption API on the fork's main, so Package.swift and Package.resolved move off the quic-session-resumption branch back to main (238005f), the reproducible pin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
|
Merge when green. |
### Motivation: 0-RTT ([RFC 9001 § 4.6](https://datatracker.ietf.org/doc/html/rfc9001#section-4.6)) lets a resumed connection carry application data in its very first flight, before the handshake completes, saving a round trip. A client that holds a session ticket from an earlier connection offers it and sends 0-RTT packets protected with the resumed session's early keys; a server accepts when the ticket's early-data context matches, and delivers the data as though it arrived after the handshake. The trade is replay exposure — 0-RTT data is not forward-secret and can be replayed ([RFC 9000 § 9.2](https://datatracker.ietf.org/doc/html/rfc9000#section-9.2)) — so the transport remembers and honours the server's earlier transport parameters ([§ 4.6.1](https://datatracker.ietf.org/doc/html/rfc9001#section-4.6.1)) and replays any rejected 0-RTT data at 1-RTT. Client-side 1-RTT session resumption landed in #173; the frame × packet-type gate 0-RTT sends depend on landed in #176. This adds the remaining half: offering, sending, accepting, and — on rejection — replaying early data, exposed through the public API. ### Modifications: A resumable session becomes a public `QUICSession` value pairing the TLS ticket with the issuing server's transport parameters, round-tripped to and from a `ByteBuffer` (`encoded()` / `init(decoding:)`) for a store that persists or shares sessions. A client offering 0-RTT builds its stream multiplexer early from those remembered parameters — bounding the first flight per § 4.6.1 — and raises the limits in place once the negotiated parameters arrive (never reduced, § 7.4.1). `datagramsToSend` gains an `.earlyData` level between Initial and Handshake: a resumed client coalesces its Initial and 0-RTT packets and drains the multiplexer's stream frames there, withholding the 1-RTT-only frames, `ACK`, and probes (the § 12.5 gate). A server opts in with `QUICConfiguration.acceptsEarlyData`, binding acceptance to its configured transport parameters as the early-data context. On rejection the client forgets its 0-RTT packets from loss recovery and requeues their stream frames for 1-RTT, its send offsets intact so nothing is lost or duplicated. The client opts in per dial through `QUICConnection.connectEarly` / `QUICTransport.connectEarly`, which return the connection before the handshake completes; `connection.earlyDataAccepted` awaits the verdict. Verified by `ConnectionCoreTests` (0-RTT delivered before completion; rejected 0-RTT replayed at 1-RTT), `QUICLoopbackTests` (the public `connectEarly` path over real sockets), and the `zerortt` interop case against quic-go and ngtcp2. ### Result: The client-initiated half of RFC 9001 § 4.6 0-RTT works end to end: a resumed client dials with `connectEarly` and sends application data in its first flight, a server that opts in with `acceptsEarlyData` accepts it, and a rejection falls back cleanly to 1-RTT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
Motivation:
A QUIC client reconnecting to a server it has spoken to before can resume the prior TLS session instead of running a full handshake — an abbreviated exchange keyed on a pre-shared secret, with no certificate chain to re-send and verify (RFC 8446 § 2.2, RFC 9001 § 4.6). A TLS 1.3 server issues session tickets after the handshake (RFC 8446 § 4.6.1); the client keeps them and offers one on a later connection to that server.
The swift-nio-ssl fork surfaces this through
NIOSSLQUICHandshake(alta/swift-nio-ssl#19); this PR drives it through the QUIC stack, so a realQUICConnectionresumes.Modifications:
TLSHandshakeadopts the fork's resumption API — it offers a stored session, captures the server's post-handshake tickets (takeNewSessions()), and reportssessionReused— picking up the reorderedNIOSSLQUICHandshakeinitializer and its newearlyDataRejectedstate along the way.Above it, a client session store mirroring the
NEW_TOKENtokenStore.QUICSessionStore(protocol + the built-in bounded-LRUQUICInMemorySessionStore, single-usepopSessionper RFC 8446 § 4.2.11 anti-replay) hangs offQUICConfiguration.clientSessionStore, keyed by the server dialed — the SNI hostname or the address (a sharedQUICServerKey, theKeyof both stores). The offer is popped on connect and threaded throughmakeCoreinto the handshake at construction, since the session must reach TLS before the ClientHello; the capture side mirrorstokenStore— the server's ticket is drained from the core and cached inDatagramHandler.deliver.Package.swift/Package.resolvedpin the fork'smainat #19's merge commit; they tracked thequic-session-resumptionbranch while #19 was in review, so this PR's CI could build against the unmerged API.Validation:
TLSHandshakeTests.resumesACapturedSessionandTransportSimulatorTests.sessionResumptioncapture a ticket on a first connection and assert the second handshake is abbreviated (sessionReused/didResumeSession); theresumptioninterop case runs it end to end (added tosupportedTestCasesand the CI matrix, with the negative 127-guard removed).docs/PLAN.mdandTRACEABILITY.mdrecord it.Result:
Client TLS session resumption works end to end through the public API, opt-in via
QUICConfiguration.clientSessionStore, and the runner'sresumptioncase passes. 0-RTT early data — the fork'sResumptionAPI is already in place for it — is the follow-up.🤖 Generated with Claude Code