Skip to content

Report the outcome of every physical connection attempt, identifying the client certificate used - #3257

Open
mgravell wants to merge 4 commits into
mainfrom
marc/connection-attempt-outcome
Open

mgravell wants to merge 4 commits into
mainfrom
marc/connection-attempt-outcome

Conversation

@mgravell

@mgravell mgravell commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Proposed resolution for #3252: report the outcome of every physical connection attempt, including how far it got, which client certificate it used, and what happened to the server certificate.

Rather than new Tunnel hooks, this adds an observation-only event on ConfigurationOptions, alongside the existing CertificateSelection / CertificateValidation events. It is subscribed before connecting (so initial attempts are seen as well as reconnects), copied by Clone(), and raised on a worker via CompleteAsWorker, the same as ConnectionFailed, so handlers never run on the connection's own thread. Keeping it off Tunnel avoids every chaining tunnel (e.g. LoggingTunnel) having to remember to forward new members, and the library continues to own TLS.

What is reported

ConfigurationOptions.ConnectionAttemptCompleted (EventHandler<ConnectionAttemptCompletedEventArgs>) is raised exactly once per physical connection attempt: initial connect or reconnect, interactive or subscription.

  • Success: the Redis handshake completed.
  • Failure: the attempt ended before that point. A connection that fails after it was established is a disconnect, not a failed attempt, and does not report.
  • Not suppressed: unlike ConnectionFailed, failed reconnects keep being reported while a server stays unreachable; ConnectionFailed / ConnectionRestored behaviour is unchanged.

ConnectionAttemptCompletedEventArgs:

Member Meaning
EndPoint, ConnectionType which connection this attempt was for
IsSuccess, FailureType, Exception the outcome
Stage how far the attempt got: Connect (100), Tunnel (200), Tls (300), Handshake (400), or Established (500) on success; values are spaced so finer stages can be added later
TlsHostName the host name sent as SNI and used to validate the server certificate; can differ from EndPoint (explicit SslHost, or a host inferred for an address endpoint)
ClientCertificateSubject, ClientCertificateIssuer, ClientCertificateThumbprint (SHA-1), ClientCertificateThumbprintSha256 the client certificate used for this attempt
ServerCertificatePolicyErrors, ServerCertificateChainStatus, ServerCertificateAccepted what the platform found when validating the server certificate (SslPolicyErrors, plus the combined X509ChainStatusFlags behind any chain error), and whether it was accepted
SequenceNumber, CompletedTimeUtc orders outcomes from the same multiplexer; handlers run on worker threads, so arrival order is not reliable

The client certificate itself is not exposed: descriptive values are snapshotted when the outcome is recorded, and the library does not retain the certificate (or its key) beyond that. Each value is read defensively: a certificate disposed while the attempt is in flight throws when read (measured: CryptographicException), and that value is then reported as null. The reporting path as a whole is also guarded, so observing can never affect the connection, including one being established. Correlation is per physical connection, so concurrent interactive/subscription attempts completing out of order cannot be misattributed.

Which client certificate is reported:

  • Handshake completed: the certificate actually sent (SslStream.LocalCertificate). This is null if the server did not ask for one, even though the selection callback still runs in that case.
  • Handshake itself failed: the certificate returned by CertificateSelection.

Server certificate validation is observed by wrapping the validation callback, because AuthenticationException does not carry SslPolicyErrors as data (modern .NET describes them in the message text; netfx does not). With no callback configured, the wrapper applies the platform default (accept only a certificate with no errors). In that case the platform's exception message would otherwise become "rejected by the provided callback", so it is re-described with the errors, and the original is kept as the inner exception. Observing therefore does not cost diagnostic detail.

Measured (in-process TLS server, net10.0)

Scenario Stage FailureType Server certificate
nothing listening Connect UnableToConnect (or similar) n/a
server certificate rejected (platform default) Tls AuthenticationFailure RemoteCertificateChainErrors / UntrustedRoot, accepted: false
client certificate rejected by the server (TLS 1.3) Handshake SocketClosed accepted: true
wrong password Handshake AuthenticationFailure as validated (accepted: true with TLS)
success Established None as validated

The TLS 1.3 row is the one #3252 cares about. The client's handshake completes before the server rejects the certificate, so the failure is observed during the Redis handshake, as SocketClosed. The wrong-password row happens at the same stage; the difference is that the server replies with an error (AuthenticationFailure) rather than hanging up (SocketClosed). A separate "Redis authentication" stage would not help here: with TLS 1.3, the certificate is rejected while HELLO/AUTH is in flight, so both happen at the same point.

Capturing what #3252 asks for

Classifying an outcome for a certificate-health policy (the same classification is in docs/Authentication.md, and pinned by a test):

options.CertificateSelection += (_, _, _, _, _) => certs.Select();
options.ConnectionAttemptCompleted += (_, e) =>
{
    if (e.ClientCertificateThumbprintSha256 is not { } thumbprint) return; // no client certificate involved

    if (e.IsSuccess) certs.RecordSuccess(thumbprint, e.SequenceNumber);
    else if (IsLikelyClientCertificateRejection(e)) certs.RecordSuspectedRejection(thumbprint, e.SequenceNumber);
    // anything else (socket connect, server certificate, a wrong password, ...) says nothing about the client certificate
};

static bool IsLikelyClientCertificateRejection(ConnectionAttemptCompletedEventArgs e)
    => !e.IsSuccess
    && e.ServerCertificateAccepted == true // the server certificate was fine...
    && ((e.Stage == ConnectionAttemptStage.Tls && e.FailureType == ConnectionFailureType.AuthenticationFailure) // TLS 1.2
        || (e.Stage == ConnectionAttemptStage.Handshake && e.FailureType == ConnectionFailureType.SocketClosed)); // TLS 1.3

What certs does with that is the consumer's policy, deliberately not prescribed here. SocketClosed is a weak signal, though: restarts, resets, maxclients and network blips look the same. So the docs recommend that a policy should:

  • require corroboration (e.g. several consecutive suspected rejections), rather than reacting to one;
  • keep retrying the primary certificate periodically, so a fallback is never permanent;
  • check the fallback's expiry, and consider whether falling back to an older, possibly compromised, certificate is acceptable at all;
  • not assume ordering: an outcome can be delivered after the next attempt has already selected its certificate, so use SequenceNumber rather than arrival order.

Not included / limitations

  • Structured "client certificate rejected" signal or TLS alert code: not achievable; SslStream does not expose alerts. The combination above is as close as the platform allows.
  • Redis AUTH as a stage distinct from the rest of the handshake: not needed to separate a wrong password from a certificate rejection (see above), and it would not separate them anyway. The stage values leave room for it if there is another reason.
  • Callbacks supplied by SslClientAuthenticationOptions: SslStream throws if a constructor callback differs from one supplied in the options, so the library only wraps callbacks the options leave to it. If the options supply their own validation callback, the server-certificate members are null. A client certificate supplied via the options is only identified after a completed handshake. A test covers the no-conflict behaviour.
  • Tunnel-supplied transports (ConnectTransportAsync): outcomes are reported, but connect and TLS are the tunnel's, so they report as Connect, with no TLS details.
  • TLS 1.2: if the handshake fails before the server asks for a certificate, the reported client certificate is the one selected, which may not have been sent. (Untested.)
  • TLS session resumption (not verified): a success reported for a newly rotated certificate assumes that certificate was actually presented and checked, rather than an earlier session (established with a different certificate) being resumed. I believe .NET's session cache is keyed on the client certificate, which would rule this out, but that has not been measured, particularly on Linux/OpenSSL.

Tests

  • ConnectionAttemptUnitTests covers:

    • success reported for the interactive and subscription connections;
    • every failed reconnect reported against an unreachable endpoint (Stage: Connect);
    • accepted and rejected client certificates identified correctly;
    • no client certificate reported when the server did not request one;
    • the server certificate rejected by the platform default and by a configured callback (with the exception detail preserved);
    • an SslClientAuthenticationOptions validation callback not displaced;
    • a wrong password reported as Handshake + AuthenticationFailure, with and without TLS, and not classified as a certificate rejection;
    • a disposed client certificate snapshotted without throwing;
    • outcomes carrying distinct, ordered sequence numbers.

    TLS tests run on .NET only.

  • InProcessTestServer gains:

    • an optional ClientCertificateValidator, which makes the server require a client certificate and accept or reject it;
    • a validateServerCertificate switch on GetClientConfig.
  • ConfigTests.ExpectedFields lists the new ConfigurationOptions field.

  • docs/Authentication.md documents the event alongside CertificateSelection, including the classification above, guidance for a fallback policy, and cautions on SHA-1 thumbprints and logging certificate subjects.

  • The full repo builds clean on all TFMs with /p:CI=true; the relevant tests pass on net8.0 and net10.0. net481 builds but was not run.

Checklist

  • I fully and freely contribute this code in accordance with the project license (and am legally able to do so)
  • I take responsibility for this contribution's quality and correctness, including any portions produced with AI assistance (see CONTRIBUTING.md).

…the client certificate used

Adds ConfigurationOptions.ConnectionAttemptCompleted, raised exactly once per physical
connection attempt (initial connect or reconnect, interactive or subscription): success once
the Redis handshake completes, or failure if the attempt ends first. Unlike ConnectionFailed,
failed reconnects are not suppressed while a server stays unreachable.

The args identify the client certificate by subject, issuer and SHA-1/SHA-256 thumbprint,
snapshotted rather than retained: the certificate actually sent after a completed handshake,
or the one the selection callback returned when the handshake itself failed.

See #3252.
…rationOptions field in ExpectedFields

TlsHostName is what the library asked TLS to authenticate (and sent as SNI): the resolved
host, or the caller's TargetHost when SslClientAuthenticationOptions is used; null when the
library did not perform TLS.
…ion attempt

ConnectionAttemptStage (Connect, Tunnel, Tls, Handshake, Established; spaced by 100 to leave room)
says how far an attempt got. ServerCertificatePolicyErrors/ChainStatus/Accepted capture the server
certificate validation, observed via a wrapping callback (platform-default semantics when none is
configured); when that injected callback rejects, the AuthenticationException is re-described so
observing does not cost the platform's error detail.

The TLS block now resolves SslClientAuthenticationOptions before constructing the SslStream, and only
wraps callbacks the options do not supply: SslStream rejects a constructor callback that differs from
one in the options.
… failures correctly

- Guard the certificate snapshot per value, and the whole reporting path: reading a certificate
  disposed mid-attempt throws (measured: CryptographicException), and this runs on the connection's
  own path, including establishing a healthy connection.
- Rename ConnectionAttemptEventArgs to ConnectionAttemptCompletedEventArgs, matching the event.
- Add SequenceNumber and CompletedTimeUtc, since handlers on worker threads can observe outcomes out
  of order.
- Document the classification: a wrong password is Handshake + AuthenticationFailure (the server
  replied), a TLS 1.3 client-certificate rejection is Handshake + SocketClosed (the server hung up);
  plus guidance on corroboration, retrying the primary, fallback expiry and ordering; SHA-1 and PII notes.
- Tests: wrong password (plaintext, and TLS with an accepted certificate), the documented classifier,
  a disposed certificate, and outcome sequencing.
@tlupes

tlupes commented Oct 2, 2026

Copy link
Copy Markdown

Thanks for this Marc, this change covers my need well. I don't have any concerns with this PR, outside of the one potential hashing perf concern I left a comment on. Thanks again!

This branch has not been deployed

No deployments
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