Skip to content

Pass the server's intermediates to Windows certificate verification - #2602

Merged
yhirose merged 2 commits into
masterfrom
windows-verify-intermediates
Oct 3, 2026
Merged

yhirose merged 2 commits into
masterfrom
windows-verify-intermediates

Conversation

@yhirose

@yhirose yhirose commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Refs #2596

This fixes a regression from v0.31.0 (#2346). Up to v0.30.2, the backend verified the chain against the Windows root store, and that was the only check. #2346 added a CryptoAPI check that runs after the backend succeeds, and gave it only the leaf. Since then, servers like the one below fail even when their chain is valid.

Problem

When Windows certificate verification is enabled (the default on Windows), verify_cert_with_windows_schannel() gives CryptoAPI only the leaf certificate. CryptoAPI then follows the leaf's AIA URL to find an issuer instead of using the intermediates the server sent. This happens with every TLS backend.

accounts.spotify.com shows the problem:

  • The server sends Certainly Intermediate R1 cross-signed by Starfield Root Certificate Authority - G2, which Windows trusts.
  • The leaf's AIA URL serves a Certainly Intermediate R1 issued by Certainly Root R1, which Windows does not trust.

So the connection fails with ssl_backend_error=16777312 (0x01000060 = CERT_TRUST_IS_UNTRUSTED_ROOT | CERT_TRUST_REVOCATION_STATUS_UNKNOWN | CERT_TRUST_IS_OFFLINE_REVOCATION), even though the backend has already verified the chain.

Fix

  • Add tls::get_peer_certs() for OpenSSL, Mbed TLS and wolfSSL. It returns the certificates the peer sent, leaf first, and the caller frees each one with free_cert(), as with get_ca_certs().
    • wolfSSL keeps the received chain only when built with SESSION_CERTS. Without it, the function returns nothing and CryptoAPI gets the leaf alone, as before.
  • The CryptoAPI check puts these certificates into a memory store and passes it to CertGetCertificateChain() as hAdditionalStore. This part is shared by all backends.

Verification

  • Windows: On a windows-latest GitHub Actions runner with OpenSSL 3.x and the stock root store, accounts.spotify.com used to fail with 0x01000060. It now succeeds. Self-signed, expired, untrusted-root and hostname-mismatch sites on badssl.com are still rejected as before.
  • macOS: For each backend, get_peer_certs() was checked against a local server that sends a two-certificate chain. Each backend returned both certificates with the leaf first, on the client side and on the server side (client certificate). AddressSanitizer reported no errors.
  • Test: Added SSLClientTest.WindowsCertificateVerification_ServerIntermediates_Online. Windows CI skips *_Online tests, so it does not run there.

This does not change which roots are trusted. A root that Windows has not fetched into its store yet (ssl_backend_error=20 in #2596) is still rejected by every backend.

CryptoAPI got only the leaf, so it fetched an issuer from the leaf's AIA
URL instead of using the intermediates the server sent. For
accounts.spotify.com that issuer chains to Certainly Root R1, which
Windows does not trust, while the server's own chain ends at Starfield
Root G2.

Add tls::get_peer_certs(), which returns the certificates the peer sent
in the same way get_ca_certs() returns the CA certificates, for every
backend. The CryptoAPI check puts them into a memory store that it
passes to CertGetCertificateChain(). wolfSSL keeps the received chain
only when built with SESSION_CERTS; without it, CryptoAPI still gets the
leaf alone.

Refs #2596
@yhirose
yhirose force-pushed the windows-verify-intermediates branch from 086a648 to 75cc18b Compare October 2, 2026 19:29
Also stop the Mbed TLS chain walk at an empty entry, as get_ca_certs()
does, and note that get_peer_certs() results share the session's
lifetime like get_peer_cert()'s.
@yhirose
yhirose merged commit 0db1df7 into master Oct 3, 2026
44 of 45 checks passed
@yhirose
yhirose deleted the windows-verify-intermediates branch October 3, 2026 00:51
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.

1 participant