Skip to content

feat(quiche)!: add mTLS client auth and TLS server name control#325

Merged
kixelated merged 2 commits into
mainfrom
claude/tls-certificates-section-d22d31
Jul 16, 2026
Merged

feat(quiche)!: add mTLS client auth and TLS server name control#325
kixelated merged 2 commits into
mainfrom
claude/tls-certificates-section-d22d31

Conversation

@kixelated

Copy link
Copy Markdown
Collaborator

Upstream work for the TLS and certificates section of moq-dev/moq#2296. Those gaps split across two repos — this covers the parts that genuinely need new API here. Outbound client certs and the SNI cert resolver were already exposed (ez::ClientBuilder::with_single_cert, ServerBuilder::with_cert_resolver), so those items are pure moq-native wiring and aren't touched.

Inbound mTLS + peer identity

ServerBuilder::with_client_auth takes a new ClientAuth policy — None (default), Optional(roots), or Required(roots) — applied to both the static-cert and cert-resolver paths. The verified chain comes back via Connection::peer_certificates(), which is what moq-native needs for Request::peer_identity (reachable as request.conn().peer_certificates()).

Two decisions worth a reviewer's attention:

  • Roots are parsed at build time, not inside the hook. A ConnectionHook can only report failure by returning None, and tokio-quiche then falls back to a context with no client auth — a silent security downgrade. Empty roots are rejected as a config error rather than fail-closed on every client.
  • The server now forces Settings::verify_peer off. quiche applies that flag after the hook and would overwrite the verification mode, quietly turning Required into Optional. ClientAuth is the server-side expression of the same knob. Documented on with_settings and pinned by a test.

Client hostname override

ClientBuilder::with_server_name decouples the TLS name from the dial target, on both the raw-QUIC and WebTransport builders. quiche's set_host_name drives SNI and the certificate hostname check together, so one setter covers --client-tls-host-name.

Fix: server_name() always returned None

Pre-existing bug, found while testing the override. ez::Server::run_socket read SNI off the SslRef before start() — but at that point quiche is still holding the Initial packet as unparsed bytes in params.initial_pkt, so BoringSSL had never seen the ClientHello and SSL_get_servername returned NULL. The cert-resolver path was unaffected, since its callback runs mid-handshake.

It's now captured post-handshake via qconn.server_name(), alongside the peer chain.

Behavior change: clients now report the SNI they sent, where they previously reported None (only the server path ever set it). That follows from SSL_get_servername, but it's observable if anything relied on None to infer "this is the client side".

Breaking: Incoming::server_name / Incoming::alpn removed

Both described values that only exist once the handshake completes, so they returned None where the type invited you to read them. They remain on Connection, which is only reachable through Incoming::accept or Connecting::established — so holding one already proves the handshake finished, and a None now means genuinely absent (no SNI sent, no ALPN negotiated) rather than not-yet-known. with_cert_resolver remains the way to act on SNI mid-handshake, the last point a server can still choose a certificate or refuse.

Safe for the known consumer: moq-native's quiche backend already reads peer_addr() off Incoming and alpn() off Connection after accept(). Nothing in this repo used either method.

While documenting that invariant I found poll_handshake was keying off alpn.is_some() as its "established" signal, while connected() sets alpn to None when nothing is negotiated. It holds today only because QuicSettings::alpn defaults to [b"h3"]; a caller passing Settings with an empty alpn would have hung in accept() forever. Now an explicit established flag, so the guarantee doesn't rest on that coincidence.

Tests

23 tests pass; just check and just test are green.

  • tests/client_auth.rs — required/optional × present/absent/untrusted, the peer chain the server observes, empty-roots rejection, and the verify_peer downgrade guard. The load-bearing assertions are the negative ones: an untrusted chain must fail the handshake, and a required cert must stay required.
  • tests/server_name.rs — SNI reporting, the override (accept + the reject that proves the check is real), and that SNI reaches a CertResolver. The two accessor tests fail without the fix (left: None, right: Some("localhost")).

🤖 Generated with Claude Code

Upstream work for the TLS and certificates gaps tracked in moq-dev/moq#2296,
where moq-native's quiche backend can't reach quinn/noq parity without new
API here.

Inbound mTLS: ServerBuilder::with_client_auth takes a ClientAuth policy
(None/Optional/Required) that applies to both the static-cert and
cert-resolver paths, and the verified chain is exposed via
Connection::peer_certificates for callers that need a peer identity.

Roots are parsed at build time rather than inside the TLS hook: a
ConnectionHook can only report failure by returning None, which falls back
to a context with no client authentication at all. Empty roots are rejected
as a config error instead of failing closed on every client. For the same
reason the server forces Settings::verify_peer off, since quiche applies it
after the hook and would overwrite the verification mode, quietly turning a
required client certificate into an optional one.

Client hostname override: ClientBuilder::with_server_name decouples the TLS
name from the dial target. quiche's set_host_name drives both SNI and the
certificate hostname check, so one setter covers both.

Also fixes Connection::server_name always returning None. It was read off
the SslRef before start(), where quiche is still holding the Initial packet
as unparsed bytes, so BoringSSL had never seen the ClientHello. It's now
captured post-handshake alongside the peer chain. Clients consequently
report the SNI they sent, where they previously reported None.

BREAKING CHANGE: Incoming::server_name and Incoming::alpn are removed. Both
described values that only exist once the handshake completes, so they
returned None where the type invited callers to read them. They remain on
Connection, which is only reachable via Incoming::accept or
Connecting::established, so holding one already proves the handshake
finished and a None now means genuinely absent rather than not yet known.
Use ServerBuilder::with_cert_resolver to act on SNI mid-handshake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @kixelated, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@kixelated, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 35 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a1433a91-e5bf-462e-9b7d-45673aec0020

📥 Commits

Reviewing files that changed from the base of the PR and between 635a325 and 141f79d.

📒 Files selected for processing (12)
  • rs/web-transport-quiche/README.md
  • rs/web-transport-quiche/src/client.rs
  • rs/web-transport-quiche/src/ez/client.rs
  • rs/web-transport-quiche/src/ez/connection.rs
  • rs/web-transport-quiche/src/ez/driver.rs
  • rs/web-transport-quiche/src/ez/mod.rs
  • rs/web-transport-quiche/src/ez/server.rs
  • rs/web-transport-quiche/src/ez/tls.rs
  • rs/web-transport-quiche/src/lib.rs
  • rs/web-transport-quiche/src/server.rs
  • rs/web-transport-quiche/tests/client_auth.rs
  • rs/web-transport-quiche/tests/server_name.rs
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/tls-certificates-section-d22d31

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Resolves a conflict with #322, which dropped ClientBuilder::with_metrics and
its M type parameter from the same client builder this branch adds
with_server_name to.

Kept the new server_name field and took main's `..self` struct literals. This
branch had spelled those literals out only because moving `metrics` explicitly
was what kept it from tripping the dead_code lint; with the field gone, that
reason goes with it.
@kixelated
kixelated enabled auto-merge (squash) July 16, 2026 16:41
@kixelated
kixelated merged commit 3c815c1 into main Jul 16, 2026
2 checks passed
@kixelated
kixelated deleted the claude/tls-certificates-section-d22d31 branch July 16, 2026 16:54
@github-actions github-actions Bot mentioned this pull request Jul 16, 2026
kixelated added a commit that referenced this pull request Jul 16, 2026
#325 landed ClientBuilder::with_server_name on main with the same field and
the same connect_with_config approach this branch used, so keep main's and
drop this branch's duplicate.

Also drop connect_to. It was proposed as the enabler for address-family-aware
DNS selection, but the existing surface already covers that: std's
ToSocketAddrs parses IP literals before falling back to DNS, so
connect(&addr.ip().to_string(), port) already dials a chosen address without
a lookup, and #325's with_server_name supplies the name to verify. connect_to
only saved a stringify-and-reparse.

Taking the first DNS result is also a bug rather than a knob to route around
-- binding 0.0.0.0 and resolving AAAA first fails the same way -- so the fix
belongs in connect() picking an address compatible with the socket, not in
new API that every caller must opt into. Tracked separately along with the
implicit-bind EINVAL that CodeRabbit found on this PR.

What remains is what only this branch provides: keep-alive, the GSO opt-out,
and the IPv6-literal URL fix. Server-side conflicts were with #325's
with_client_auth landing beside these builder methods; both are kept.

tests/verify.rs returns to main's copy, since #325's tests/server_name.rs now
covers SNI. Those tests dial a hostname, so the IPv6-literal path is covered
by the new tests/connect.rs, whose IPv6 case fails without the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot mentioned this pull request Jul 17, 2026
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.

1 participant