Skip to content

Verify the server certificate chain in the application - #133

Merged
ydnar merged 4 commits into
mainfrom
custom-certificate-verification
Jun 19, 2026
Merged

Verify the server certificate chain in the application#133
ydnar merged 4 commits into
mainfrom
custom-certificate-verification

Conversation

@ydnar

@ydnar ydnar commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Motivation

Authenticating the server's certificate is part of the QUIC TLS 1.3 handshake (RFC 9001 § 4), which QUIC drives directly rather than over the record layer. Built-in verification — the chain checked against TLSConfiguration.certificateVerification, plus the serverHostname name check (#109) — covers the common case. Some clients need the trust decision themselves: certificate pinning, a custom trust store, or validation that consults an external service such as an OCSP responder.

Such validation is network-bound and may suspend, while the QUIC connection core is sans-I/O and synchronous. The decision therefore belongs at the connection's edge, on the event loop, with the core pausing the handshake until a verdict arrives. The fork's NIOSSLQUICHandshake gained that pause as a sans-I/O two-step (alta/swift-nio-ssl#16): it parks at .wantsCertificateVerify carrying the peer chain and resumes on resumeVerification. This PR builds the QUIC consumer on top of it, the design settled in alta/swift-nio-ssl#13.

Modifications

QUICConfiguration gains verifyTLS, an optional @Sendable (QUICHandshakeContext) async throws -> NIOSSLVerificationResult. When set, it replaces the built-in trust and hostname checks — the application owns both — and opts the client handshake into the fork's custom verification. The QUICHandshakeContext is an immutable snapshot of what the handshake has settled when the certificate is ruled on: the peer chain, the serverHostname dialed, the negotiated protocol and cipher suite, and the peer address — modeled on Go's VerifyConnection(ConnectionState), deliberately a value rather than the live QUICConnection.

The pause threads through three layers. TLSHandshake propagates the opt-in, maps the fork's .wantsCertificateVerify into its own State, and forwards the verdict; it also splits CRYPTO reassembly from advancing (provideCryptoFrame), so a flight re-sent while paused buffers without re-driving the handshake. ConnectionCore parks on the chain — surfaced once by takeCertificateVerificationRequest — and resumes on the verdict via resumeCertificateVerification, which on .failed fails the handshake with the certificate alert (CRYPTO_ERROR, § 4.8); while paused it buffers received CRYPTO and keeps ACKing, with no new timer, so a verdict that never comes lets the connection idle out. TransportHandler assembles the QUICHandshakeContext, runs the verifier in a detached Task off the loop, and hops the verdict back through a NIOLoopBound to resume and flush; the Task is cancelled on teardown.

A verifier that throws (a check that could not complete) currently fails the handshake the same as a .failed verdict — the connection closes and the dial surfaces handshakeFailed. Splitting that into an INTERNAL_ERROR close, distinct from the certificate alert a rejection raises, is a small follow-up left out here to keep the change focused; flag it if it should land in this PR.

The mTLS direction — a server custom-verifying the client's certificate — is a separate follow-up (it needs a fork change to install custom verify on the server arm); the decisions are recorded on alta/swift-nio-ssl#13.

Validation: sans-I/O ConnectionCore tests drive a paused handshake to completion on a .certificateVerified verdict (asserting the verifier saw the server's leaf certificate, against an untrusted, name-mismatched server) and to a CRYPTO_ERROR close on .failed; loopback tests over real sockets prove the async path end to end — a verifier approving an otherwise-untrusted chain completes the dial (asserting the handed-in context carried the dialed name, the chain, and the negotiated ALPN), and a verifier rejecting it over a built-in .none policy fails the dial.

Result

A QUIC client can verify the server's certificate chain in the application, given an immutable QUICHandshakeContext, replacing the built-in trust and name checks (RFC 9001 § 4). The post-ClientHello transport-parameter selection remains the last piece of the § 4 handshake-driver row.

Tasks

  • QUICConfiguration.verifyTLS async hook taking a QUICHandshakeContext
  • TLSHandshake state, opt-in, resume, and the provide/advance split
  • ConnectionCore park/resume (takeCertificateVerificationRequest / resumeCertificateVerification)
  • TransportHandler off-loop Task bridge with the verdict hop back
  • Sans-I/O accept + reject tests; loopback accept + reject tests
  • TRACEABILITY.md § 4 row updated
  • swift-nio-ssl dependency repointed to main once Verify the peer certificate chain in the application swift-nio-ssl#16 landed
  • Issue Roadmap & progress tracker #1 M3 item reconciled at merge

🤖 Generated with Claude Code

Motivation:

Authenticating the server's certificate is part of the QUIC TLS 1.3
handshake (RFC 9001 § 4), which QUIC drives directly rather than over the
record layer. Built-in verification — the chain checked against
TLSConfiguration.certificateVerification, plus the serverHostname name
check (#109) — covers the common case. Some clients need the trust
decision themselves: certificate pinning, a custom trust store, or
validation that consults an external service such as an OCSP responder.

Such validation is network-bound and may suspend, while the QUIC
connection core is sans-I/O and synchronous. The decision therefore
belongs at the connection's edge, on the event loop, with the core
pausing the handshake until a verdict arrives. The fork's
NIOSSLQUICHandshake gained that pause as a sans-I/O two-step
(alta/swift-nio-ssl#16): it parks at .wantsCertificateVerify carrying the
peer chain and resumes on resumeVerification. This change builds the QUIC
consumer on top of it.

Modifications:

QUICConfiguration gains verifyCertificateChain, an optional
`@Sendable ([NIOSSLCertificate]) async throws -> NIOSSLVerificationResult`.
When set, it replaces the built-in trust and hostname checks: the
application owns both. Setting it opts the client handshake into the
fork's custom verification.

The pause threads through three layers. TLSHandshake propagates the
opt-in, maps the fork's .wantsCertificateVerify into its own State, and
forwards the verdict; it also splits CRYPTO reassembly from advancing, so
a flight re-sent while paused buffers without re-driving the handshake.
ConnectionCore parks on the chain — surfaced once by
takeCertificateVerificationRequest — and resumes on the verdict via
resumeCertificateVerification, which on .failed fails the handshake with
the certificate alert (CRYPTO_ERROR, RFC 9001 § 4.8); while paused it
buffers received CRYPTO and keeps ACKing, with no new timer, so a verdict
that never comes lets the connection idle out. TransportHandler runs the
verifier in a detached Task off the loop and hops the verdict back through
a NIOLoopBound to resume and flush; the Task is cancelled on teardown. A
verifier that throws fails the handshake like a rejection.

The swift-nio-ssl dependency tracks the custom-verification branch until
alta/swift-nio-ssl#16 lands, then repoints to main.

Validation: sans-I/O ConnectionCore tests drive a paused handshake to
completion on a `.certificateVerified` verdict (asserting the verifier saw
the server's leaf certificate, against an untrusted, name-mismatched
server) and to a CRYPTO_ERROR close on `.failed`; loopback tests over real
sockets prove the async path end to end, a verifier approving an otherwise
untrusted chain and a verifier rejecting it failing the dial.

Result:

A QUIC client can verify the server's certificate chain in the
application, replacing the built-in trust and name checks (RFC 9001 § 4).

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 June 18, 2026 18:43

@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.

How does the caller implementing certificate verification get visibility into the connection metadata?

Motivation:

The custom verifier that QUICConfiguration.verifyCertificateChain installs
received only the peer certificate chain. That left it unable to do the
hostname check it is documented to own — it was never told which name was
dialed — nor to see the peer address or the negotiated protocol, which a
pinning or per-host policy needs. Go answers the same need with
VerifyConnection(ConnectionState), handing the verifier a value snapshot of
the handshake state (ServerName, NegotiatedProtocol, PeerCertificates, …)
rather than the live connection.

Modifications:

QUICHandshakeContext, a public immutable value, carries what the handshake
has settled by the point the certificate is verified: the peer chain, the
serverHostname being dialed, the negotiated protocol and cipher suite, and
the peer address. verifyCertificateChain now takes it as its single
argument. It is a snapshot, not the live QUICConnection: that handle is
mutable (close, migrate, open streams), its cached facts are not populated
until the handshake completes, and it does not exist on the accept path —
so a value, assembled fresh at verification time, is both safer and more
honest about the moment.

TLSHandshake remembers the negotiated cipher suite as a secret installs, and
ConnectionCore exposes it beside negotiatedProtocol; both read live by the
time the peer Certificate is processed, since the handshake-level secret is
already in place. TransportHandler assembles the context from the core
(protocol, cipher suite), the active path (peer address), and the
serverHostname now threaded through register, and hands it to the verifier
across the loop boundary by value.

Validation: the loopback accept test asserts the verifier was handed the
dialed name, the server's leaf certificate, and the negotiated ALPN; the
reject test rejects over a `.none` built-in policy that would otherwise
complete, so the failure is the verdict's, not the built-in check's.

Result:

A QUIC client's certificate verifier sees the connection it is ruling on —
the dialed name, the peer chain and address, and the negotiated TLS
parameters — through an immutable QUICHandshakeContext (RFC 9001 § 4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
@ydnar

ydnar commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 2a314e0. The verifier now receives a QUICHandshakeContext rather than the bare chain — an immutable snapshot of what the handshake has settled by the point the certificate is ruled on: the peer chain, the serverHostname dialed (the name it validates against, since under a custom verifier it owns the hostname check), the negotiated protocol and cipher suite, and the peer address. This follows Go, where VerifyConnection(ConnectionState) hands the verifier a state snapshot (ServerName, NegotiatedProtocol, PeerCertificates, …) rather than the live connection.

It's a value, deliberately not the live QUICConnection: that handle is mutable (close, migrate, open streams), its cached facts aren't populated until the handshake completes (so negotiatedProtocol would read stale mid-handshake, where the core reads live), and it doesn't exist on the accept path — so a snapshot generalizes to the deferred mTLS direction too. The struct is Sendable and easy to extend (a …WithMetadata-style addition later, if wanted).


Generated by Claude Code

Comment thread Sources/NIOQUIC/QUICConfiguration.swift Outdated
///
/// Defaults to `nil`: the built-in ``TLSConfiguration/certificateVerification``
/// and the `serverHostname` check apply. Ignored by servers.
public var verifyCertificateChain: (@Sendable (QUICHandshakeContext) async throws -> NIOSSLVerificationResult)?

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.

I think this should be called verifyTLS.

claude added 2 commits June 19, 2026 00:33
Now that the callback is handed a QUICHandshakeContext rather than the bare
chain — and owns trust evaluation and the hostname check both — the hook
verifies the TLS connection, not just the certificate chain. verifyTLS says
that, and pairs with the `tls` configuration property it overrides. The
core's pause/resume seam keeps its mechanism names
(takeCertificateVerificationRequest / resumeCertificateVerification): those
name the certificate-verification pause, not the application's hook.
The custom certificate-verification API this PR consumes landed on the
fork's main (alta/swift-nio-ssl#16), so the dependency points back at
`main` and Package.resolved pins the squash-merge commit, dropping the
PR-branch pin used while the fork change was in review.
@ydnar
ydnar merged commit 312aa24 into main Jun 19, 2026
18 checks passed
@ydnar
ydnar deleted the custom-certificate-verification branch June 19, 2026 18:11
ydnar added a commit that referenced this pull request Jun 20, 2026
Motivation:

Mutual authentication in the QUIC TLS 1.3 handshake (RFC 9001 § 4) has the server verify the client's certificate, the reverse of the client authenticating the server. The client direction shipped in #133: QUICConfiguration.verifyTLS hands the application an immutable QUICHandshakeContext and awaits its verdict, parking the handshake through the fork's pause/resume two-step (alta/swift-nio-ssl#16). The hook and the park/resume are role-agnostic, but the consumer wired them only on the dial path, so a server never requested a client certificate or ran the verifier on one.

Modifications:

verifyTLS set on a server configuration now means "request the client's certificate and custom-verify it", symmetric with its client meaning. The flag threads through the accept path: TransportCore.Accepting carries it, the server's deferred handshake (built from the client's first Initial) is constructed with customCertificateVerification, and the fork's server arm (alta/swift-nio-ssl#17) installs the custom-verify callback, which is also what sends the CertificateRequest. The accepted connection's attachment receives the listener's verifier, and TransportHandler assembles the QUICHandshakeContext as it does for a client: peerCertificates is the client's chain, serverHostname is nil, since a server has no SNI to check.

Whether a client must present a certificate rides the existing tls.certificateVerification rather than a new knob, the same lever NIOSSL servers and HTTP/2 already use: .fullVerification and .noHostnameVerification reject a client that presents none, .none (the default) leaves it optional. That lever stands on its own. A server that sets certificateVerification and trustRoots gets working mutual TLS with no verifyTLS at all, the client chain validated against the configured roots exactly as a NIOSSL TLS server does. verifyTLS is for owning the trust decision in the application instead of delegating it to the trust store.

Validation: loopback tests drive a server whose verifyTLS approves the client's certificate (asserting the verifier saw the client's chain and a nil serverHostname) and a .fullVerification server that rejects a client presenting none (the handshake fails server-side, so the connection is never delivered); two more cover the built-in trust-store path with no verifier, accepting a trusted client and rejecting an anonymous one. The client-side and sans-I/O park/resume coverage from #133 is unchanged, the mechanism being the same code reached on the server.

Result:

A QUIC server can verify the client's certificate, optional or required via certificateVerification, either against its trust store or in the application through verifyTLS: the mutual-TLS counterpart of the client-side verification (RFC 9001 § 4).

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