dotnet / dotnet/aspnetcore

[Experimental] [DirectTls] Support ClientCertificateMode.DelayCertificate (post-handshake client authentication)

Closed
#67,915 1 comment 0 reactions 1 assignee Claimed by @DeagleGross View on GitHub
area-networking
Dominant language
C#
Stars
38.4k
Forks
10.9k
Avg merge
2d 10h
Merged PRs (30d)
281

Description

Follow-up for the experimental **DirectTls** transport (proposal #67909, draft implementation #67912).

DirectTls does **not** support `ClientCertificateMode.DelayCertificate` today — it throws `NotSupportedException` when that mode is configured. `NoCertificate`, `Allow`, and `Require` are fully supported.

## What DelayCertificate is

`ClientCertificateMode.DelayCertificate` lets an app request a client certificate **after** the handshake — on demand, the first time the app calls `ITlsConnectionFeature.GetClientCertificateAsync()` (e.g. "only `/admin` needs a client cert; the rest of the site does not"). In TLS 1.3 this is **post-handshake authentication (PHA)** ([RFC 8446 §4.6.2](https://www.rfc-editor.org/rfc/rfc8446#section-4.6.2)): the server sends a `CertificateRequest` after the handshake, and the client answers with a `Certificate` message — or an **empty** one to decline.

This is an **HTTP/1.1-only** feature: HTTP/2 prohibits TLS renegotiation / PHA ([RFC 8740 §2.1](https://www.rfc-editor.org/rfc/rfc8740#section-2.1)).

## Why SslStream can do it and DirectTls cannot

The difference is **who owns the transport**.

- **`SslStream`** owns the socket and drives the PHA exchange frame-by-frame, watching the crypto status transition `ContinueNeeded → OK`. When the client declines (empty Certificate), it still processes every frame itself, so it *knows* the exchange finished and can surface `RemoteCertificateNotAvailable`.
- **DirectTls** hands the raw fd to OpenSSL and reads through the runtime's `TlsSocketSession.Read`. The PHA flight is processed **inside** the runtime's socket read and then silently swallowed. The public `Read()` only ever returns `Complete` (real app data) / `Closed` / `NeedMoreData`. There is **no return value, event, or status** that says "a post-handshake Certificate just arrived."

```mermaid
sequenceDiagram
participant App as Kestrel app
participant T as DirectTls transport
participant S as TlsSocketSession Read - runtime
participant Peer as Client

App->>T: GetClientCertificateAsync
T->>Peer: server post-handshake CertificateRequest
Peer-->>S: Certificate message, or EMPTY to decline
Note over S: runtime decrypts and processes the PHA flight,
populates cert, then continues and swallows it
S-->>T: Read returns Complete app-data or NeedMoreData
Note over T: transport never sees the transition.
Success: poll GetRemoteCertificate, non-null.
Decline: cert stays null, indistinguishable from still waiting
```

## The core gap

| | Success (client sent a cert) | Decline (client sent empty Certificate) |
| --- | --- | --- |
| How DirectTls could detect it | poll `GetRemoteCertificate()` until non-null | **no signal** — cert stays null, exactly like "still waiting" |

So **success is pollable but decline is not observable at all.** The only way to resolve a decline on the fd path today is a **forced timeout** (wait N seconds; if the cert is still null, treat as declined) — which couples correctness to a magic delay and adds latency to exactly the requests that declined. That's not acceptable, so the mode is deferred.

## What unblocks it

A minimal, `Read`-decoupled signal from the runtime that a post-handshake Certificate flight completed. With it, DirectTls resolves **both** success and decline deterministically, with no timeout.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.