Skip to content

Verify the client certificate for mutual TLS - #17

Merged
ydnar merged 6 commits into
mainfrom
quic-mtls-verify
Jun 20, 2026
Merged

Verify the client certificate for mutual TLS#17
ydnar merged 6 commits into
mainfrom
quic-mtls-verify

Conversation

@ydnar

@ydnar ydnar commented Jun 19, 2026

Copy link
Copy Markdown

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

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 a requirePeerCertificate flag for this; a client always passes false, 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: the 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). This is the fork half of the mTLS follow-up tracked in #13; the swift-nio-quic consumer side is alta/swift-nio-quic#134.

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
@ydnar
ydnar marked this pull request as ready for review June 19, 2026 18:18
@ydnar

ydnar commented Jun 19, 2026

Copy link
Copy Markdown
Author

The title for this PR is word salad.

What does it enable?

@ydnar ydnar changed the title Verify the client certificate chain in the application Verify the client certificate for mutual TLS Jun 19, 2026
@ydnar

ydnar commented Jun 19, 2026

Copy link
Copy Markdown
Author

Fair — it doesn't say what it's for. What it enables is mutual TLS: a server requesting the client's certificate (the CertificateRequest) and handing the chain to the application's verifier, the server-side counterpart of #16's client-verifies-server. Retitled to "Verify the client certificate for mutual TLS", and gave the consumer half (swift-nio-quic#134) the same title for the pair.


Generated by Claude Code

claude added 2 commits June 19, 2026 19:21
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
ydnar pushed a commit to alta/swift-nio-quic that referenced this pull request Jun 19, 2026
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)

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.

Why requirePeerCertificate: false?

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.

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) {

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.

Default this to false to simplify the client calling case.

claude added 2 commits June 20, 2026 01:10
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
Comment thread Sources/NIOSSL/QUIC/NIOSSLQUICHandshake.swift
@ydnar
ydnar merged commit af21827 into main Jun 20, 2026
44 checks passed
ydnar pushed a commit to alta/swift-nio-quic that referenced this pull request Jun 20, 2026
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.
ydnar added a commit to alta/swift-nio-quic 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
ydnar added a commit that referenced this pull request Jun 30, 2026
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
ydnar added a commit that referenced this pull request Jul 17, 2026
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
@ydnar
ydnar deleted the quic-mtls-verify 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