Verify the server certificate chain in the application - #133
Conversation
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
left a comment
There was a problem hiding this comment.
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
|
Addressed in 2a314e0. The verifier now receives a It's a value, deliberately not the live Generated by Claude Code |
| /// | ||
| /// Defaults to `nil`: the built-in ``TLSConfiguration/certificateVerification`` | ||
| /// and the `serverHostname` check apply. Ignored by servers. | ||
| public var verifyCertificateChain: (@Sendable (QUICHandshakeContext) async throws -> NIOSSLVerificationResult)? |
There was a problem hiding this comment.
I think this should be called verifyTLS.
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.
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
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 theserverHostnamename 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
NIOSSLQUICHandshakegained that pause as a sans-I/O two-step (alta/swift-nio-ssl#16): it parks at.wantsCertificateVerifycarrying the peer chain and resumes onresumeVerification. This PR builds the QUIC consumer on top of it, the design settled in alta/swift-nio-ssl#13.Modifications
QUICConfigurationgainsverifyTLS, 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. TheQUICHandshakeContextis an immutable snapshot of what the handshake has settled when the certificate is ruled on: the peer chain, theserverHostnamedialed, the negotiated protocol and cipher suite, and the peer address — modeled on Go'sVerifyConnection(ConnectionState), deliberately a value rather than the liveQUICConnection.The pause threads through three layers.
TLSHandshakepropagates the opt-in, maps the fork's.wantsCertificateVerifyinto its ownState, and forwards the verdict; it also splitsCRYPTOreassembly from advancing (provideCryptoFrame), so a flight re-sent while paused buffers without re-driving the handshake.ConnectionCoreparks on the chain — surfaced once bytakeCertificateVerificationRequest— and resumes on the verdict viaresumeCertificateVerification, which on.failedfails the handshake with the certificate alert (CRYPTO_ERROR, § 4.8); while paused it buffers receivedCRYPTOand keeps ACKing, with no new timer, so a verdict that never comes lets the connection idle out.TransportHandlerassembles theQUICHandshakeContext, runs the verifier in a detachedTaskoff the loop, and hops the verdict back through aNIOLoopBoundto resume and flush; theTaskis cancelled on teardown.A verifier that throws (a check that could not complete) currently fails the handshake the same as a
.failedverdict — the connection closes and the dial surfaceshandshakeFailed. Splitting that into anINTERNAL_ERRORclose, 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
ConnectionCoretests drive a paused handshake to completion on a.certificateVerifiedverdict (asserting the verifier saw the server's leaf certificate, against an untrusted, name-mismatched server) and to aCRYPTO_ERRORclose 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.nonepolicy 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.verifyTLSasync hook taking aQUICHandshakeContextTLSHandshakestate, opt-in, resume, and the provide/advance splitConnectionCorepark/resume (takeCertificateVerificationRequest/resumeCertificateVerification)TransportHandleroff-loopTaskbridge with the verdict hop backTRACEABILITY.md§ 4 row updatedswift-nio-ssldependency repointed tomainonce Verify the peer certificate chain in the application swift-nio-ssl#16 landed🤖 Generated with Claude Code