Skip to content

Resume a prior TLS session in the QUIC handshake - #173

Merged
ydnar merged 6 commits into
mainfrom
session-resumption
Jul 1, 2026
Merged

Resume a prior TLS session in the QUIC handshake#173
ydnar merged 6 commits into
mainfrom
session-resumption

Conversation

@ydnar

@ydnar ydnar commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

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 real QUICConnection resumes.

Modifications:

TLSHandshake adopts the fork's resumption API — it offers a stored session, captures the server's post-handshake tickets (takeNewSessions()), and reports sessionReused — picking up the reordered NIOSSLQUICHandshake initializer and its new earlyDataRejected state along the way.

Above it, 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 anti-replay) hangs off QUICConfiguration.clientSessionStore, keyed by the server dialed — the SNI hostname or the address (a shared QUICServerKey, the Key of both stores). 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 ticket is drained from the core and cached in DatagramHandler.deliver.

Package.swift / Package.resolved pin the fork's main at #19's merge commit; they tracked the quic-session-resumption branch while #19 was in review, so this PR's CI could build against the unmerged API.

Validation: TLSHandshakeTests.resumesACapturedSession and TransportSimulatorTests.sessionResumption capture a ticket on a first connection and assert the second handshake is abbreviated (sessionReused / didResumeSession); the resumption interop case runs it end to end (added to supportedTestCases and the CI matrix, with the negative 127-guard removed). 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 runner's resumption case passes. 0-RTT early data — the fork's Resumption API is already in place for it — is the follow-up.

🤖 Generated with Claude Code

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

@ydnar ydnar left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

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
@ydnar
ydnar marked this pull request as ready for review July 1, 2026 21:45
Comment thread Sources/NIOQUIC/ConnectionCore.swift Outdated
/// 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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would there ever be a QUICServerSessionStore type? If not, can we trim the Client part of the name?

Comment thread Sources/NIOQUIC/QUICClientSessionStore.swift Outdated
/// ``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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly if there will never be a QUICInMemoryServerSessionStore, then let’s omit the Client part of the name.

Comment thread Sources/NIOQUIC/QUICConfiguration.swift Outdated
Comment thread Sources/NIOQUIC/RoutingCore.swift Outdated
Comment thread Sources/NIOQUIC/RoutingCore.swift Outdated
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
@ydnar

ydnar commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all addressed in fed9e85:

  • .mapif-expression (ConnectionCore): a ternary would force-unwrap the optional, so it reads let resumption: NIOSSLQUICHandshake.Resumption = if let resumeSession { .resume(session: resumeSession) } else { .none }.
  • Client in the type names: dropped — no QUICServerSessionStore is planned. A server resumes transparently, validating the client's ticket against its own key, so there's nothing to configure on that side. Now QUICSessionStore / QUICInMemorySessionStore / QUICSessionStoreKey; kept QUICConfiguration.clientSessionStore per your note on line 119.
  • resumptionSessionresumeSession across the offer path.
  • Key nested under the store: a Swift protocol can't contain a nested concrete type, so it stays a top-level QUICSessionStoreKey surfaced as QUICSessionStore.Key through the protocol's typealias Key — call sites can write QUICSessionStore.Key, the same shape as QUICTokenStoreKey.
  • fileprivate(set) vs private(set): the setter runs from RoutingCore.registerDialed, which lives on the enclosing RoutingCore, not on the nested RoutedConnection. private(set) scopes the setter to RoutedConnection and its same-file extensions, so RoutingCore couldn't reach it — it wouldn't compile. Left fileprivate(set), matching the tokenStore / tokenStoreKey fields two lines up.

Generated by Claude Code

Comment thread Sources/NIOQUIC/QUICSessionStore.swift Outdated
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
@ydnar

ydnar commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Done in 904ab3f — merged both into one QUICServerKey (Sources/NIOQUIC/QUICServerKey.swift), now the Key typealias of both QUICTokenStore and QUICSessionStore. Of your three, QUICServerKey is the most accurate: QUICSessionKey would mislead since the token store shares it, and QUICAddressKey names the mechanism (hostname or address) rather than the meaning — both caches key on which server the client dialed.


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
@ydnar ydnar mentioned this pull request Jul 1, 2026
80 tasks
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
@ydnar

ydnar commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Merge when green.

@ydnar
ydnar merged commit 825261b into main Jul 1, 2026
20 checks passed
@ydnar
ydnar deleted the session-resumption branch July 1, 2026 23:37
@ydnar ydnar mentioned this pull request Jul 2, 2026
11 tasks
ydnar added a commit that referenced this pull request Jul 5, 2026
### 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants