Skip to content

Implement the ASGI TLS extension (#788)#854

Open
matssun wants to merge 1 commit into
emmett-framework:masterfrom
matssun:feat/asgi-tls-extension
Open

Implement the ASGI TLS extension (#788)#854
matssun wants to merge 1 commit into
emmett-framework:masterfrom
matssun:feat/asgi-tls-extension

Conversation

@matssun

@matssun matssun commented May 27, 2026

Copy link
Copy Markdown

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:

Key Value
tls_version negotiated version as an int (e.g. 0x0304), or None
cipher_suite negotiated cipher suite as its 16-bit IANA id, or None
client_cert_chain PEM-encoded client chain, leaf first; empty if none presented
client_cert_name None
client_cert_error None
server_cert None

The tls key 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_name is None. Deriving the RFC4514 DN would mean pulling in an X.509 parser; the spec explicitly allows None and notes it duplicates client_cert_chain[0], which apps can parse themselves. No new dependency.
  • server_cert is None. 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_error is always None. See the security note below — in Granian's model the app never sees a certificate that failed verification.
  • ASGI only. RSGI/WSGI carry no standard TLS-extension shape, so they only accept-and-ignore the threaded value.

Implementation notes

  • TLS metadata is captured once per connection, at accept time, after the handshake completes — read directly from the rustls ServerConnection on the accepted TlsStream. It is not recomputed per request.
  • A small PeerTlsInfo trait does the extraction. The plain TcpStream / UnixStream impls return None, which monomorphizes away — so the non-TLS serving path is unchanged and pays nothing, and it keeps reusing the cached process-wide extensions dict. Only TLS connections build a per-connection extensions dict carrying tls.
  • DER→PEM uses the pem crate already in the tree; no new dependencies.
  • The session info is threaded through the existing worker target signature the same way addr_remote already is.

Security: the trust boundary is unchanged

This was the main thing I wanted to get right, so it's covered explicitly by tests:

  • All client-certificate verification is still done by rustls before tls_listener yields the stream. A failed verification yields Err and no worker service is ever created — the application is never invoked. This code only reads already-verified, post-handshake state.
  • Therefore a populated client_cert_chain is always a verified chain, which is why client_cert_error is always None (the app cannot observe a cert that failed verification). Tests assert that untrusted, missing, and revoked (CRL) certificates are all rejected at the handshake.
  • The subtle case is covered too: with --ssl-ca but --no-ssl-client-verify (rustls allow_unauthenticated), a certificate is optional but a presented one must still be valid — an invalid presented cert is rejected, not surfaced as trusted.
  • No new panic / unwrap paths on peer-controlled data; chain size is bounded by rustls. The extension exposes only the peer's own certificate — no key material, and server_cert is None.

Tests

New suite in tests/test_tls_extension.py (23 tests), plus client-CA / chain / rogue / revoked fixtures and mtls / crl switches in conftest.py:

  • presence on TLS / absence on plaintext
  • tls_version values for forced TLS 1.2 / 1.3
  • mTLS single cert; leaf→intermediate chain ordering; PEM byte-exact round-trip to the original DER
  • consistency across keep-alive, and per-connection isolation under concurrent distinct clients
  • HTTP/2 and WebSocket (wss) paths
  • trust boundary: untrusted cert rejected (mandatory and optional verify), missing cert rejected when required, revoked cert rejected via CRL (with a control proving it's the CRL, not an invalid cert, doing the rejecting)

Test results

Full suite, CPython 3.13, macOS:

$ pytest tests/test_tls_extension.py -q
23 passed

$ pytest tests -q
196 passed, 4 skipped

The 4 skipped tests are the same ones skipped on master.

Rust checks clean with the project config:

$ cargo fmt --all -- --check        # clean
$ cargo clippy --lib -- -D warnings -W clippy::pedantic ...   # clean

Follow-up (kept separate)

#788 also asks whether --ssl-client-verify without a --ssl-ca should 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 when client_cert_chain is empty.

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
@matssun matssun marked this pull request as ready for review May 28, 2026 23:27
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.

2 participants