Implement the ASGI TLS extension (#788) - #854
Conversation
Expose TLS connection details to ASGI applications via scope["extensions"]["tls"], following the ASGI TLS extension spec (https://asgi.readthedocs.io/en/latest/specs/tls.html). The verified TLS session is captured once per connection from the rustls ServerConnection, after the handshake completes, and threaded into the request scope. The non-TLS serving path is unaffected: plain streams yield no session info (monomorphized away) and keep reusing the cached extensions dict. The tls dict reports tls_version and cipher_suite (as integers) and the PEM-encoded client_cert_chain (leaf first, empty when no client certificate was presented). Because Granian rejects invalid client certificates during the handshake, a populated chain is always verified, so client_cert_error is always None. client_cert_name and server_cert are None for now. Adds a dedicated test suite covering presence/absence, negotiated parameters, mTLS single/chain delivery, PEM integrity, HTTP/2 and WebSocket paths, per-connection isolation under concurrency, and the trust boundary: untrusted, missing, and revoked (CRL) client certificates are rejected at the handshake and never reach the application, including under optional verification. Refs emmett-framework#788, emmett-framework#93
764b41d to
1014a40
Compare
|
Rebased onto current master (2.8.1) — now a single linear commit, no conflicts. The only conflict was in I also fixed a real bug in my own PR while revalidating: the two HTTP/2 cases in Local run on macOS arm64 / CPython 3.14: 199 passed, 4 skipped (the PGO-only ones), 0 failures. This implements #788, labelled |
Summary
Implements the ASGI TLS extension, exposing the connection's TLS details to ASGI applications under
scope["extensions"]["tls"]. This closes the gap noted in #788: server-side mTLS (added in #574) already verifies client certificates, but the verified certificate was never communicated to the application.Ticks the TLS box in the extensions tracker (#93).
What the application sees
On a TLS connection,
scope["extensions"]["tls"]is a dict per the spec:tls_version0x0304), orNonecipher_suiteNoneclient_cert_chainclient_cert_nameNoneclient_cert_errorNoneserver_certNoneThe
tlskey is present only on TLS connections, so apps detect support by its presence, exactly as the spec intends.A few deliberate choices, kept minimal on purpose — happy to extend any of these if you'd prefer them in this PR:
client_cert_nameisNone. Deriving the RFC4514 DN would mean pulling in an X.509 parser; the spec explicitly allowsNoneand notes it duplicatesclient_cert_chain[0], which apps can parse themselves. No new dependency.server_certisNone. It is server-constant rather than connection-derived, so wiring it cleanly means threading the configured leaf through the acceptor. Left it out to keep this PR focused; easy to add if you want it.client_cert_erroris alwaysNone. See the security note below — in Granian's model the app never sees a certificate that failed verification.Implementation notes
ServerConnectionon the acceptedTlsStream. It is not recomputed per request.PeerTlsInfotrait does the extraction. The plainTcpStream/UnixStreamimpls returnNone, which monomorphizes away — so the non-TLS serving path is unchanged and pays nothing, and it keeps reusing the cached process-wideextensionsdict. Only TLS connections build a per-connectionextensionsdict carryingtls.pemcrate already in the tree; no new dependencies.targetsignature the same wayaddr_remotealready is.Security: the trust boundary is unchanged
This was the main thing I wanted to get right, so it's covered explicitly by tests:
tls_listeneryields the stream. A failed verification yieldsErrand no worker service is ever created — the application is never invoked. This code only reads already-verified, post-handshake state.client_cert_chainis always a verified chain, which is whyclient_cert_erroris alwaysNone(the app cannot observe a cert that failed verification). Tests assert that untrusted, missing, and revoked (CRL) certificates are all rejected at the handshake.--ssl-cabut--no-ssl-client-verify(rustlsallow_unauthenticated), a certificate is optional but a presented one must still be valid — an invalid presented cert is rejected, not surfaced as trusted.unwrappaths on peer-controlled data; chain size is bounded by rustls. The extension exposes only the peer's own certificate — no key material, andserver_certisNone.Tests
New suite in
tests/test_tls_extension.py(23 tests), plus client-CA / chain / rogue / revoked fixtures andmtls/crlswitches inconftest.py:tls_versionvalues for forced TLS 1.2 / 1.3Test results
Full suite, CPython 3.13, macOS:
The 4 skipped tests are the same ones skipped on
master.Rust checks clean with the project config:
Follow-up (kept separate)
#788 also asks whether
--ssl-client-verifywithout a--ssl-cashould be a hard error rather than a silent downgrade. That's a behaviour change, so I filed it separately as #853 to keep this PR focused. Worth noting this extension actually mitigates that footgun: an app can now fail closed whenclient_cert_chainis empty.