dotnet / dotnet/runtime

Silent no-op when all explicitly-configured ClientCertificates are ineligible for client authentication — consider a louder diagnostic

Open
#131,563 5 comments 0 reactions 0 assignees View on GitHub
area-System.Net.Security documentation
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

### Description

When `HttpClientHandler.ClientCertificates` (or the `SslStream` certificate collection path) contains only certificates that fail the eligibility check in `CertificateHelper.GetEligibleClientCertificate` — e.g. certificates whose EKU lacks Client Authentication (`1.3.6.1.5.5.7.3.2`) — .NET presents **no client certificate** during the TLS handshake. The only signal is an Info-level `NetEventSource` trace ("No eligible client certificate found."), which is invisible unless you're already tracing with `dotnet-trace`/an `EventListener`.

**I'm not asking to change the selection behavior.** #121982 settled that (and having now lived through the consequences, I think the conclusion there is right — a serverAuth-only certificate *shouldn't* be auto-selected for client auth). This issue is only about observability when that filtering results in nothing being sent despite explicit configuration.

I think this is about to affect many more people: per the Chrome Root Program policy (§4.2.1, effective June 15 2026), public CAs no longer include the clientAuth EKU in publicly-trusted TLS certificates (see the DigiCert sunset notice referenced in #121982). Many organizations use public CA certificates as mTLS client certificates against partner APIs. As those certificates renew over the coming months, each renewal silently converts a working mTLS integration into "no certificate sent" — with no code change and no .NET-side error.

### Reproduction Steps

1. Obtain a certificate + private key whose EKU extension is present but contains only Server Authentication (`1.3.6.1.5.5.7.3.1`). Any post-June-2026 publicly-issued TLS certificate has this shape, or create one:

```bash
openssl req -x509 -newkey rsa:2048 -keyout client.key -out client.pem -days 30 -nodes \
-subj "/CN=repro-client" -addext "extendedKeyUsage=serverAuth" -addext "keyUsage=digitalSignature"
```

2. Point an `HttpClient` at any TLS endpoint that requires a client certificate (we hit this against an AWS API Gateway custom domain with mTLS enabled; any mTLS-enforcing server reproduces it):

```csharp
using var ephemeral = X509Certificate2.CreateFromPemFile("client.pem", "client.key");
var cert = X509CertificateLoader.LoadPkcs12(ephemeral.Export(X509ContentType.Pfx), null);

var handler = new HttpClientHandler();
handler.ClientCertificates.Add(cert);

using var client = new HttpClient(handler);
var response = await client.PostAsync("https:///", new StringContent("{}"));
```

3. Observe the request fail with a server-dependent transport error (see Actual behavior). Remove the EKU extension (or add clientAuth) from the certificate and the same code presents the certificate and succeeds.

### Expected behavior

When the developer has **explicitly** populated `ClientCertificates` (clear intent to perform client authentication) and eligibility filtering eliminates **every** provided certificate, there is some observable signal at normal diagnostic levels that the certificates were considered and discarded — for example a Warning-level `NetEventSource` event stating "N certificates provided, 0 eligible" with the per-certificate skip reason (missing clientAuth EKU / missing DigitalSignature KU / no private key).

### Actual behavior

The handshake proceeds with no client certificate and no error, warning, or exception from .NET. The observable failure comes from the *server* and is server-dependent; in our case (AWS API Gateway mTLS, TLS 1.3) it was a connection reset **after the request was sent**:

```
System.Net.Http.HttpRequestException: An error occurred while sending the request.
---> System.IO.IOException: Unable to read data from the transport connection: Connection reset by peer.
---> System.Net.Sockets.SocketException (104): Connection reset by peer
```

To me, this alludes to a network/firewall problem, not an authentication problem. Diagnosis is further complicated because `openssl s_client` and `curl` send the configured certificate regardless of EKU (and many servers, including ours, don't inspect the EKU), so manual verification of the same certificate against the same endpoint **succeeds** — actively pointing the investigation away from the certificate.

### Regression?

No — the eligibility filtering is longstanding behavior and was reaffirmed as by-design in #121982. What has changed is the environment: since the June 2026 CA policy change, routine certificate renewals now flip previously-eligible certificates into ineligible ones, so the silent path is being hit far more often, typically with no code change on the affected application.

### Known Workarounds

`LocalCertificateSelectionCallback` bypasses the eligibility filter and sends whatever it returns (the "do what I say" path confirmed in #121982):

```csharp
var handler = new SocketsHttpHandler();
handler.SslOptions.LocalCertificateSelectionCallback = (_, _, _, _, _) => cert;
```

This resolved our outage. The workaround is fine once you know it exists — the problem is that nothing tells you that you need it.

### Configuration

Observed on .NET 10 on Linux (containerized, where the affected production service runs) and Windows 11 x64 (standalone repro). Not OS- or arch-specific as far as we can tell; the relevant shared code path (`CertificateHelper.GetEligibleClientCertificate`) is present in current `main`.

### Other information

Real-world impact, for calibration: we run a shipping integration platform, and a partner carrier (mTLS on an AWS API Gateway endpoint) published renewed client certificates in June 2026 — same CA, same subject, but `serverAuth`-only where the previous certificate had `serverAuth + clientAuth`. We rotated following our runbook; every existing check passed (key/cert modulus match, expiry, successful calls to the partner's non-mTLS endpoints). The result was a production outage, reported by our customer before we detected it. Once we knew where to look, the fix took minutes; getting to "where to look" cost a full evening of packet-level investigation and side-by-side repros. A single warning would have made it a 30-second log read.

If a diagnostic event is deemed too heavy, a remarks/callout in the `HttpClientHandler.ClientCertificates` and `SslClientAuthenticationOptions.ClientCertificates` docs describing the filtering, the EKU requirement, and the callback escape hatch — ideally referencing the 2026 CA change — would capture most of the value; I'd be glad to contribute that docs PR. And per @bartonjs's note in #121982 that a "do what I say in Manual mode" case could be raised if someone had a concrete scenario: this is that scenario, though a diagnostic alone would have been enough for us.

Related: #121982, #94152.

Contributor guide

Open the contributing guide

Research direction

Start with CertificateHelper.GetEligibleClientCertificate and the HttpClientHandler.ClientCertificates and SslClientAuthenticationOptions.ClientCertificates entry points; reproduce the server-dependent failure with an explicitly configured serverAuth-only certificate. Compare the existing NetEventSource message with the behavior discussed in #121982. Done means the explicit all-ineligible path has a useful diagnostic or documented explanation without changing certificate selection behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
authentication, networking, security
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.