HTTP mTLS in Direct Mode — closing the other half of #25¶
Status: ✅ Done (2026-08-23) — implemented, tested end to end against a real running
HTTPGateway, all 1659 existing tests + 5 new ones pass, 3x stable, ruff/mypy --strict clean.
Verified against civitas-io/presidium's own real mTLS test suite too, via a local editable
install (not committed as a dependency change) — all 4 of its previously-failing scenarios now
pass with this fix installed. Presidium itself stays pinned to the last published civitas
release until this ships in a real tagged version; see "Downstream rollout" below.
Source: GH #25 (originally closed by
mtls_source="proxy_header", gateway-http-mtls-proxy.md, v0.7.3 — that design explicitly left
direct mode "unchanged, still non-functional," see its own §1/Milestones R9 row); surfaced again
2026-08-23 from civitas-io/presidium, which needed direct-mode mTLS to actually work for a
self-hostable, single-process governance server (no reverse proxy required) and found — the same
day, independently, before finding this doc's own prior art — that require_client_cert never
succeeds against a real uvicorn deployment. See civitas-io/presidium/docs/design/presidium-server.md
and its own roadmap's M7 section for the consuming side of this work.
Builds on: civitas/gateway/mtls.py's existing _client_cert_from_scope(), _dn_from_der(),
_check_dn() — all correct and unchanged; the gap is entirely on uvicorn's side of the boundary,
not civitas's authorization logic.
1. Problem¶
docs/gateway.md's own "HTTP mTLS via a reverse proxy" section already documents the root cause
precisely: uvicorn never exposes the client certificate from its own TLS handshake to the ASGI
app (uvicorn#400). civitas.gateway.asgi.
_client_cert_from_scope() reads scope["extensions"]["tls"]["client_cert_chain"] — the
documented, spec-shaped ASGI TLS extension — but nothing in uvicorn's HttpToolsProtocol/
H11Protocol ever populates it. Confirmed empirically this session, not just from the docs:
grep-ing uvicorn's entire installed source tree for getpeercert/extensions/tls returns
zero matches in any protocol implementation.
Concrete, live consequence, reproduced end to end: a real self-signed CA, a real server leaf,
a real client leaf signed by that CA, client_cert_mode="required" — the TLS handshake itself
succeeds (uvicorn's ssl_cert_reqs=CERT_REQUIRED does work; a client presenting no cert or a
cert from an untrusted CA is correctly refused at the transport layer). But require_client_cert
still returns 401 {"error": "client certificate required"} for the fully valid, correctly
signed, allowlisted client — because request.client_cert is always None. mTLS in direct
mode currently locks out every legitimate client, not just illegitimate ones — worse than
"theater," a real functional dead end for anyone who can't or doesn't want to run a
TLS-terminating reverse proxy in front of civitas.
mtls_source="proxy_header" (R9, v0.7.3) is a real, working fix — but it requires a materially
different deployment topology (a real reverse proxy doing real TLS termination). Presidium's own
M7 milestone wants a genuinely self-hostable, single-process server; forcing a mandatory proxy
dependency onto every deployment to get mTLS at all is a real, avoidable cost this design removes.
2. The mechanism, verified empirically before writing any implementation code¶
Standard Python ssl/asyncio already exposes exactly what's needed — uvicorn's ASGI layer just
never forwards it. Verified with a minimal, real asyncio TLS server (no civitas, no uvicorn) in
this session, not assumed from documentation:
ssl_obj = transport.get_extra_info("ssl_object")
der = ssl_obj.getpeercert(binary_form=True) # real DER bytes of the client's leaf cert
Confirmed: the DN extracted from der via civitas's own existing _dn_from_der() is
byte-identical to the DN of the certificate the client actually presented. This is the same
primitive ssl.SSLSocket.getpeercert() gRPC's own transport already relies on indirectly (via
grpc's C-core) — Python's stdlib has always had this; uvicorn simply never wires it into the
ASGI scope it builds.
uvicorn.Config.http already accepts type[asyncio.Protocol] directly (not just the string
names "h11"/"httptools"/"auto") — a real, existing, documented extension point, not a
private API being relied on. httptools is the concrete implementation "auto" resolves to in
this repo's dependency set (uvicorn.config.HTTP_PROTOCOLS["auto"] picks it when installed, which
it is here).
3. Design approach¶
A new module, civitas/gateway/_tls_protocol.py, defines TlsAwareHttpToolsProtocol, a thin
subclass of uvicorn.protocols.http.httptools_impl.HttpToolsProtocol:
connection_made(transport): callsuper().connection_made(transport), then capturetransport.get_extra_info("ssl_object")once per connection (self._civitas_ssl_object) — a plaintext connection has no SSL object, so this isNonefor non-TLS gateways and costs nothing.on_message_begin(): callsuper().on_message_begin()(builds the fresh per-requestself.scopedict, unchanged), then, only ifself._civitas_ssl_object is not None: callgetpeercert(binary_form=True); if it returns real bytes, setself.scope["extensions"] = {"tls": {"client_cert_chain": [der], "client_cert_name": _dn_from_der(der)}}— reusing the exact shared DN extractor gRPC's mTLS path already uses (mtls.py's own_dn_from_der), so DN string format is guaranteed identical across every transport by construction, not by an unverified claim that two paths happen to agree (the same principlegateway-ws-grpc-auth.md's D4 already established for HTTP vs. gRPC).HTTPGateway's own uvicornConfig(...)construction (core.py, next to the existingssl_certfile/ssl_cert_reqslines): passhttp=TlsAwareHttpToolsProtocolinstead of leaving the default"auto"string, but only whenclient_cert_mode != "none"andmtls_source == "direct"— a plaintext gateway or aproxy_headerdeployment gets uvicorn's ordinary default protocol, completely unaffected by this change.
No change to _client_cert_from_scope(), require_client_cert, _check_dn(), or
_dn_from_der() — they already expect exactly this shape (confirmed by _client_cert_from_scope's
existing, pre-#25 implementation); this closes the gap in what actually delivers the data they
already correctly consume.
4. Decisions¶
| # | Decision | Rationale |
|---|---|---|
| D1 | Subclass HttpToolsProtocol specifically, not H11Protocol or a protocol-agnostic shim. |
httptools is the concrete implementation this repo's dependency set actually resolves to (uvicorn.config.HTTP_PROTOCOLS["auto"]); a dual-implementation shim adds real maintenance surface for a code path (h11 fallback, used only when httptools isn't installed) this repo doesn't exercise. Revisit only if httptools is ever dropped as a dependency. |
| D2 | Only swap the protocol class when client_cert_mode != "none" AND mtls_source == "direct". |
Keeps the change fully inert for the two deployment shapes that don't need it (plaintext, proxy_header) — zero behavioral or performance change for any existing deployment. |
| D3 | Reuse mtls.py's existing _dn_from_der(), do not add a second DN-extraction path. |
Matches gateway-ws-grpc-auth.md's own D4 principle: one shared function, so HTTP-direct, HTTP-proxy-header, and gRPC's independently-necessary extraction all agree on DN format by construction. |
| D4 | Capture the SSL object once in connection_made, not on every request. |
A connection's peer certificate cannot change mid-connection (TLS renegotiation is disabled by default and civitas does not enable it) — matches HTTP/1.1 keep-alive's existing one-handshake-many-requests shape; avoids a redundant get_extra_info() call per request. |
| D5 | No change to the "no certificate" or "wrong DN" failure paths. | require_client_cert's existing _NoCertificate/_Forbidden/_MtlsMisconfigured handling is correct today and untested only because it never had real data reaching it — this design supplies the missing data, not new authorization logic. |
5. Threat model¶
No new trust primitive is introduced. The trust anchor is unchanged: tls_ca_cert must be a
dedicated private CA (per mtls.py's own long-standing module docstring) — this design does not
change what "signed by a trusted CA" means, only makes the already-correct DN-allowlist check
after that point actually reachable. ssl.CERT_REQUIRED (uvicorn's own, already-correct handling
of client_cert_mode="required") continues to reject a missing or untrusted-CA certificate at the
TLS layer itself, before any of this design's code runs — confirmed empirically this session (see
§6 test plan) that this half was never actually broken; only the app-layer authorization on top of
a successful handshake was.
6. Test plan¶
Mirrors the four real handshake scenarios civitas-io/presidium wrote (and found this gap with)
in packages/presidium-contrib/tests/integration/test_presidium_server_mtls.py, run here against
a real HTTPGateway + real uvicorn directly (closing the gap in this repo, not just downstream):
- Trusted client cert (signed by the configured CA), DN in the allowlist →
200. - Client cert signed by the same trusted CA, DN not in the allowlist →
403(proves the TLS-trust layer and the DN-authorization layer are both real and distinct, not conflated). - No client certificate presented → TLS handshake itself fails (never reaches the ASGI app).
- Client cert signed by a different, untrusted CA → TLS handshake itself fails.
Plus: a plaintext (no TLS) gateway and a mtls_source="proxy_header" gateway both continue
unaffected (protocol class unchanged in both cases) — regression coverage for D2.
7. Non-goals / fast-follows¶
- WS mTLS via this mechanism —
gateway-ws-grpc-auth.md§8 deferred WS mTLS pending #25; this design closes the HTTP half. The samescope["extensions"]["tls"]shape is available to a WS upgrade handshake the identical way (uvicorn's WS protocol implementations would need the same treatment) — a natural, low-risk fast-follow, not bundled here to keep this change's own scope (HTTP direct-mode only) tight and reviewable, matching the precedentgateway-http-mtls-proxy.md§8 already set for its own scope cut. H11Protocolsupport — see D1; only needed ifhttptoolsstops being the resolved default.- HTTP/3 — unaffected;
enable_http3is already, separately, incompatible with anyclient_cert_mode(aioquic cannot enforce client certs), unchanged by this design.
9. Real bugs found while implementing, before landing (plus one found right after publishing)¶
-
A real, live packaging bug, found by v0.11.2's own release verification, fixed same-day as v0.11.3:
_tls_protocol.pyimportsuvicorn.protocols.http.httptools_impleagerly at module level, andcore.pyimported that module at its own top level -- defeating this codebase's established discipline of only ever importinguvicornlazily insideon_start(). A plainpip install civitasfailed entirely. Fixed by moving the_tls_protocolimport to be lazy too, confirmed against a real fresh-venv install both with and withoutcivitas[http]. -
A structural nesting bug in
core.py's own edit: the pre-existing D9 "proxy_header withoutrequire_client_certin middleware" guard got accidentally moved inside the newdirect-mode block during editing, corruptingclient_cert_mode="required"+mtls_source="direct"startup (a real, pre-existing, previously-passing test --test_on_start_passes_ssl_cert_reqs-- caught this immediately). Fixed by keeping eachmtls_sourcebranch's own guard scoped to itself. - A real bug in the test harness, not the fix: the first version of both this repo's new
test and
civitas-io/presidium's own mirrored test used httpx's deprecatedcert=(cert_path, key_path)+verify=<str ca_path>combination -- which produced a bare, signal-freehttpx.ReadErrorfor the two "should succeed" scenarios, withTlsAwareHttpToolsProtocol.connection_madenever even being called server-side. Diagnosed by comparing a rawasyncio.open_connection(ssl=...)client (worked immediately) against httpx side by side against the same running server. Root cause: httpx's legacycert=path has a real, current incompatibility with a plain stringverify=path in this httpx version (0.28.1) that the modern, recommended API -- one fully-configuredssl.SSLContext(built with bothload_verify_locations()andload_cert_chain()) passed asverify=<context>-- does not have. Confirmed empirically: switching only the test's client construction (no server-side code change) made all four real handshake scenarios pass immediately. Both this repo's test and Presidium's now use the modern API. - Two cert-generation gotchas, not a design bug: modern OpenSSL (3.x) refuses chain validation
without (1) a
SubjectKeyIdentifieron the CA matched by anAuthorityKeyIdentifieron each leaf, and (2) aKeyUsageextension on the CA assertingkeyCertSign/cRLSign. Neither is needed bycivitas's own existing gRPC mTLS test fixture (test_gateway_ws_grpc_auth.py'stls_certs) becausegrpc.aio's own cert validation path is more lenient than Python'ssslmodule (used here by uvicorn/httpx) -- a real, useful fact for anyone building a similar fixture in this repo in future.
10. Downstream rollout¶
civitas-io/presidium needs this fix to make its own M7 mTLS handshake test pass for real -- but
Presidium pins civitas>=0.11.0 (a real, published PyPI release), and this fix is not published
yet. Verified the full integration works end to end via a local, uncommitted editable install
of this repo into Presidium's venv (uv pip install -e <this repo>), confirming all 4 of
Presidium's own mTLS scenarios pass with this fix present -- then reverted Presidium's venv back
to the published dependency. Presidium's own mTLS test file marks its two currently-blocked
scenarios xfail(strict=True) with a reason citing this design doc, so: (a) Presidium's CI stays
accurately green today (a real, tracked, not-yet-shipped gap, not a mysterious red), and (b) the
moment this fix ships in a real civitas release and Presidium bumps its pin, strict=True
forces those xfail markers to fail loudly (since the tests would unexpectedly start passing) --
the correct signal to remove the markers and graduate the tests to real, enforced coverage.
11. References¶
- uvicorn#400 — the underlying upstream gap.
- GH #25 — this repo's tracking issue
(reopened for the
direct-mode half;proxy_headerhalf already shipped, v0.7.3). docs/design/gateway-http-mtls-proxy.md— theproxy_headersibling design; both now exist side by side as two independently valid deployment modes for the same underlying authorization logic (mtls.py, unchanged by either).civitas-io/presidium/docs/design/presidium-server.mdand its roadmap's M7 section — the consuming side that surfaced this gap in practice.