Skip to content

h3: accept client trust anchors as PEM text, not only as a path - #192

Merged
MDA2AV merged 2 commits into
mainfrom
fix/h3-client-ca-pem
Aug 17, 2026
Merged

h3: accept client trust anchors as PEM text, not only as a path#192
MDA2AV merged 2 commits into
mainfrom
fix/h3-client-ca-pem

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Mutual TLS over HTTP/3 could only be configured with a client CA file. The TCP side has always taken either form:

public string? ClientCaPath { get; init; }   // a bundle on disk
public string? ClientCaPem  { get; init; }   // the same bundle as text

QUIC took only the path. iq_engine_new_mtls handed it straight to X509_STORE_load_locations, which cannot read memory, so there was nowhere for the text form to go.

The consequence was worse than an inconvenience. A caller holding only the PEM string - a host that keeps its bundle in a secrets store rather than on disk - got mutual TLS on HTTP/1.1 and HTTP/2 and none at all on HTTP/3. Nothing was logged, because from the shim's point of view no client CA had been configured. On an endpoint serving Protocols.All that is the same origin enforcing client certificates on two transports and waving everyone through on the third.

What changed

iq_engine_new_mtls takes client_ca_pem alongside client_ca_pem_path, and builds the same X509_STORE from a BIO when given text, adding each block with X509_STORE_add_cert. A block already present counts rather than failing - a bundle naming one anchor twice is not an error. With both sources null the whole block is skipped exactly as before, so a server that never asked for mutual TLS gets the handshake it had, byte for byte.

QuicEngine refuses both sources at once instead of quietly preferring one, matching what TlsService already does for TCP.

Testing

Three tests added to the h3 mutual-TLS suite, anchors loaded from text in each:

  • a trusted client connects and the handler is told who it is
  • a certificate from another CA is refused - the one that matters, since a store that verified nothing would pass the first test just as happily
  • naming both a file and text is refused

Full suites, all green: E2E 54, Tls 23, Chaos 47, Unit 31, Http 28 - 183 passed, 0 failed.

Also exercised through Playground/Http3/MutualTls, which now takes PLAYGROUND_CLIENT_CA_PEM:

run result
PEM text, certificate optional h3x connects anonymously, 5/5
PEM text, certificate required h3x offers none, 0/5 - refused
malformed PEM text refused at startup: the client CA PEM text held no usable certificate

Performance

The native library was rebuilt (ngtcp2 + picotls + shim), so the benchable h3 samples were re-measured against their recorded baselines at the same reactor count:

sample baseline 96137b9 this branch
Http3/Nghttp3Buffered 516,707 req/s 523,782 req/s +1.4%
Http3/Buffered 665,936 req/s 657,288 req/s -1.3%

Noise in both directions. The added code runs once, at engine construction, and not on any data path.

No bench result is committed here: the run was made on a dirty tree, and recording that as latest.json is precisely what 9b4579e set out to stop.

MDA2AV added 2 commits August 17, 2026 16:37
The TCP side takes its client CA either way - ClientCaPath or ClientCaPem - because
TlsService parses the text into the store itself. QUIC took only a path:
iq_engine_new_mtls handed it to X509_STORE_load_locations, which cannot read memory. So
a host keeping its bundle in a secrets store had to write it to disk for HTTP/3 alone,
and anything that had only the PEM string got mutual TLS on HTTP/1.1 and HTTP/2 and
none whatsoever on HTTP/3, without a word said about it.

The shim builds the same store from a BIO when given text, adding each block with
X509_STORE_add_cert - the same anchors reached the other way. A block already in the
store counts rather than failing, since a bundle naming one anchor twice is not an
error. Both sources null still skips the whole block, so a server that never asked for
mutual TLS gets the handshake it had, byte for byte.

QuicEngine refuses both sources at once instead of quietly preferring one, which is
what TlsService already does on the TCP side.

Covered by the h3 mutual-TLS suite: a trusted client is admitted and named, and one
signed by another CA is refused at the crypto layer, with the anchors loaded from text
both times - the second test matters, since a store that verified nothing would pass
the first. Playground/Http3/MutualTls takes PLAYGROUND_CLIENT_CA_PEM for the same.

The native library was rebuilt for this. The two benchable h3 samples land within 1.5%
of their recorded baselines, which is noise.
Named for the pull request that carries it: mutual TLS over HTTP/3 accepts trust
anchors as PEM text, which the TCP side already did. A minor rather than a patch
because QuicEngine's constructor grew a parameter, and because a caller that only ever
had the PEM string was silently getting no client verification on HTTP/3 - anyone in
that position wants to know the version they move to is different in kind, not degree.
@MDA2AV
MDA2AV force-pushed the fix/h3-client-ca-pem branch from 69c5074 to dabcbd4 Compare August 17, 2026 15:38
@MDA2AV
MDA2AV merged commit 1fd549e into main Aug 17, 2026
1 check passed
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