Verify the client certificate for mutual TLS - #17
Conversation
Motivation: Mutual authentication in the QUIC TLS 1.3 handshake (RFC 9001 § 4) has the server verify the client's certificate as well as the reverse. A server asks for one by sending a CertificateRequest, which BoringSSL drives from the verify mode set on the connection. The client direction already routes verification through the application: customCertificateVerification installs SSL_set_custom_verify and surfaces the peer chain at .wantsCertificateVerify for a verdict. The server arm did not install it, so a server could not custom-verify a client — and, since the same call is what requests the client certificate, could not ask for one through this path at all. Modifications: When customCertificateVerification is set on a server, the handshake now installs the same custom-verify callback in the server arm. SSL_VERIFY_PEER makes the server send the CertificateRequest and routes the client's chain through the existing .wantsCertificateVerify / resumeVerification two-step; the .wantsCertificateVerify chain, resumeVerification, and the callback's state machine are unchanged and role-agnostic. Client authentication is optional: without SSL_VERIFY_FAIL_IF_NO_PEER_CERT, a client that sends no certificate is not failed by BoringSSL here. The public API is unchanged — only the server arm and the customCertificateVerification documentation. Validation: three XCTest cases in NIOSSLQUICHandshakeTests — a server verifying a client that presents a certificate (completing and seeing the chain), a .failed verdict failing with the certificate alert, and an optional client that presents none still completing. Result: A server QUIC handshake can verify the client's certificate chain in the application, 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
|
The title for this PR is word salad. What does it enable? |
|
Fair — it doesn't say what it's for. What it enables is mutual TLS: a server requesting the client's certificate (the Generated by Claude Code |
The optional mTLS test now asserts what BoringSSL does when the client presents no certificate: with SSL_VERIFY_PEER and no SSL_VERIFY_FAIL_IF_NO_PEER_CERT, the custom verifier is not consulted at all, so the handshake completes and a server cannot reject an anonymous client through the verifier. Documenting that optional client auth is optional in the strong sense — a presented certificate is verified, an absent one is not failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
A server requesting a client certificate for mutual TLS (RFC 9001 § 4) may either require it or accept its absence. The custom-verify path hardcoded SSL_VERIFY_PEER, so it could only ever be optional: a client that sent no certificate was never failed, and the verifier was not consulted for the empty case, so the application could not reject an anonymous client. The requirement is the verify mode, not a verdict. installCustomVerify now takes requirePeerCertificate, which adds SSL_VERIFY_FAIL_IF_NO_PEER_CERT so BoringSSL fails an absent certificate before the callback. The server derives it from the context's certificateVerification — the same lever the record path uses: .fullVerification / .noHostnameVerification require a peer certificate, .none leaves it optional. A client passes false: a server always presents its certificate, so the bit is moot there. Validation: a requiring server rejects a client that presents no certificate (failing before the verifier) and completes with one; the optional path is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
A mutual-TLS server may want to reject clients that present no certificate, not just verify the ones that do (RFC 9001 § 4). That requirement is the verify mode, not the verdict, so it rides the configuration's certificateVerification — the same lever the record path and HTTP/2 servers use: `.fullVerification` / `.noHostnameVerification` require a client certificate, `.none` (the default) leaves it optional. The fork honors it on the server arm (alta/swift-nio-ssl#17); this bumps the dependency to that commit and documents the behavior on `verifyTLS`. No new QUIC API. Validation: a loopback server set to `.fullVerification` with a `verifyTLS` hook does not accept a client that presents no certificate — the handshake fails server-side and the connection is never delivered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
The `Sources/NIOSSL/QUIC/` code is this fork's QUIC-TLS addition, not upstream NIOSSL, so its comments follow the swift-nio-quic writing conventions: RFC citations are links, and em dashes are closed (no spaced form). This links the file's bare RFC references and closes the open em dashes, in the new mutual-TLS comments and the existing ones alike. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
| // skip the built-in name check—the callback owns that too. A | ||
| // server always presents its certificate, so requiring one adds | ||
| // nothing here. | ||
| Self.installCustomVerify(on: ssl, requirePeerCertificate: false) |
There was a problem hiding this comment.
Why requirePeerCertificate: false?
There was a problem hiding this comment.
SSL_VERIFY_FAIL_IF_NO_PEER_CERT only bites when the peer might present no certificate. A client verifies the server, and a TLS 1.3 server always presents one (server authentication is mandatory for the certificate-based handshakes QUIC uses), so the handshake already fails if it somehow doesn't. The bit is moot client-side, so false keeps the client exactly as it was before the flag existed. Required-vs-optional is a server-only choice, which the server arm derives from certificateVerification. The comment just above (a server always presents its certificate, so requiring one adds nothing here) is meant to say this, but the call site clearly didn't carry it on its own.
Generated by Claude Code
| /// - requirePeerCertificate: whether an absent peer certificate fails the | ||
| /// handshake. Always `false` for a client (a server presents one | ||
| /// unconditionally); on a server it follows the verification mode. | ||
| private static func installCustomVerify(on ssl: OpaquePointer, requirePeerCertificate: Bool) { |
There was a problem hiding this comment.
Default this to false to simplify the client calling case.
The client never requires a peer certificate, so default the flag and drop the argument from the client call site; the server still passes the value it derives from the verification mode. Per review on #17. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
requirePeerCertificate was just a projection of the context's CertificateVerification. Pass the enum instead and read the require bit inside, so the translation lives in one place and the server arm drops its switch. The client keeps the `.none` default. Per review on #17. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cu2dW2zXuAnwDRaiHr7feR
The server-side custom certificate verification this PR consumes landed on the fork's main (alta/swift-nio-ssl#17), 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: Mutual authentication in the QUIC TLS 1.3 handshake (RFC 9001 § 4) has the server verify the client's certificate as well as the reverse. The client direction shipped in #16: customCertificateVerification installs SSL_set_custom_verify and surfaces the peer chain at .wantsCertificateVerify for a verdict. The server arm did nothing past SSL_set_accept_state, so a server could neither custom-verify a client nor, since the same call requests the client certificate, ask for one at all. And once it can, whether a client must present one (reject anonymous) is a separate policy. Modifications: customCertificateVerification on a server now installs the same custom-verify callback in the server arm. SSL_VERIFY_PEER makes the server send the CertificateRequest and routes the client's chain through the existing .wantsCertificateVerify / resumeVerification two-step; the State case, resumeVerification, and the callback's state machine are unchanged and role-agnostic. Whether the client must present a certificate follows the context's certificateVerification, the same lever the record path uses: .fullVerification / .noHostnameVerification add SSL_VERIFY_FAIL_IF_NO_PEER_CERT (required), .none leaves it optional. installCustomVerify takes the CertificateVerification and reads only that bit from it; a client uses the default (.none), since a server presents its certificate unconditionally. Optional is optional in the strong sense: with no fail bit, BoringSSL does not consult the verifier when the client sends none, so the handshake completes and the verifier cannot reject the anonymous case; required fails the handshake before the callback. The public API is unchanged: only the server arm and the customCertificateVerification documentation move. Validation: XCTest cases in NIOSSLQUICHandshakeTests cover a server verifying a client that presents a certificate (completing and seeing the chain); a .failed verdict failing with the certificate alert; an optional server completing with no client certificate, with the verifier not consulted; and a required (.fullVerification) server rejecting a client that presents none (failing before the verifier) and completing with one. Result: A server QUIC handshake can verify the client's certificate in the application, optional or required per certificateVerification: 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: Mutual authentication in the QUIC TLS 1.3 handshake (RFC 9001 § 4) has the server verify the client's certificate as well as the reverse. The client direction shipped in #16: customCertificateVerification installs SSL_set_custom_verify and surfaces the peer chain at .wantsCertificateVerify for a verdict. The server arm did nothing past SSL_set_accept_state, so a server could neither custom-verify a client nor, since the same call requests the client certificate, ask for one at all. And once it can, whether a client must present one (reject anonymous) is a separate policy. Modifications: customCertificateVerification on a server now installs the same custom-verify callback in the server arm. SSL_VERIFY_PEER makes the server send the CertificateRequest and routes the client's chain through the existing .wantsCertificateVerify / resumeVerification two-step; the State case, resumeVerification, and the callback's state machine are unchanged and role-agnostic. Whether the client must present a certificate follows the context's certificateVerification, the same lever the record path uses: .fullVerification / .noHostnameVerification add SSL_VERIFY_FAIL_IF_NO_PEER_CERT (required), .none leaves it optional. installCustomVerify takes the CertificateVerification and reads only that bit from it; a client uses the default (.none), since a server presents its certificate unconditionally. Optional is optional in the strong sense: with no fail bit, BoringSSL does not consult the verifier when the client sends none, so the handshake completes and the verifier cannot reject the anonymous case; required fails the handshake before the callback. The public API is unchanged: only the server arm and the customCertificateVerification documentation move. Validation: XCTest cases in NIOSSLQUICHandshakeTests cover a server verifying a client that presents a certificate (completing and seeing the chain); a .failed verdict failing with the certificate alert; an optional server completing with no client certificate, with the verifier not consulted; and a required (.fullVerification) server rejecting a client that presents none (failing before the verifier) and completing with one. Result: A server QUIC handshake can verify the client's certificate in the application, optional or required per certificateVerification: 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
Mutual authentication in the QUIC TLS 1.3 handshake (RFC 9001 § 4) has the server verify the client's certificate as well as the reverse. The client direction shipped in #16:
customCertificateVerificationinstallsSSL_set_custom_verifyand surfaces the peer chain at.wantsCertificateVerifyfor a verdict. The server arm did nothing pastSSL_set_accept_state, so a server could neither custom-verify a client nor, since the same call is what requests the client certificate, ask for one at all. And once it can, whether a client must present one (reject anonymous) is a separate policy.Modifications
customCertificateVerificationon a server now installs the same custom-verify callback in the server arm.SSL_VERIFY_PEERmakes the server send theCertificateRequestand routes the client's chain through the existing.wantsCertificateVerify/resumeVerificationtwo-step; theStatecase,resumeVerification, and the callback's state machine are unchanged and role-agnostic.Whether the client must present a certificate follows the context's
certificateVerification, the same lever the record path uses:.fullVerification/.noHostnameVerificationaddSSL_VERIFY_FAIL_IF_NO_PEER_CERT(required),.noneleaves it optional.installCustomVerifytakes arequirePeerCertificateflag for this; a client always passesfalse, since a server presents its certificate unconditionally. Optional is optional in the strong sense: with no fail bit, BoringSSL does not consult the verifier when the client sends none, so the handshake completes and the verifier cannot reject the anonymous case; required fails the handshake before the callback. The public API is unchanged: only the server arm and thecustomCertificateVerificationdocumentation move.Validation: the XCTest cases in
NIOSSLQUICHandshakeTestscover a server verifying a client that presents a certificate (completing and seeing the chain); a.failedverdict failing with the certificate alert; an optional server completing with no client certificate, with the verifier not consulted; and a required (.fullVerification) server rejecting a client that presents none (failing before the verifier) and completing with one.Result
A server QUIC handshake can verify the client's certificate in the application, optional or required per
certificateVerification: the mutual-TLS counterpart of the client-side verification (RFC 9001 § 4). This is the fork half of the mTLS follow-up tracked in #13; theswift-nio-quicconsumer side is alta/swift-nio-quic#134.