feat(quic): mutual TLS - verify client certificates, and say who connected - #180
Merged
Conversation
…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.
This was referenced Aug 11, 2026
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`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_mtlstakes a client CA bundle and a require flag, wiresptls_openssl_init_verify_certificate, and setsrequire_client_authentication.iq_engine_newstill exists 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. A server that can only answer some valid certificate cannot authorise anything.
The client side, which was not optional
iq_client_engine_new_mtlslets 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 onreturned400 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_establishedis 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.PeerSubjectread 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:
CN=aliceThe 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: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-resumptionso every exchange is a full handshake, against a baseline worktree built frommainand 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/MutualTlsand a site pane. 0.4.179.