Skip to content

feat(quic): mutual TLS - verify client certificates, and say who connected - #180

Merged
MDA2AV merged 2 commits into
mainfrom
feat/quic-mtls
Aug 11, 2026
Merged

feat(quic): mutual TLS - verify client certificates, and say who connected#180
MDA2AV merged 2 commits into
mainfrom
feat/quic-mtls

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Closes #179.

QUIC's handshake is TLS 1.3, so client certificates were always possible - the shim just never set the two picotls fields that ask for one.

The server side

iq_engine_new_mtls takes a client CA bundle and a require flag, wires ptls_openssl_init_verify_certificate, and sets require_client_authentication. iq_engine_new still exists and forwards NULL, so a server that does not want mTLS gets byte-for-byte the handshake it had.

using var engine = new QuicEngine(certPath, keyPath, alpn: ["h3"],
    clientCaPemPath: "ca.crt", requireClientCertificate: true);

// per REQUEST - see below
string? peer = (connection as QuicEngineConnection)?.PeerSubject;   // "CN=alice, O=..."

The identity mattered as much as the gate. picotls does not keep the peer chain after the handshake, so a wrapper around the verifier takes the subject as it is proved and parks it on the connection. A server that can only answer some valid certificate cannot authorise anything.

The client side, which was not optional

iq_client_engine_new_mtls lets ioxide's own client present a certificate. That was not scope creep: neither curl's quiche backend nor h2load can send a client certificate over HTTP/3, so without it there was no way to test the server at all.

Worth confirming rather than assuming - pointing the same curl invocation at nginx with ssl_verify_client on returned 400 No required SSL certificate was sent, which established it was the client and not us.

Two things that made green tests meaningless

Both cost real time, and both are the kind of thing that passes while proving nothing:

  • iq_conn_is_established is the CLIENT's view. In TLS 1.3 the client finishes its own side before it can learn the server rejected its certificate, so "handshake completed" says nothing about acceptance. The tests assert on being served.
  • PeerSubject read where the connection is accepted is always null - that callback runs before the handshake. It has to be read per request. The sample and the pane both say so, because it will catch the next person.

Tests

E2E gains four:

trusted certificate connects, and the handler sees CN=alice
certificate from another CA refused
no certificate offered refused
no client CA configured nothing asked for, peer is anonymous

The last one is the regression guard for everyone not using mTLS.

Unit 36, Chaos 47, Http 38, E2E 51 - all 0 failed, 0 skipped.

No regression

Against main, mTLS off, one reactor, Playground/Http3/Buffered:

main this branch
steady state ~299,500 req/s ~303,800 req/s
full handshake per connection 71 / 71 / 71 72 / 71 / 71

Steady state interleaves across reps, so that is noise rather than a gain. The handshake figure is the one that mattered, since that is the path that changed, and it is identical. Measured with h3x --reconnect 1 --no-resumption so every exchange is a full handshake, against a baseline worktree built from main and checked to have no mTLS symbols.

One QUIC constraint the API cannot hide

RFC 9001 section 4.4 forbids post-handshake authentication. Over TCP a server can ask for a certificate lazily when a request reaches a protected route. QUIC cannot: client auth is settled during the handshake and applies to the whole connection. "mTLS on /admin only" needs a second port, and the sample, the pane and the XML docs all say so rather than letting someone discover it.

Playground/Http3/MutualTls and a site pane. 0.4.179.

MDA2AV added 2 commits August 11, 2026 03:25
…ected

Closes #179.

QUIC's handshake is TLS 1.3, so client certificates were always possible; the
shim just never set the two picotls fields that ask for one. It does now:
iq_engine_new_mtls takes a client CA bundle and a require flag, wires
ptls_openssl_init_verify_certificate, and sets require_client_authentication.
iq_engine_new keeps working unchanged and forwards NULL, so a server that does
not want mTLS gets byte-for-byte the handshake it had.

The identity mattered as much as the gate. picotls does not keep the peer chain
after the handshake, so a wrapper around the verifier takes the subject as it is
proved and parks it on the connection, where QuicEngineConnection.PeerSubject
reads it. A server that can only answer "some valid certificate" cannot
authorise anything.

The client engine can present a certificate too - iq_client_engine_new_mtls -
which was not optional to add: neither curl's quiche backend nor h2load can send
one over HTTP/3, so without it there was no way to test the server at all. That
was worth finding out early; nginx rejected the same curl invocation with 400,
which is how it was confirmed to be the client rather than us.

Two things the tests had to be rewritten around, both worth stating because
either makes a green test meaningless:

  - iq_conn_is_established is the CLIENT's view. In TLS 1.3 the client finishes
    its side before it can learn the server rejected its certificate, so
    "handshake completed" proves nothing. The tests assert on being SERVED.
  - PeerSubject read where the connection is accepted is always null - that
    callback runs before the handshake. It has to be read per request.

E2E has four: a trusted certificate gets in and is named, one from another CA is
turned away, one that offers nothing is turned away, and with no client CA
configured nothing is asked for and the peer is anonymous.

No regression with mTLS off, against main, one reactor:

  steady state   ~299,500 -> ~303,800 req/s   (interleaves across reps; noise)
  full handshake  71/71/71 -> 72/71/71 req/s  (identical)

Handshake rate is the one that mattered, since that is the path that changed.

Playground/Http3/MutualTls and a site pane. 0.4.179.
CI failed where local passed:

  The requested notAfter value (08/11/2027 02:27:30) is later than
  issuerCertificate.NotAfter (08/11/2027 02:27:29)

EnsureMutualTls took DateTimeOffset.UtcNow separately for the CA and for each
leaf it signed. When those calls straddle a second boundary the leaf would
outlive its issuer, which .NET refuses outright - so the fixture only worked
when certificate generation happened to fit inside one second. Locally it did;
on a fresh CI runner it did not.

The window is computed once now, and leaves expire a year inside the CA rather
than alongside it, so the outcome no longer depends on how long generation
takes. Verified with three runs from a clean temp directory rather than one -
the previous green was luck, and one more would have been too.
@MDA2AV
MDA2AV merged commit 0ccb2ee into main Aug 11, 2026
1 check passed
MDA2AV added a commit that referenced this pull request Aug 12, 2026
* refactor(httpclient): HTTP/1.1 only - drop the h2 and h3 clients

The client half never kept pace with the server half. HTTP/2 and HTTP/3
clients mean owning HPACK and QPACK, dynamic tables in both directions and
per-stream flow control, and nothing drove them: the E2E suite proved the
test harness worked, not the shipped package. The mTLS gap was the tell -
QuicClientEngine still cannot present a certificate to a server this repo
just taught to demand one, and no test noticed.

HTTP/1.1 is the leg that actually carries proxy-to-origin traffic, and it is
small enough to keep correct.

Gone: Http2ClientConnection/Pool, Http3ClientConnection/Pool/Messages, and
RingHttpClient - the Alt-Svc negotiating client, which only existed because
there were three protocols to choose between. With it go AltSvcTests,
Http2ClientTests, Http3ClientTests, RingHttpClientTests, the two h2-over-TLS
cases in TlsClientTests, and six of the nine Proxy/* samples. The three that
remain - h1/h2/h3 in, h1 out - are the shapes people deploy.

The dependency graph is the real prize. ioxide.httpclient referenced
ioxide.ngtcp2, ioxide.http2 and ioxide.nghttp3; it now references ioxide core
alone, and the InternalsVisibleTo("ioxide.httpclient") declarations are gone
from all three protocol packages. A client that shares no internals with a
protocol package cannot drift out of sync with one.

Build clean, 29 unit and 18 http tests green.

* feat(httpclient): parse responses with Glyph11 instead of by hand

The hand-rolled head parser was written to get a response out of a
well-behaved origin, and it showed. It skipped any line it could not read
("tolerate a junk line rather than failing the response"), took the last
Content-Length it saw rather than rejecting conflicting ones, had no opinion
on Transfer-Encoding arriving alongside a Content-Length, and trimmed values
with Trim(' ') so a HTAB survived into the value. Every one of those is a
desync waiting for the next request on a keep-alive connection.

Glyph11 0.3.7 does that job properly, so it does it now: bare LF, obs-fold,
whitespace before the colon, token and field-value charsets, Content-Length
format and duplicates, and the Transfer-Encoding + Content-Length pair. Pure
managed, no native asset, no transitive dependency - httpclient still
references ioxide core and nothing else of ours.

Framing moves to BodyFramingDetector, which takes the request method and
therefore gets HEAD right by construction: a HEAD response carries the
Content-Length its body would have had, so framing on the response alone
blocks waiting for bytes that are never coming. 1xx/204/304 and CONNECT
tunnels come from the same call rather than a hand-maintained list.

Completeness is now the parser's answer too - TryExtractFullResponseHeaderROM
returns false for a partial block - so the separate scan for the terminator
is gone. Note the +1: Glyph11 reports one less than the block's real size,
as its own diff harness documents.

Also drops ResponseAssembly and its tests, dead since the h2/h3 clients went.

New: five tests driving responses no server should send - both framings at
once, conflicting Content-Lengths, obs-fold, a bare LF inside a header line,
and the HEAD trap. All were accepted before this change.

Build clean, 25 unit and 23 http tests green.

* refactor(httpclient): use ioxide's UnmanagedMemoryManager rather than a second one

ioxide.utils already exports exactly this type; the nested copy was a
duplicate. Reset is internal to ioxide, so the view is rebuilt on the rare
realloc instead of re-pointed.

* feat(httpclient): mutual TLS - present a client certificate to an origin

The gap that started this: ioxide could REQUIRE mutual TLS as a server (#180)
and had no client that could satisfy one. TlsClientOptions gains
CertificateFile and PrivateKeyFile, loaded through
SSL_CTX_use_certificate_chain_file and SSL_CTX_use_PrivateKey_file.

The chain form of the certificate call, not the leaf form: an origin
validating us needs the intermediates, and leaf-only works against a store
that already holds them while failing against every other one - the worst way
for this to break.

Nothing here arms anything. Client authentication is driven by the server
sending a CertificateRequest, so configuring a certificate against an origin
that never asks costs a file read and changes no handshake. Unlike QUIC there
is no RFC 9001 4.4 restriction to reason about.

Setting one of the two without the other throws at construction, as does a
key that does not match its certificate - OpenSSL catches the mismatch while
loading the key, and check_private_key backstops the rest. Both fail where
the configuration is written rather than as an opaque handshake error against
one origin later.

Five tests, driven against SslStream so a pass means we agree with an
independent implementation: a good certificate accepted and the identity
asserted at the origin (CN=alice actually arrived, not merely a successful
handshake), no certificate refused, a certificate from an untrusted CA
refused, and both configuration errors.

Build clean, 25 unit and 28 http tests green.

* chore: 0.4.184

Also drops the stale claim from ioxide.http2 and ioxide.nghttp2 that they
back an HTTP/2 client in ioxide.httpclient. They no longer do, and the text
ships on nuget.org.
MDA2AV added a commit that referenced this pull request Aug 12, 2026
ioxide could require client certificates over QUIC (#180) and not over TCP.
The TCP server had no peer verification at all: no SSL_CTX_set_verify, no
load_verify_locations, and nothing in TlsOptions to configure one. An
https:// port could prove who the SERVER was and never ask the other
question.

TlsOptions gains ClientCaPath, ClientCaPem and RequireClientCertificate.
Anchors from a file go through load_verify_locations; anchors from memory are
parsed into the context's own store, mirroring how the server's certificate
already comes from either. With a file we also send the CA names in the
CertificateRequest, so a client holding several certificates picks the one
this server accepts instead of guessing - a failure to enumerate them costs
the hint, not the verification.

RequireClientCertificate is the interesting distinction. Off, a client with
no certificate still connects and TlsSession.PeerSubject is null, so a
handler can serve a public route and refuse a protected one. On, it is
refused at the handshake. Either way a certificate that IS offered is
verified: "optional" governs presenting nothing, not presenting anything.

PeerSubject is the other half. Enforcing an identity is not much use if
nothing can read one, and a value there means the chain VALIDATED - an
invalid one already failed the handshake, so a connection that reaches a
handler never carries a merely-offered certificate.

Orthogonal to kTLS, which is why both samples exist and differ by one line.
The certificate is exchanged and verified during the handshake, which OpenSSL
performs either way; the kernel only takes over record crypto afterwards.
MtlsKtlsPipes is MtlsOpenSslPipes with KernelTx = true and nothing else.

Nothing changes for anyone not using it: every path here sits behind "are
anchors configured", and a server without them performs the handshake it
performed before.

Verified end to end with curl against the sample:
  valid client cert  -> 200, X-Client: /CN=alice
  cert from a rogue CA -> tlsv1 alert unknown ca
  no cert, optional  -> 403 from the handler
  no cert, required  -> tlsv13 alert certificate required

11 new tests, driven with SslStream so a pass means agreeing with an
independent implementation. Build clean; 25 unit, 23 tls and 28 http green.
The three kTLS cases skip without `sudo modprobe tls`.
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.

mTLS: no client-certificate support in the QUIC/HTTP3 stack

1 participant