diff --git a/scripts/check_ca_trust.py b/scripts/check_ca_trust.py index c675dc6f3..36457fe17 100644 --- a/scripts/check_ca_trust.py +++ b/scripts/check_ca_trust.py @@ -30,9 +30,12 @@ def check_bundle(path: Path) -> None: """Raise on an unreadable, empty, malformed, or affected CA bundle.""" context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) context.load_verify_locations(cafile=str(path)) + # A hashed OpenSSL directory can include valid non-CA certificates (for + # example Ubuntu's snakeoil server certificate). get_ca_certs() omits them; + # use the total X.509 count to distinguish these from an empty store. + if not context.cert_store_stats()["x509"]: + raise ValueError("no certificates") certificates = context.get_ca_certs(binary_form=True) - if not certificates: - raise ValueError("no CA certificates") blocked = { BLOCKED_ROOTS[fingerprint] for certificate in certificates diff --git a/tests/fixtures/ca-trust/README.md b/tests/fixtures/ca-trust/README.md index 47e440ac1..98b57d82d 100644 --- a/tests/fixtures/ca-trust/README.md +++ b/tests/fixtures/ca-trust/README.md @@ -1,5 +1,5 @@ # CA trust regression fixtures -The public CA certificates embedded in `tests/test_ca_trust.py` exercise the October 2026 eMudhra trust-store check. They contain no private keys. `emsign-g3.pem` and `aaa.pem` are the incident's affected trust anchors; `emsign-g1.pem` is an unaffected emSign anchor that must remain usable. The test creates temporary PEM files from these public constants. The certificate fingerprints are the factual identity of each fixture. +The public certificates embedded in `tests/test_ca_trust.py` exercise the October 2026 eMudhra trust-store check. They contain no private keys. `emsign-g3.pem` and `aaa.pem` are the incident's affected trust anchors; `emsign-g1.pem` is an unaffected emSign anchor that must remain usable. `localhost-leaf.pem` is a synthetic self-signed Ed25519 server certificate with `CA:FALSE`, generated solely to reproduce hashed non-CA certificates found in Ubuntu's OpenSSL directory; its private key was discarded. The test creates temporary PEM files from these public constants. The certificate fingerprints are the factual identity of each fixture. Sources: the [incident root list](https://emudhra.we-have-a-problem.com/roots.json) and the public Mozilla root certificates distributed in Debian's `ca-certificates` package. Behavioral chain verification should also cover the incident site's separate [twelve published server chains](https://emudhra.we-have-a-problem.com/chains.zip). diff --git a/tests/test_ca_trust.py b/tests/test_ca_trust.py index b8e7b1bb0..88a5a343f 100644 --- a/tests/test_ca_trust.py +++ b/tests/test_ca_trust.py @@ -75,6 +75,20 @@ class CATrustTests(unittest.TestCase): self.assertEqual(result, 1) self.assertIn("emSign ECC Root CA - G3", errors.getvalue()) + def test_hashed_non_ca_certificate_does_not_reject_clean_store(self): + # Ubuntu's OpenSSL directory can also hash ssl-cert-snakeoil.pem. + # A valid server certificate has no CA entries in get_ca_certs(). + with tempfile.TemporaryDirectory() as tmp: + directory = Path(tmp) + (directory / "ce275665.0").symlink_to(self.fixtures / "localhost-leaf.pem") + defaults = ssl.get_default_verify_paths()._replace( + cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory) + ) + with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict( + os.environ, {defaults.openssl_capath_env: str(directory)} + ), contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(checker.main(["--system"]), 0) + def test_multiple_ca_directories_are_all_checked(self): with tempfile.TemporaryDirectory() as tmp: clean, stale = Path(tmp) / "clean", Path(tmp) / "stale" @@ -96,6 +110,16 @@ class CATrustTests(unittest.TestCase): # Public Mozilla CA certificates; provenance: tests/fixtures/ca-trust/README.md. PUBLIC_CERTIFICATES = { + 'localhost-leaf.pem': """-----BEGIN CERTIFICATE----- +MIIBOTCB7KADAgECAhRcVxnOQNEFWf92K4dm4I8ChyjVMzAFBgMrZXAwFDESMBAG +A1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyMTE4NTgwN1oXDTM2MTAxODE4NTgwN1ow +FDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEARglphvNgp9qRCESLHnuu +W8VC5urdZl+YgS+Ux1BoLryjUDBOMB0GA1UdDgQWBBSaEckyR/76lX3a5Ijh8B42 +oJwN1DAfBgNVHSMEGDAWgBSaEckyR/76lX3a5Ijh8B42oJwN1DAMBgNVHRMBAf8E +AjAAMAUGAytlcANBAJkq9ayy+d4AUZnjhWM31rFke5DT+l9QFcPl34+dpVhqJV/L +m4IpPHYagqrBRHn9Dv/C8qYyPBKnAwJRpNptsgw= +-----END CERTIFICATE----- +""", 'aaa.pem': """-----BEGIN CERTIFICATE----- MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow