Implement the ASGI TLS extension (#788)#854
Open
matssun wants to merge 1 commit into
Open
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
adamsway144000-crypto
approved these changes
Jun 10, 2026
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.
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.