Skip to content

Fix handshake failures being reported as authentication failures - #126

Merged
SeanTAllen merged 1 commit into
mainfrom
issue-118-ssl-auth-fail
Aug 1, 2026
Merged

Fix handshake failures being reported as authentication failures#126
SeanTAllen merged 1 commit into
mainfrom
issue-118-ssl-auth-fail

Conversation

@SeanTAllen

@SeanTAllen SeanTAllen commented Aug 1, 2026

Copy link
Copy Markdown
Member

SSLAuthFail's docstring was one line: "The peer's certificate could not be verified." A session set that state for every SSL-layer handshake failure, bytes that were not a TLS record and two sessions with no protocol version in common included, with the certificate check reporting no problem. Since SSLConnection calls the wrapped protocol's auth_failed for a session in that state, bytes from a peer that spoke no TLS came back to an application as an authentication failure.

A failed handshake now reports SSLAuthFail only when the session was created with verification on and either the peer's chain did not verify or the peer presented no certificate when one was required. The other place SSLAuthFail gets set, for a certificate that is not valid for the hostname, runs on a handshake that succeeded and is untouched. SSL.read already reported SSLError for the same class of failure and is untouched too.

Two authentication failures report SSLError on purpose, and that is the part worth arguing about. A peer presenting a certificate it cannot prove it holds, and a peer whose certificate will not parse, both reported SSLAuthFail on main and report SSLError here: the chain verifies, so the verify result is X509_V_OK, and the SSL reasons are 123 and 524301 rather than 199. Certificates are public, so presenting a copy of the real one is what an impersonator without the key does, which means the attacker picks the quieter failure. I left it narrow anyway. A reason set is not portable. On LibreSSL the client-auth direction of that same failure raises an EVP error rather than an SSL one, so the same failure would land in different states on different backends. "Did the peer present a certificate" does not work either: it would fire for an ALPN failure that happens after the certificate arrives, and OpenSSL does not retain the peer certificate after a failed handshake. Covering it properly needs its own design, most likely a verify callback. Nothing is bypassed: the connection closes either way and no plaintext is delivered. But an application that alerts on auth_failed loses that alert for the case that most deserves it. A test pins the boundary, so moving it has to be deliberate.

For a wrapped protocol this leaves three outcomes distinguishable where there were two. An authentication failure gives auth_failed then closed. Any other handshake failure gives closed with no connected or accepted before it, because SSLConnection swallows the TCP-level events and re-issues them only from _poll's SSLReady arm. A peer disconnecting normally gives closed after one of them. Before this, auth_failed fired for both kinds of failure and said nothing about which. One case stays unreadable and is unchanged here: a peer that rejects the session after it has reached SSLReady arrives as connected then closed.

set_server_verify defaults to false, so a default SSLConnection server never reports SSLAuthFail now and never calls auth_failed. It could before. It sends no certificate request, so it has no peer identity to reject.

607 of the 759 inserted lines are tests. Eleven tests are new: nine drive SSL in memory, one runs a real SSLConnection against a plain TCP server answering a TLS client with HTTP, and one is a unit test on the error-code field extraction. Each part of the rule has a test that fails when it is deleted: the untrusted-chain tests on both sides for the verify result, the no-peer-certificate test for the reason scan, the verification-off test for the verify guard, and the non-TLS-bytes, no-shared-version, peer-rejected-our-certificate and SSLConnection tests for the classification itself. Three things below that are not covered. Four assert a state main already produces, and those are what stops someone narrowing the rule too far.

The verification-off test does not pin the reason the guard exists. _peer_auth_failed returns on not _verify before it reads the verify result, so it passes whether or not OpenSSL recorded one. OpenSSL does record one for a client created with set_client_verify(false). Deleting the guard is what makes that test fail.

Three mutations in _peer_auth_failed no test fails on, and I could not construct one that does: widening the OpenSSL 3.x reason mask to the 1.1.x one, dropping the per-entry library check, and reading only the head instead of scanning the queue. None is reachable through the public API — both masks give the same value for every reason a backend raises, and nothing in the package puts a foreign entry on the queue ahead of a real one on demand. What the new unit test does pin is the extraction the guard rests on: the per-backend shifts, and that _ERRLibrary.of tells libraries apart rather than answering ssl() for everything. The handshake tests catch neither.

The suite passes on the five Linux backend configurations CI builds: OpenSSL 1.1.1w, 3.6.2 and 4.0.0, and LibreSSL 3.9.2 and 4.2.1. The two Windows jobs build against LibreSSL 3.9.1, which I did not run locally.

Closes #118

@ponylang-main ponylang-main added the discuss during sync Should be discussed during an upcoming sync label Aug 1, 2026
`SSLAuthFail` now means a failed handshake on a session created with
verification on, where the peer's chain did not verify or the peer sent no
certificate when one was required. Everything else at the SSL layer is
`SSLError`.

`SSL_get_verify_result` alone would not do. A server built with
`set_server_verify(true)` whose client arrives with no certificate is the
mutual-TLS rejection you turn that flag on to get, and on the verify result
alone that server would report `SSLError`.

The rule is off for a session created with `set_client_verify(false)`.
Turning verification off does not stop OpenSSL verifying a client's peer
chain and recording the result, only from aborting over it. A client handed
a chain it cannot verify carries that failed result for life, and without
the guard every later handshake failure on that session would report
`SSLAuthFail`.

The error queue gets scanned rather than read at the head, because libcrypto
puts an entry there before libssl does — measured at 3 of 3 and 7 of 7.
Reason 199 gets pushed alone, so the head was right by accident. The library
is checked per entry because 199 is
`SSL_R_PEER_DID_NOT_RETURN_A_CERTIFICATE` in libssl and an ASN.1 error in
libcrypto.

Two failures you would call authentication failures report `SSLError` here:
a peer presenting a certificate it cannot prove it holds, and a peer whose
certificate will not parse. Both verify clean, and no reason code for them
means the same thing on every backend, so covering them needs its own
design. The connection closes either way and no plaintext gets delivered,
but an application alerting on `auth_failed` loses that alert for the case
that most deserves it.

Closes #118
@SeanTAllen
SeanTAllen force-pushed the issue-118-ssl-auth-fail branch from 92bd01a to c8aff68 Compare August 1, 2026 20:54
@SeanTAllen
SeanTAllen merged commit 346cd25 into main Aug 1, 2026
13 checks passed
@SeanTAllen
SeanTAllen deleted the issue-118-ssl-auth-fail branch August 1, 2026 21:04
@ponylang-main ponylang-main removed the discuss during sync Should be discussed during an upcoming sync label Aug 1, 2026
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.

SSLAuthFail is set for any handshake protocol error, not just a certificate failure

2 participants