Skip to content

Resume TLS sessions and drive 0-RTT in the QUIC handshake - #19

Merged
ydnar merged 1 commit into
mainfrom
quic-session-resumption
Jul 1, 2026
Merged

Resume TLS sessions and drive 0-RTT in the QUIC handshake#19
ydnar merged 1 commit into
mainfrom
quic-session-resumption

Conversation

@ydnar

@ydnar ydnar commented Jul 1, 2026

Copy link
Copy Markdown

Motivation:

A QUIC client resumes a prior TLS session to abbreviate the handshake, and with 0-RTT it sends application data in its first flight, before the handshake completes (RFC 9001 § 4.6, RFC 8446 § 2.2). BoringSSL exposes the primitives — SSL_set_session, the new-session callback, SSL_set_early_data_enabled / SSL_set_quic_early_data_context, and SSL_early_data_accepted — but NIOSSLQUICHandshake surfaced none of them, so a QUIC transport built on it could neither capture a session ticket nor resume one.

Modifications:

The initializer takes a Resumption value (default .none). A client offers a stored session with .resume(session:) for a 1-RTT resumption, or .offerEarlyData(session:) to also send 0-RTT; a server accepts 0-RTT bound to a context with .acceptEarlyData(context:). Because 0-RTT is keyed on the resumption secret, the session rides inside the early-data case, so asking for early data without a session cannot be expressed.

A client captures the tickets a server issues after the handshake: the new-session callback serializes each SSL_SESSION, drained via drainNewSessions(). sessionReused and earlyDataAccepted report the outcome, and a server's 0-RTT refusal surfaces as the new State.earlyDataRejected, where advance() resets the TLS state and drives the full handshake.

The initializer's parameters are reordered so the three required ones (context, role, localTransportParameters) lead, then the optional refinements — the surface had grown by appending. All public types stay Swift/NIOSSL only: Resumption carries opaque [UInt8], and no BoringSSL type crosses the API.

Validation: NIOSSLQUICHandshakeTests cover a resume-and-reuse round trip (a ticket from a first handshake yields sessionReused on a second) and a corrupt-session throw; the existing call sites move to the new parameter order. The whole suite passes, and the warnings-as-errors build and strict format lint are clean.

Result:

NIOSSLQUICHandshake can resume a TLS session and drive 0-RTT early data, the TLS half of RFC 9001 § 4.6. Only session resumption is wired into the QUIC transport for now; the 0-RTT paths (.offerEarlyData / .acceptEarlyData, .earlyDataRejected, earlyDataAccepted) are in place for the transport to adopt next, so the fork is touched once for both.

🤖 Generated with Claude Code


Generated by Claude Code

Motivation:

A QUIC client resumes a prior TLS session to abbreviate the handshake, and
with 0-RTT it sends application data in its first flight, before the handshake
completes (RFC 9001 § 4.6, RFC 8446 § 2.2). BoringSSL exposes the primitives —
SSL_set_session, the new-session callback, SSL_set_early_data_enabled /
SSL_set_quic_early_data_context, and SSL_early_data_accepted — but
NIOSSLQUICHandshake surfaced none of them, so a QUIC transport built on it could
neither capture a session ticket nor resume one.

Modifications:

The initializer takes a Resumption value (default .none). A client offers a
stored session with .resume(session:) for a 1-RTT resumption, or
.offerEarlyData(session:) to also send 0-RTT; a server accepts 0-RTT bound to a
context with .acceptEarlyData(context:). Because 0-RTT is keyed on the
resumption secret, the session rides inside the early-data case, so asking for
early data without a session cannot be expressed.

A client captures the tickets a server issues after the handshake: the
new-session callback serializes each SSL_SESSION, drained via drainNewSessions().
sessionReused and earlyDataAccepted report the outcome, and a server's 0-RTT
refusal surfaces as the new State.earlyDataRejected, where advance() resets the
TLS state and drives the full handshake.

The initializer's parameters are reordered so the three required ones (context,
role, localTransportParameters) lead, then the optional refinements.

Validation: NIOSSLQUICHandshakeTests cover a resume-and-reuse round trip (a
ticket from a first handshake yields sessionReused on a second) and a
corrupt-session throw; the existing call sites move to the new parameter order.

Result:

NIOSSLQUICHandshake can resume a TLS session and drive 0-RTT early data, the TLS
half of RFC 9001 § 4.6. Only session resumption is wired into the QUIC transport
for now; the 0-RTT paths are in place for the transport to adopt next.

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
Author

Choose a reason for hiding this comment

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

LGTM

@ydnar
ydnar marked this pull request as ready for review July 1, 2026 23:10
@ydnar
ydnar merged commit 238005f into main Jul 1, 2026
43 of 45 checks passed
ydnar pushed a commit to alta/swift-nio-quic that referenced this pull request Jul 1, 2026
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 added a commit to alta/swift-nio-quic that referenced this pull request Jul 1, 2026
* Resume a prior TLS session in the QUIC handshake

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

* Wire session resumption through the public API and interop

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

* Wire session resumption through the public API and interop

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

* Combine the token and session store keys into QUICServerKey

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

* Drop the unused public NIOCore import from the stores

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

* Re-pin swift-nio-ssl to the merged fork main

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
ydnar added a commit that referenced this pull request Jul 2, 2026
Motivation:

The QUIC-TLS API gained a Resumption enum with 0-RTT early data
(.offerEarlyData / .acceptEarlyData) and the earlyDataAccepted accessor in #19,
but only 1-RTT session resumption was covered by a test. A client offering early
data on a resumed session, and a server accepting it against a matching
quic_early_data_context, went unexercised (RFC 9001 section 4.6).

Modifications:

Add testOfferedEarlyDataIsAccepted: a first handshake whose server enables early
data mints a ticket, and a second handshake offers 0-RTT on it (.offerEarlyData)
to a server accepting the same context (.acceptEarlyData); both sides then report
earlyDataAccepted. A companion earlyDataSession helper mints the
early-data-capable ticket.

Result:

The 0-RTT accept path is covered: earlyDataAccepted is exercised true on both the
offering client and the accepting server.


Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR

Co-authored-by: Claude <noreply@anthropic.com>
ydnar added a commit that referenced this pull request Jul 17, 2026
Motivation:

A QUIC client resumes a prior TLS session to abbreviate the handshake, and
with 0-RTT it sends application data in its first flight, before the handshake
completes (RFC 9001 § 4.6, RFC 8446 § 2.2). BoringSSL exposes the primitives —
SSL_set_session, the new-session callback, SSL_set_early_data_enabled /
SSL_set_quic_early_data_context, and SSL_early_data_accepted — but
NIOSSLQUICHandshake surfaced none of them, so a QUIC transport built on it could
neither capture a session ticket nor resume one.

Modifications:

The initializer takes a Resumption value (default .none). A client offers a
stored session with .resume(session:) for a 1-RTT resumption, or
.offerEarlyData(session:) to also send 0-RTT; a server accepts 0-RTT bound to a
context with .acceptEarlyData(context:). Because 0-RTT is keyed on the
resumption secret, the session rides inside the early-data case, so asking for
early data without a session cannot be expressed.

A client captures the tickets a server issues after the handshake: the
new-session callback serializes each SSL_SESSION, drained via drainNewSessions().
sessionReused and earlyDataAccepted report the outcome, and a server's 0-RTT
refusal surfaces as the new State.earlyDataRejected, where advance() resets the
TLS state and drives the full handshake.

The initializer's parameters are reordered so the three required ones (context,
role, localTransportParameters) lead, then the optional refinements.

Validation: NIOSSLQUICHandshakeTests cover a resume-and-reuse round trip (a
ticket from a first handshake yields sessionReused on a second) and a
corrupt-session throw; the existing call sites move to the new parameter order.

Result:

NIOSSLQUICHandshake can resume a TLS session and drive 0-RTT early data, the TLS
half of RFC 9001 § 4.6. Only session resumption is wired into the QUIC transport
for now; the 0-RTT paths are in place for the transport to adopt next.


Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR

Co-authored-by: Claude <noreply@anthropic.com>
ydnar added a commit that referenced this pull request Jul 17, 2026
Motivation:

The QUIC-TLS API gained a Resumption enum with 0-RTT early data
(.offerEarlyData / .acceptEarlyData) and the earlyDataAccepted accessor in #19,
but only 1-RTT session resumption was covered by a test. A client offering early
data on a resumed session, and a server accepting it against a matching
quic_early_data_context, went unexercised (RFC 9001 section 4.6).

Modifications:

Add testOfferedEarlyDataIsAccepted: a first handshake whose server enables early
data mints a ticket, and a second handshake offers 0-RTT on it (.offerEarlyData)
to a server accepting the same context (.acceptEarlyData); both sides then report
earlyDataAccepted. A companion earlyDataSession helper mints the
early-data-capable ticket.

Result:

The 0-RTT accept path is covered: earlyDataAccepted is exercised true on both the
offering client and the accepting server.


Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR

Co-authored-by: Claude <noreply@anthropic.com>
@ydnar
ydnar deleted the quic-session-resumption branch August 5, 2026 17:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants