From 672d8d2d8c27b0e2a5ebcb66d8b4aac6f1a00c49 Mon Sep 17 00:00:00 2001 From: Remco Koopmans Date: Mon, 11 May 2026 11:17:15 +0200 Subject: [PATCH 1/4] chore: add 3.14 to tox.ini --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index f5a6543..3e18507 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,16 @@ [tox] -envlist = clean,py27,py35,py36,py37,py38,py39,py310,py311,py312,mypy,mypy2 +envlist = clean,py27,py35,py36,py37,py38,py39,py310,py311,py312,py313,py314,mypy,mypy2 [testenv] deps = -rrequirements_dev.txt commands = pytest --cov=cert_chain_resolver --cov-append --cov-report=term-missing {posargs} depends = - {py27,py312}: clean + {py27,py314}: clean report: py27,py312 [testenv:mypy] -basepython = python3.12 +basepython = python3.14 deps = mypy -rrequirements_dev.txt From d4cef6e76d62fdcf0130bc089eccaa9ea98223ed Mon Sep 17 00:00:00 2001 From: Remco Koopmans Date: Mon, 11 May 2026 11:21:03 +0200 Subject: [PATCH 2/4] fix: type annotations --- cert_chain_resolver/castore/file_system.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cert_chain_resolver/castore/file_system.py b/cert_chain_resolver/castore/file_system.py index 111c5b1..1d0e56c 100644 --- a/cert_chain_resolver/castore/file_system.py +++ b/cert_chain_resolver/castore/file_system.py @@ -31,12 +31,9 @@ class FileSystemStore(CAStore): """The :class:`SystemStore ` for finding the CA from file system bundle (PEM ONLY).""" - _cache = None # type: dict[str, list[Cert]] - path = None # type: str - def __init__(self, path=None): # (None | str) -> None - self._cache = defaultdict(list) + self._cache = defaultdict(list) # type: dict[str, list[Cert]] if not path: try: path = next(p for p in eligible_paths if p and os.path.exists(p)) From 2933f267dcd354eb5feff1751e0db93060bd6861 Mon Sep 17 00:00:00 2001 From: Remco Koopmans Date: Mon, 11 May 2026 13:18:18 +0200 Subject: [PATCH 3/4] feat: add option to add cross signed roots to chain --- CHANGELOG.md | 7 + README.md | 15 ++ cert_chain_resolver/castore/base_store.py | 1 - cert_chain_resolver/cli.py | 97 +++++++++---- cert_chain_resolver/models.py | 60 +++++++- cert_chain_resolver/resolver.py | 18 ++- cert_chain_resolver/utils.py | 65 +++++++-- docs/api.rst | 21 +++ docs/cli_usage.rst | 15 +- tests/_utils.py | 3 +- tests/certs/ca/sectigo_r46.p7c | Bin 0 -> 4563 bytes tests/test_cli.py | 165 +++++++++++++++++++++- tests/test_models.py | 111 ++++++++++++++- tests/test_resolver.py | 128 +++++++++++++++-- tests/test_utils.py | 54 ++++++- 15 files changed, 681 insertions(+), 79 deletions(-) create mode 100644 tests/certs/ca/sectigo_r46.p7c diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7d173..d767655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +* New: discover cross-signed root variants from the issuing CA's AIA bundle and expose them on the chain. + * CLI: new `--include-cross-signs` flag appends discovered cross-signs to the bundle output. Cross-signs are also surfaced in `--info` regardless of the flag. + * API: `CertificateChain.cross_signs` lists the discovered variants; `Cert.public_key_fingerprint` exposes the SubjectPublicKeyInfo hash used for matching. + * Utils: new `load_ascii_to_x509_all`, `load_der_to_x509_all`, `load_bytes_to_x509_all` return every certificate from a PKCS7 bundle (existing `load_*_to_x509` keeps returning the first cert). + ## 1.4.0 * Extended support to python 3.13 diff --git a/README.md b/README.md index b3fd4d1..fa63fc1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The library provides an easy to use API to access each property of a certificate * PKCS7, PEM and DER formats * LetsEncrypt certificates * Including the root certificate using the system CA bundle or custom bundle +* Discovering and including cross-signed variants of the root from the issuing CA's AIA bundle * Python2 (but not for much longer..) ## Installation @@ -48,6 +49,20 @@ The bundle gets written to stdout and the chain information to stderr. 3. ``` +### Including cross-signed root variants + +Some roots are cross-signed by older CAs so that clients which don't yet trust the modern root can still build a path to a trust anchor they do have. Pass `--include-cross-signs` to append every cross-signed variant discovered in the issuing CA's AIA bundle: + +``` + $ cert_chain_resolver --include-cross-signs certificate.crt > compat-bundle.crt + 1. + 2. + 3. + 4. +``` + +The flag composes with `--include-root`: with both, the self-signed root is emitted first and the cross-signs follow. Cross-signs that share Subject + Subject Public Key Info with a cert already in the chain (but were issued by a different CA) are detected automatically; nothing happens when the AIA bundle contains no extras. + ## Python API Make sure to read the [documentation](https://certificate-resolver.readthedocs.io/en/latest/api.html) for more examples and options. diff --git a/cert_chain_resolver/castore/base_store.py b/cert_chain_resolver/castore/base_store.py index d08723b..891d589 100644 --- a/cert_chain_resolver/castore/base_store.py +++ b/cert_chain_resolver/castore/base_store.py @@ -1,7 +1,6 @@ from cert_chain_resolver.exceptions import RootCertificateNotFound from cert_chain_resolver.models import Cert - try: from typing import TYPE_CHECKING diff --git a/cert_chain_resolver/cli.py b/cert_chain_resolver/cli.py index 5480e60..680ea02 100644 --- a/cert_chain_resolver/cli.py +++ b/cert_chain_resolver/cli.py @@ -13,45 +13,70 @@ pass -def _print_chain_details(chain, include_root): +def _print_cert_details(cert, index, heading="Certificate"): + print("== {0} #{1} ==".format(heading, index)) + print("Subject:".ljust(20) + cert.subject) + print("Issuer:".ljust(20) + cert.issuer) + print("NotBefore:".ljust(20) + cert.not_valid_before.isoformat()) + print("NotAfter:".ljust(20) + cert.not_valid_after.isoformat()) + print("Serial:".ljust(20) + str(cert.serial)) + print("Sha256Fingeprint:".ljust(20) + str(cert.fingerprint)) + print( + "CAIssuerLoc:".ljust(20) + cert.ca_issuer_access_location + if cert.ca_issuer_access_location + else "" + ) + print("Is root:".ljust(20) + repr(cert.is_root)) + print("Is CA:".ljust(20) + repr(cert.is_ca)) + print("Domains:") + print(" Common name:".ljust(20) + cert.common_name) + if cert.subject_alternative_names: + print(" SANExtensions:") + for domain in cert.subject_alternative_names: + print(" " + domain) + print("") + + +def _print_chain_details(chain, include_root, include_cross_signs): for index, cert in enumerate(chain, 1): - print("== Certificate #{0} ==".format(index)) - print("Subject:".ljust(20) + cert.subject) - print("Issuer:".ljust(20) + cert.issuer) - print("NotBefore:".ljust(20) + cert.not_valid_before.isoformat()) - print("NotAfter:".ljust(20) + cert.not_valid_after.isoformat()) - print("Serial:".ljust(20) + str(cert.serial)) - print("Sha256Fingeprint:".ljust(20) + str(cert.fingerprint)) - print( - "CAIssuerLoc:".ljust(20) + cert.ca_issuer_access_location - if cert.ca_issuer_access_location - else "" - ) - print("Is root:".ljust(20) + repr(cert.is_root)) - print("Is CA:".ljust(20) + repr(cert.is_ca)) - print("Domains:") - print(" Common name:".ljust(20) + cert.common_name) - if cert.subject_alternative_names: - print(" SANExtensions:") - for domain in cert.subject_alternative_names: - print(" " + domain) - print("") + _print_cert_details(cert, index) if include_root and not chain.root: sys.stderr.write("WARNING: Root certificate was requested, but not found!\n") + if chain.cross_signs: + for index, cert in enumerate(chain.cross_signs, 1): + _print_cert_details(cert, index, heading="Cross-sign") + if not include_cross_signs: + sys.stderr.write( + "Note: {0} cross-signed variant(s) discovered. " + "Pass --include-cross-signs to add them to the bundle.\n".format( + len(chain.cross_signs) + ) + ) + -def cli(file_bytes, show_details=False, include_root=False, root_ca_store=None): - # type: (bytes, bool, bool, Optional[CAStore]) -> None +def cli( + file_bytes, + show_details=False, + include_root=False, + include_cross_signs=False, + root_ca_store=None, +): + # type: (bytes, bool, bool, bool, Optional[CAStore]) -> None chain = resolve(file_bytes, root_ca_store=root_ca_store) if show_details: - _print_chain_details(chain, include_root=include_root) + _print_chain_details( + chain, include_root=include_root, include_cross_signs=include_cross_signs + ) else: root = [chain.root] if chain.root and include_root else [] - for c in [chain.leaf] + list(chain.intermediates) + root: + cross_signs = list(chain.cross_signs) if include_cross_signs else [] + bundle = [chain.leaf] + list(chain.intermediates) + root + cross_signs + for c in bundle: sys.stdout.write(c.export()) - for i, c in enumerate([chain.leaf] + list(chain.intermediates) + root, 1): + for i, c in enumerate(bundle, 1): sys.stderr.write(str(i) + ". " + repr(c) + "\n") if not root and include_root: @@ -63,6 +88,14 @@ def cli(file_bytes, show_details=False, include_root=False, root_ca_store=None): "Consider running the CLI with --use-certifi-store to find the matching root CA\n" ) + if chain.cross_signs and not include_cross_signs: + sys.stderr.write( + "Note: {0} cross-signed variant(s) discovered. " + "Pass --include-cross-signs to add them to the bundle.\n".format( + len(chain.cross_signs) + ) + ) + def parse_args(): parser = argparse.ArgumentParser( @@ -96,6 +129,15 @@ def parse_args(): action="store_true", help="Include root certificate in the chain if available", ) + parser.add_argument( + "--include-cross-signs", + action="store_true", + help=( + "Append cross-signed variants of the chain (same Subject and public key, " + "different Issuer) to the bundle output. Useful for max compatibility with " + "older clients that don't yet trust the modern root." + ), + ) parser.add_argument( "--ca-bundle-path", type=str, @@ -115,6 +157,7 @@ def main(): "file_bytes": None, "show_details": args.info, "include_root": args.include_root, + "include_cross_signs": args.include_cross_signs, } cli_args["root_ca_store"] = FileSystemStore(args.ca_bundle_path) diff --git a/cert_chain_resolver/models.py b/cert_chain_resolver/models.py index 3ce2589..1efa3af 100644 --- a/cert_chain_resolver/models.py +++ b/cert_chain_resolver/models.py @@ -2,7 +2,7 @@ from cert_chain_resolver.utils import load_ascii_to_x509, load_bytes_to_x509 from cryptography import x509 from cryptography.x509.oid import ExtensionOID, AuthorityInformationAccessOID, NameOID -from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.serialization import Encoding from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from cryptography.hazmat.primitives.asymmetric.ec import ECDSA, EllipticCurvePublicKey @@ -12,7 +12,6 @@ import binascii - try: from typing import List, Union, Optional, Type, Iterator, TYPE_CHECKING @@ -136,7 +135,7 @@ def signature_hash_algorithm(self): def not_valid_before(self): # type: () -> datetime.datetime """Date from the underlying :py:class:`cryptography.x509.Certificate` object. returns the UTC version if cryptography version is 42.0 or higher""" - if hasattr(self._x509, 'not_valid_before_utc'): + if hasattr(self._x509, "not_valid_before_utc"): return self._x509.not_valid_before_utc else: return self._x509.not_valid_before @@ -145,7 +144,7 @@ def not_valid_before(self): def not_valid_after(self): # type: () -> datetime.datetime """Date from the underlying :py:class:`cryptography.x509.Certificate` object. returns the UTC version if cryptography version is 42.0 or higher""" - if hasattr(self._x509, 'not_valid_after_utc'): + if hasattr(self._x509, "not_valid_after_utc"): return self._x509.not_valid_after_utc else: return self._x509.not_valid_after @@ -156,6 +155,22 @@ def fingerprint(self): """ascii encoded sha256 fingerprint by calling :py:func:`get_fingerprint`""" return self.get_fingerprint(hashes.SHA256) + @property + def public_key_fingerprint(self): + # type: () -> str + """SHA-256 hex digest of the certificate's SubjectPublicKeyInfo. + + Two certificates with the same Subject and same ``public_key_fingerprint`` + represent the same identity - this is how cross-signed variants of a CA + certificate are detected, since they share both fields but differ in Issuer. + """ + spki = self._x509.public_key().public_bytes( + Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo + ) + digest = hashes.Hash(hashes.SHA256()) + digest.update(spki) + return binascii.hexlify(digest.finalize()).decode("ascii") + @property def ca_issuer_access_location(self): # type: () -> Union[str, None] @@ -188,6 +203,21 @@ def get_fingerprint(self, _hash=hashes.SHA256): txt = binascii.hexlify(binary).decode("ascii") return txt + def is_cross_sign_of(self, other): + # type: (Cert) -> bool + """Whether this certificate is a cross-signed variant of ``other``. + + Two certificates are cross-signs of each other when they share Subject and + Subject Public Key Info but were issued by different CAs - i.e. they represent + the same identity but provide alternative paths to different trust anchors. + A certificate is never its own cross-sign. + """ + return ( + self != other + and self.subject == other.subject + and self.public_key_fingerprint == other.public_key_fingerprint + ) + def is_issued_by(self, other): # type: (Cert) -> bool """Verify if certificate is issued by the passed CA cert""" @@ -258,6 +288,8 @@ def __init__(self, chain=None): self._fingerprints = ( set() if not chain else {x509_obj.fingerprint for x509_obj in chain} ) + self._cross_signs = [] # type: List[Cert] + self._cross_sign_fingerprints = set() # type: set def __iter__(self): # type: () -> Iterator[Cert] @@ -299,6 +331,26 @@ def root(self): return self._chain[-1] return None + @property + def cross_signs(self): + # type: () -> List[Cert] + """Cross-signed variants discovered alongside the chain. + + A cross-sign is a certificate that shares Subject and public key with a cert + already in the chain but is issued by a different CA. Modern clients ignore + them; older clients can use them to build a path to a different trust anchor. + Returns a shallow copy. + """ + return list(self._cross_signs) + + def add_cross_sign(self, cert): + # type: (Cert) -> None + """Add a cross-signed variant to the chain, deduplicating by fingerprint.""" + if cert.fingerprint in self._cross_sign_fingerprints: + return + self._cross_signs.append(cert) + self._cross_sign_fingerprints.add(cert.fingerprint) + @classmethod def load_from_pem(cls, input_bytes): # type: (bytes) -> CertificateChain diff --git a/cert_chain_resolver/resolver.py b/cert_chain_resolver/resolver.py index 9d99083..f6cfdf2 100644 --- a/cert_chain_resolver/resolver.py +++ b/cert_chain_resolver/resolver.py @@ -1,6 +1,7 @@ from contextlib import closing from cert_chain_resolver.models import CertificateChain, Cert +from cert_chain_resolver.utils import load_bytes_to_x509_all try: from urllib.request import urlopen, Request @@ -51,12 +52,19 @@ def resolve(bytes_cert, _chain=None, root_ca_store=None): _chain += cert - parent_cert = None + parent_bytes = None if cert.ca_issuer_access_location: - parent_cert = _download(cert.ca_issuer_access_location) - - if parent_cert: - return resolve(parent_cert, _chain=_chain, root_ca_store=root_ca_store) + bundle_bytes = _download(cert.ca_issuer_access_location) + parents = load_bytes_to_x509_all(bundle_bytes) + primary = Cert(parents[0]) + for extra_x509 in parents[1:]: + extra = Cert(extra_x509) + if extra.is_cross_sign_of(primary): + _chain.add_cross_sign(extra) + parent_bytes = bundle_bytes + + if parent_bytes: + return resolve(parent_bytes, _chain=_chain, root_ca_store=root_ca_store) elif not _chain.root and root_ca_store: _chain += root_ca_store.find_issuer(cert) diff --git a/cert_chain_resolver/utils.py b/cert_chain_resolver/utils.py index d5fab79..2656f2c 100644 --- a/cert_chain_resolver/utils.py +++ b/cert_chain_resolver/utils.py @@ -3,7 +3,7 @@ from cert_chain_resolver.exceptions import ImproperlyFormattedCert try: - from typing import TYPE_CHECKING + from typing import List, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover from cert_chain_resolver.models import Cert @@ -14,28 +14,65 @@ def load_ascii_to_x509(bytes_input): # type: (bytes) -> x509.Certificate - """Converts ASCII PKCS7 or Certificate to a :py:class:`cryptography.x509.Certificate` object""" + """Converts ASCII PKCS7 or Certificate to a :py:class:`cryptography.x509.Certificate` object. + + Kept for backwards compatibility; returns only the first certificate when the + input contains multiple (e.g. a PKCS7 bundle with cross-signs). Prefer + :py:func:`load_ascii_to_x509_all` when you need every cert in the input. + """ + return load_ascii_to_x509_all(bytes_input)[0] + + +def load_der_to_x509(bytes_input): + # type: (bytes) -> x509.Certificate + """Converts bytes formatted DER (PKCS7 or Cert) to :py:class:`cryptography.x509.Certificate` object. + + Kept for backwards compatibility; returns only the first certificate when the + input contains multiple. Prefer :py:func:`load_der_to_x509_all` when you need + every cert in the input. + """ + return load_der_to_x509_all(bytes_input)[0] + + +def load_bytes_to_x509(bytes_input): + # type: (bytes) -> x509.Certificate + """Converts Certificate / PKCS7 in ASCII or DER to :py:class:`cryptography.x509.Certificate` object. + + Kept for backwards compatibility; returns only the first certificate when the + input contains multiple. Prefer :py:func:`load_bytes_to_x509_all` when you need + every cert in the input. + """ + return load_bytes_to_x509_all(bytes_input)[0] + + +def load_ascii_to_x509_all(bytes_input): + # type: (bytes) -> List[x509.Certificate] + """Converts ASCII PKCS7 or Certificate to a list of :py:class:`cryptography.x509.Certificate` objects. + + A PKCS7 bundle may contain multiple certificates (e.g. a root and its cross-signed + variants); a plain PEM input yields a single-element list. + """ first_line = bytes_input.decode("ascii").splitlines()[0] if first_line == "-----BEGIN PKCS7-----": - return pkcs7.load_pem_pkcs7_certificates(bytes_input)[0] + return list(pkcs7.load_pem_pkcs7_certificates(bytes_input)) elif first_line == "-----BEGIN CERTIFICATE-----": - return x509.load_pem_x509_certificate(bytes_input) + return [x509.load_pem_x509_certificate(bytes_input)] raise ImproperlyFormattedCert("Cert can not be read! It is not a valid PEM") -def load_der_to_x509(bytes_input): - # type: (bytes) -> x509.Certificate - """Converts bytes formatted DER (PKCS7 or Cert) to :py:class:`cryptography.x509.Certificate` object""" +def load_der_to_x509_all(bytes_input): + # type: (bytes) -> List[x509.Certificate] + """Converts bytes formatted DER (PKCS7 or Cert) to a list of :py:class:`cryptography.x509.Certificate` objects.""" try: - return x509.load_der_x509_certificate(bytes_input) + return [x509.load_der_x509_certificate(bytes_input)] except ValueError: - return pkcs7.load_der_pkcs7_certificates(bytes_input)[0] + return list(pkcs7.load_der_pkcs7_certificates(bytes_input)) -def load_bytes_to_x509(bytes_input): - # type: (bytes) -> x509.Certificate - """Converts Certificate / PKCS7 in ASCII or DER to :py:class:`cryptography.x509.Certificate` object""" +def load_bytes_to_x509_all(bytes_input): + # type: (bytes) -> List[x509.Certificate] + """Converts Certificate / PKCS7 in ASCII or DER to a list of :py:class:`cryptography.x509.Certificate` objects.""" try: - return load_ascii_to_x509(bytes_input) + return load_ascii_to_x509_all(bytes_input) except UnicodeDecodeError: - return load_der_to_x509(bytes_input) + return load_der_to_x509_all(bytes_input) diff --git a/docs/api.rst b/docs/api.rst index b11250c..521a4ca 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -72,6 +72,27 @@ Not all CA intermediates provide a web traversable path to the root certificate. print(cert) +Inspecting cross-signed root variants +------------------------------------- + +When the issuing CA's AIA bundle contains more than one certificate sharing the same Subject and public key (a self-signed root plus one or more cross-signed variants by older CAs), those siblings are recorded on the chain as ``cross_signs``. They are kept separate from the main chain so iteration and ``chain.intermediates`` are unaffected. + +.. code-block:: python + + from cert_chain_resolver.api import resolve + + with open('cert.pem', 'rb') as f: + chain = resolve(f.read()) + + for cs in chain.cross_signs: + print("Cross-sign of", cs.subject, "by", cs.issuer) + print(cs.export()) + +For a Sectigo R46-issued certificate this might print two cross-signs — one issued by *USERTrust RSA Certification Authority* and one by *AAA Certificate Services* — both with Subject ``Sectigo Public Server Authentication Root R46``. + +Detection is purely based on what the CA publishes in its AIA bundle; no external lookups are made. Signatures on cross-sign candidates are not verified by the resolver itself. + + Handling Errors =============== diff --git a/docs/cli_usage.rst b/docs/cli_usage.rst index 6ac6c1d..75ebaff 100644 --- a/docs/cli_usage.rst +++ b/docs/cli_usage.rst @@ -38,8 +38,9 @@ Options The `cert_chain_resolver` CLI supports several options to customize its behavior: -- ``-i``, ``--info``: Print detailed information about each certificate in the chain. +- ``-i``, ``--info``: Print detailed information about each certificate in the chain. Cross-signed variants discovered during resolution are always listed in this mode, even when ``--include-cross-signs`` is not set. - ``--include-root``: Include the root certificate in the output if it is available in the chain. +- ``--include-cross-signs``: Append every cross-signed variant of the chain (same Subject and public key, different Issuer) discovered in the issuing CA's AIA bundle. Useful for compatibility bundles served to older clients that don't yet trust the modern root. - ``--ca-bundle-path CA_BUNDLE_PATH``: Use your own CA bundle as the root certificate store for completing the chain. By default this tries to pick your system CA bundle. Each option can be combined to tailor the output to your specific needs. @@ -63,7 +64,17 @@ Examples with Options If the root certificate is available, it will be included in the output file. -3. **Use System CA Store**: +3. **Include Cross-signed Root Variants**: + + .. code-block:: bash + + $ cert_chain_resolver --include-cross-signs certificate.crt > compat_bundle.crt + + When the issuing CA publishes its root with multiple parent signers (e.g. a self-signed root *and* one or more cross-signed variants signed by older CAs), this flag appends those variants to the bundle. Older clients that don't yet trust the modern root will then be able to build a path to a trust anchor they do have. The chain is detected by matching Subject *and* Subject Public Key Info while ignoring Issuer. + + Cross-signs are also reported in ``--info`` output regardless of whether this flag is set, so you can discover their presence before deciding to ship them. + +4. **Use System CA Store**: .. code-block:: bash diff --git a/tests/_utils.py b/tests/_utils.py index 246cd0d..4576a18 100644 --- a/tests/_utils.py +++ b/tests/_utils.py @@ -3,7 +3,8 @@ CRYPTOGRAPHY_MAJOR = int(cryptography.__version__.split(".")[0]) + def make_utc_aware_if_cryptography_above_42(dt): if CRYPTOGRAPHY_MAJOR >= 42: return dt.replace(tzinfo=datetime.timezone.utc) - return dt \ No newline at end of file + return dt diff --git a/tests/certs/ca/sectigo_r46.p7c b/tests/certs/ca/sectigo_r46.p7c new file mode 100644 index 0000000000000000000000000000000000000000..97f9bc6b3d686d0d8de95540038459e265c0cace GIT binary patch literal 4563 zcmeH~XIN8b8^?2!kkBv!B(g^$A|L`MVFhH!CV(;n2o-@41R22)uo18%Aflk)V2Lu6 zp(1rb5sVBGs|ZpM6cr1IGPG=3mPix&VyRcZz1Mra{b0W2+|PZ^lauSZ|Nr0f2RY*H zh;Oze+a=G507z&dN4x>#h;yL;K!AWke{}#}rx@f290NHpMj;FeK%rvM$Cp!oh(kga zFRPRd{8WJjMLypT2n#^KJp>`}8w893M_{0~76fTfl3$=OXg3;_8M2p-a|{U&VbTH# z`k*eq1H)*pb)2IE!a}GxHyR^`#=x0JGlOXn%n+(SGlU+2bEVUnI9FYLP*F;RNC0(+ zL~!-vEhWNR)FBdxI(mkBhTi}C7vMlQuK&}300hQ?q9Gs$ih^>W0OWLCx4vt151?Eq zZ)-swr~X)tU%sE&ZKtt04(lfNlC`W+ty$c)b@4s%HTho6U9_D^tapz4wfx-#rW5j` zGyZjPd$5z71T|bG*Ktekj2-B|$c|Gi6SVI&)sR<`cHDnOFRfxeVq}~Bi+5+B&yFTx zu7p2y(rI7izE%&J?K_@axwB9eLq0R5I)Abx@g3q8RWzZu zUxJcQx-F>E@QCKa$)7v0qi;oai2lCYlhM3Q-qz#%o9-pHe_HE~gbZDu>gU+#-X6GJ zK|`ZjO6910OWBPnkLp;mWic^A!o^m^fo0HR!TxR@!&5?H?qeUvcFR+ zerR=SG=8%{sTU)Uh3OIB^FuQ!KsWeiGI(M>!#|%G_8U^a?Sf;(ugCPqriXGjSVcC) zanBhxR1SpBC8{WUzK}?GN1lvEPaOXspXaVti8uZjhgzA44UJw&RVb7ZC|{h{U|Wok zYVRyvSvKjPY3bXUovl7LBkeI-l6CcNQ%26s{)G!&rM8xF;q6cu00AyqfZIU@-heBJ z!X@EYk0`Ie%!O03*Cn>V16W1dH>BXnpeVnM27nbfoTsg15Y4YK{8|8n@xIni8Bbcg zDFYzd-{u;BMEIti(TaTENhBPvoOJ2tqGkdMkiOlGQNe)+FVu*REbKn4dC9=~irQ?J zfV1Ca@}Yt^x%%oDE8BhNDLCVvyeSy5_T~1XsP~CQ)mEjVxj^1q?)P<=GlyDA6=M?g zO_C6)I-6_aY@{Qw2qO!A(t9`TeFOspSaLoA9*KHyGE77b11X?8!#OS zS=Fm^8^bnr0Os>vuum$%8$ z+Y0W>N8>7}3@QRGs`{jJp9r_gPcbO^QMAq^wbnsk_t>_t8>S=cBPW zZL^j7qEvg^oE42&vT2jLji0ndxsG~mgL)0-=+l&AhUVKIEtifd&H7RMIyEUkD#$@( zf*g4KpKdG;>HPjq<1c#ou96l0WG}zm`(Mlq$WB@3#}qdL1{CMJt{?`rlXd`SPh&*U zSOhF6!CypTg#KK>S%xrK1VvDuzly?aa1W;8C~nrS?yeL!cic`I^8lR@O3(tg^7}B1 z%4&}h9mO+fH*=gNjlm2FTJvqbjnf&t{rBLjUyb`;WZWD82XX*;P>DC( z3d(Ta0VVw8bWnjyf>?y01_BTeK*E5}SI||R zD&XKKP!L2Q;1mTM1a#)Vk`kN@+Je@FmWAfYX2DEmq>;8Zl@X>D9mUffUxZpzdbsvy zN%9o=H$i^ZqZaQWP;c!A0RXNJs)8zOH3%fj{AY7IH7fG&yMJLat6(ys!;kJ8yM^s& zv~*f=n(GfsP#0$l={gkd8J~8|q#_%+mF}Ta-)Q11ZdYpf+3987k7y3#oZ0g9gu(Nz zmptNs%6zH8skm+9=^{LMq1eXK(K)_B+cw~Jc+!4m$ARP?ZIqBV?H*f44T?9ND^Q-S z-fsep*L!r^t1IjR&Oxv(T{K6rv;U&@z=49Jnq&t?dVecr<#N!|@s2IG2c=7!?04mS zbgWG;zuKA-Are~li+G;zXyS9(TauZ2$NP@cMTWm!+Uk5+qi~|*kqc5Z=t;{%y<0y9 z*{W-Jfni%s=Dm##OxWI=s^)CsMsHNl)k`Q+`Ji*~;_ zG&98UsT&{L8e$vREL~S@(i^6M4>*ynbzYG&@e2D0Q=r#7coiwIE3;+#!K>L_d+)uo z`B3*rYbBSWw*Zrv@Hx#M+{UWWzJ;u~uKL@xMZE{mSFXT4->8(s2`Wcy<^nzugB^4o zLRt6Q6)$7cdNXllM;xl|tETSX&L~k!XJ3Eu*~Iq8zMX9adWM0Fe%EAh^r#Z`r(^JF*dB}vH~e<4Kgn#uy|ndUjx%% z+=bCdMV)inX4l8`(F>?AW(JI1$0c4)kOO7-FcB1%uw~HvnY{D=4*v*hFfEFANf28- zD#4M$NH_nmAUY$EAPY+KZ$}A>Thhbnfpnau`R8DPUzIK#CM<1kzMe+3)npMurA7S% ziijj$fcTmrzGjGjQ-1Co54+MHIF{h>*=$8 z!!L{k#sL5bJ$0-m$JoSAw$lqz+pIF9onjcQxa2y^JyvhEV`91_Np7qgpHTOmI5M`X zxbhzH{oVAW$ez|rczd6TcTs75@Dt&Z^is{)2hHPqebMg(WgEb#DkCP^ zWGAJ2r*kw>3T;|D)wVhA__FaxI)$FNoBr%YyoU=ab-)=rk#+*>@>UKE9&S5eaG2Gt KlJ<&1fcyc~0v*u+ literal 0 HcmV?d00001 diff --git a/tests/test_cli.py b/tests/test_cli.py index 9219d1c..8a6c526 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -24,6 +24,7 @@ "file_name": "-", "info": False, "include_root": False, + "include_cross_signs": False, "ca_bundle_path": None, }, ), @@ -33,6 +34,7 @@ "file_name": "test.crt", "info": False, "include_root": False, + "include_cross_signs": False, "ca_bundle_path": None, }, ), @@ -42,6 +44,7 @@ "file_name": "-", "info": True, "include_root": False, + "include_cross_signs": False, "ca_bundle_path": None, }, ), @@ -51,6 +54,17 @@ "file_name": "-", "info": False, "include_root": True, + "include_cross_signs": False, + "ca_bundle_path": None, + }, + ), + ( + ["--include-cross-signs"], + { + "file_name": "-", + "info": False, + "include_root": False, + "include_cross_signs": True, "ca_bundle_path": None, }, ), @@ -60,6 +74,7 @@ "file_name": "-", "info": False, "include_root": False, + "include_cross_signs": False, "ca_bundle_path": "/path/to/ca/bundle", }, ), @@ -68,6 +83,7 @@ "test.crt", "-i", "--include-root", + "--include-cross-signs", "--ca-bundle-path", "/path/to/ca/bundle", ], @@ -75,6 +91,7 @@ "file_name": "test.crt", "info": True, "include_root": True, + "include_cross_signs": True, "ca_bundle_path": "/path/to/ca/bundle", }, ), @@ -88,6 +105,7 @@ def test_parse_args(cli_args, expected, monkeypatch): assert args.file_name == expected["file_name"] assert args.info == expected["info"] assert args.include_root == expected["include_root"] + assert args.include_cross_signs == expected["include_cross_signs"] assert args.ca_bundle_path == expected["ca_bundle_path"] @@ -143,8 +161,7 @@ def test_display_flag_is_properly_formatted(capsys): captured = unicode(capsys.readouterr().out) - expected = unicode( - """== Certificate #1 == + expected = unicode("""== Certificate #1 == Subject: CN=github.com,O=GitHub\\, Inc.,L=San Francisco,ST=California,C=US Issuer: CN=DigiCert SHA2 High Assurance Server CA,OU=www.digicert.com,O=DigiCert Inc,C=US NotBefore: 2020-05-05T00:00:00{tz} @@ -173,8 +190,7 @@ def test_display_flag_is_properly_formatted(capsys): Domains: Common name: DigiCert SHA2 High Assurance Server CA -""" - ).format(tz="+00:00" if CRYPTOGRAPHY_MAJOR > 42 else "") +""").format(tz="+00:00" if CRYPTOGRAPHY_MAJOR > 42 else "") assert expected == captured @@ -192,7 +208,11 @@ def test_display_flag_includes_warning_when_root_was_requested_but_not_found(cap ) def test_main_handles_different_file_input(mocker, file_name, expected_content): args = mocker.Mock( - info=True, include_root=False, ca_bundle_path="/test/path", file_name="test.pem" + info=True, + include_root=False, + include_cross_signs=False, + ca_bundle_path="/test/path", + file_name="test.pem", ) args.file_name = file_name mocker.patch("cert_chain_resolver.cli.parse_args", return_value=args) @@ -223,6 +243,7 @@ def test_main_handles_different_file_input(mocker, file_name, expected_content): file_bytes=expected_content, show_details=True, include_root=False, + include_cross_signs=False, root_ca_store=mocker.ANY, ) @@ -234,3 +255,137 @@ def test_main_no_args_tty_shows_help_and_exits(mocker): with pytest.raises(SystemExit): main() assert sys.argv == ["script_name", "-h"] + + +def _stub_resolve_with_cross_signs(mocker, leaf, intermediate, cross_signs): + """Patch cert_chain_resolver.cli.resolve so it returns a chain populated with cross-signs.""" + from cert_chain_resolver.models import CertificateChain + + chain = CertificateChain() + chain += leaf + chain += intermediate + for c in cross_signs: + chain.add_cross_sign(c) + + mocker.patch("cert_chain_resolver.cli.resolve", return_value=chain) + return chain + + +def _make_mock_cert(mocker, fingerprint, pem, is_ca, is_root, repr_str): + c = mocker.MagicMock( + fingerprint=fingerprint, + is_ca=is_ca, + is_root=is_root, + ) + c.export.return_value = pem + c.__repr__ = lambda _self: repr_str + return c + + +def test_cli_bundle_omits_cross_signs_when_flag_off(capsys, mocker): + leaf = _make_mock_cert( + mocker, + fingerprint="leaf-fp", + pem="LEAF-PEM\n", + is_ca=False, + is_root=False, + repr_str="", + ) + intermediate = _make_mock_cert( + mocker, + fingerprint="int-fp", + pem="INT-PEM\n", + is_ca=True, + is_root=False, + repr_str="", + ) + cross = _make_mock_cert( + mocker, + fingerprint="cross-fp", + pem="CROSS-PEM\n", + is_ca=True, + is_root=False, + repr_str="", + ) + _stub_resolve_with_cross_signs(mocker, leaf, intermediate, [cross]) + + cli(file_bytes=b"ignored") + out, err = capsys.readouterr() + assert "CROSS-PEM" not in out + assert "LEAF-PEM" in out and "INT-PEM" in out + assert "Pass --include-cross-signs" in err + + +def test_cli_bundle_appends_cross_signs_when_flag_on(capsys, mocker): + leaf = _make_mock_cert( + mocker, + fingerprint="leaf-fp", + pem="LEAF-PEM\n", + is_ca=False, + is_root=False, + repr_str="", + ) + intermediate = _make_mock_cert( + mocker, + fingerprint="int-fp", + pem="INT-PEM\n", + is_ca=True, + is_root=False, + repr_str="", + ) + cross_a = _make_mock_cert( + mocker, + fingerprint="cross-a-fp", + pem="CROSS-A\n", + is_ca=True, + is_root=False, + repr_str="", + ) + cross_b = _make_mock_cert( + mocker, + fingerprint="cross-b-fp", + pem="CROSS-B\n", + is_ca=True, + is_root=False, + repr_str="", + ) + _stub_resolve_with_cross_signs(mocker, leaf, intermediate, [cross_a, cross_b]) + + cli(file_bytes=b"ignored", include_cross_signs=True) + out, err = capsys.readouterr() + + # Bundle order: leaf, intermediate, cross_a, cross_b + assert out == "LEAF-PEM\nINT-PEM\nCROSS-A\nCROSS-B\n" + # No hint when flag is on + assert "Pass --include-cross-signs" not in err + + +def test_cli_info_reports_cross_signs_always(capsys, mocker): + """--info shows the Cross-sign section regardless of --include-cross-signs.""" + import datetime + + def _make_info_cert(name, fp, is_ca, is_root): + m = mocker.MagicMock() + m.fingerprint = fp + m.subject = "CN=" + name + m.issuer = "CN=Issuer" + m.common_name = name + m.serial = 1 + m.is_ca = is_ca + m.is_root = is_root + m.subject_alternative_names = [] + m.ca_issuer_access_location = None + m.not_valid_before = datetime.datetime(2024, 1, 1) + m.not_valid_after = datetime.datetime(2025, 1, 1) + return m + + leaf = _make_info_cert("leaf", "leaf-fp", is_ca=False, is_root=False) + intermediate = _make_info_cert("int", "int-fp", is_ca=True, is_root=False) + cross = _make_info_cert("cross", "cross-fp", is_ca=True, is_root=False) + _stub_resolve_with_cross_signs(mocker, leaf, intermediate, [cross]) + + cli(file_bytes=b"ignored", show_details=True) + out, err = capsys.readouterr() + + assert "== Cross-sign #1 ==" in out + assert "Pass --include-cross-signs" in err diff --git a/tests/test_models.py b/tests/test_models.py index 64e9dbe..c56e9bd 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -11,8 +11,6 @@ from cryptography.exceptions import InvalidSignature from ._utils import make_utc_aware_if_cryptography_above_42 - - try: from contextlib import nullcontext as does_not_raise except ImportError: @@ -53,8 +51,14 @@ def test_certcontainer_x509_helper_props(cert): assert fixture["ca"] == c.is_ca assert fixture["serial"] == c.serial assert fixture["signature_algorithm"] == c.signature_hash_algorithm - assert make_utc_aware_if_cryptography_above_42(fixture["not_before"]) == c.not_valid_before - assert make_utc_aware_if_cryptography_above_42(fixture["not_after"]) == c.not_valid_after + assert ( + make_utc_aware_if_cryptography_above_42(fixture["not_before"]) + == c.not_valid_before + ) + assert ( + make_utc_aware_if_cryptography_above_42(fixture["not_after"]) + == c.not_valid_after + ) assert fixture["fingerprint_sha256"] == c.fingerprint assert fixture["ca_issuer_access_location"] == c.ca_issuer_access_location @@ -242,3 +246,102 @@ def test_is_issued_raises_when_no_signature_hash_algo(mock_x509, mock_cert): mock_x509.public_key = lambda: None with pytest.raises(MissingCertProperty): Cert(mock_x509).is_issued_by(mock_cert) + + +def _load_sectigo_p7c_certs(): + from cryptography.hazmat.primitives.serialization import pkcs7 + + with open("tests/certs/ca/sectigo_r46.p7c", "rb") as f: + return list(pkcs7.load_der_pkcs7_certificates(f.read())) + + +@pytest.mark.filterwarnings( + "ignore:PKCS#7 certificates could not be parsed as DER:UserWarning" +) +def test_public_key_fingerprint_matches_across_cross_signs(): + """Self-signed root and its cross-signs share the same Subject Public Key Info.""" + x509_certs = _load_sectigo_p7c_certs() + fps = {Cert(c).public_key_fingerprint for c in x509_certs} + assert len(fps) == 1 + + +def test_public_key_fingerprint_differs_for_unrelated_cert(pem_github): + """Different identities yield different SPKI fingerprints.""" + from cryptography import x509 + + leaf = Cert(x509.load_pem_x509_certificate(pem_github[0]["cert"].encode("ascii"))) + intermediate = Cert( + x509.load_pem_x509_certificate(pem_github[1]["cert"].encode("ascii")) + ) + assert leaf.public_key_fingerprint != intermediate.public_key_fingerprint + + +def test_is_cross_sign_of(): + """Same Subject + same SPKI + distinct cert is a cross-sign; everything else is not.""" + + def make(_subject, _spki, _fp): + class _C(Cert): + subject = _subject + public_key_fingerprint = _spki + fingerprint = _fp + __init__ = lambda self: None + + return _C() + + primary = make("CN=Root", "spki1", "fp1") + cross_sign = make("CN=Root", "spki1", "fp2") + same_subject_different_key = make("CN=Root", "spki2", "fp3") + different_subject = make("CN=Other", "spki1", "fp4") + + assert cross_sign.is_cross_sign_of(primary) is True + assert same_subject_different_key.is_cross_sign_of(primary) is False + assert different_subject.is_cross_sign_of(primary) is False + assert primary.is_cross_sign_of(primary) is False # not its own cross-sign + + +def test_chain_cross_signs_default_empty(mocker): + chain = CertificateChain() + assert chain.cross_signs == [] + + +def test_chain_add_cross_sign_appends_and_dedups(mocker): + chain = CertificateChain() + cross = mocker.MagicMock(fingerprint="fp1") + cross_dup = mocker.MagicMock(fingerprint="fp1") + cross_other = mocker.MagicMock(fingerprint="fp2") + + chain.add_cross_sign(cross) + chain.add_cross_sign(cross_dup) # same fingerprint, ignored + chain.add_cross_sign(cross_other) + + assert chain.cross_signs == [cross, cross_other] + + +def test_chain_cross_signs_dont_pollute_intermediates(mocker): + """Cross-signs look like CA intermediates structurally but must not appear in .intermediates.""" + leaf = mocker.MagicMock(is_ca=False, is_root=False) + intermediate = mocker.MagicMock(is_ca=True, is_root=False) + root = mocker.MagicMock(is_ca=True, is_root=True) + cross = mocker.MagicMock(is_ca=True, is_root=False, fingerprint="cross-fp") + + chain = CertificateChain() + chain += leaf + chain += intermediate + chain += root + chain.add_cross_sign(cross) + + assert list(chain.intermediates) == [intermediate] + assert list(chain) == [leaf, intermediate, root] + assert chain.cross_signs == [cross] + + +def test_chain_cross_signs_returns_copy(mocker): + """Mutating the returned list must not affect the chain's internal state.""" + chain = CertificateChain() + cross = mocker.MagicMock(fingerprint="fp1") + chain.add_cross_sign(cross) + + out = chain.cross_signs + out.append("garbage") + + assert chain.cross_signs == [cross] diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 7dcf1b9..72b5ac0 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -1,26 +1,45 @@ from cert_chain_resolver.resolver import resolve from cert_chain_resolver.models import Cert from cert_chain_resolver.castore.base_store import CAStore +from cryptography.x509 import Certificate import pytest +def _patch_loaders(monkeypatch, mocker, cert_sequence, bundle_sequence=None): + """Helper: stub both Cert.load (recursion) and load_bytes_to_x509_all (cross-sign scan). + + cert_sequence: certs returned by successive Cert.load(...) calls. + bundle_sequence: lists of x509 objects returned by successive load_bytes_to_x509_all + calls. Each list's first element matches the cert at the same recursion step. + Defaults to single-cert bundles (1-element list of Certificate-spec mocks) + which produce no cross-signs. + """ + cert_mock = mocker.MagicMock(side_effect=list(cert_sequence)) + monkeypatch.setattr(Cert, "load", cert_mock) + + if bundle_sequence is None: + bundle_sequence = [[mocker.Mock(spec=Certificate)] for _ in cert_sequence] + monkeypatch.setattr( + "cert_chain_resolver.resolver.load_bytes_to_x509_all", + mocker.Mock(side_effect=bundle_sequence), + ) + + def test_resolve_works_recursively(monkeypatch, mocker): leaf = mocker.Mock() intermediate = mocker.Mock() ca = mocker.Mock() ca.ca_issuer_access_location = None - resolve_mock = mocker.MagicMock() - resolve_mock.side_effect = [leaf, intermediate, ca] - monkeypatch.setattr(Cert, "load", resolve_mock) - + _patch_loaders(monkeypatch, mocker, [leaf, intermediate, ca]) monkeypatch.setattr( "cert_chain_resolver.resolver._download", - mocker.Mock(side_effect=["intermediate", "ca"]), + mocker.Mock(side_effect=[b"intermediate", b"ca"]), ) chain = resolve(b"hoi") assert list(chain) == [leaf, intermediate, ca] + assert chain.cross_signs == [] @pytest.mark.parametrize("root_ca_store", [CAStore(), None]) @@ -31,13 +50,10 @@ def test_resolve_with_castore(monkeypatch, mocker, root_ca_store): intermediate.ca_issuer_access_location = None intermediate.is_root = False - resolve_mock = mocker.MagicMock() - resolve_mock.side_effect = [leaf, intermediate] - monkeypatch.setattr(Cert, "load", resolve_mock) - + _patch_loaders(monkeypatch, mocker, [leaf, intermediate]) monkeypatch.setattr( "cert_chain_resolver.resolver._download", - mocker.Mock(side_effect=["intermediate"]), + mocker.Mock(side_effect=[b"intermediate"]), ) if root_ca_store: ca = mocker.Mock() @@ -53,14 +69,96 @@ def test_resolve_works_avoid_infinite_recursion(monkeypatch, mocker): """Ensure that a certificate with refences to itself can be resolved correctly""" leaf = mocker.Mock() - resolve_mock = mocker.MagicMock() - resolve_mock.side_effect = [leaf, leaf, leaf] - monkeypatch.setattr(Cert, "load", resolve_mock) - + _patch_loaders(monkeypatch, mocker, [leaf, leaf, leaf]) monkeypatch.setattr( "cert_chain_resolver.resolver._download", - mocker.Mock(side_effect=["leaf", "leaf"]), + mocker.Mock(side_effect=[b"leaf", b"leaf"]), ) chain = resolve(b"hoi") assert list(chain) == [leaf] + + +def test_resolve_records_cross_signs_from_aia_bundle(monkeypatch, mocker): + """When the AIA bundle has siblings that pass the cross-sign predicate, they land in chain.cross_signs.""" + leaf = mocker.Mock() + intermediate = mocker.Mock(is_root=False) + intermediate.ca_issuer_access_location = None + + primary_x509 = mocker.Mock(spec=Certificate, name="primary_x509") + cross_a_x509 = mocker.Mock(spec=Certificate, name="cross_a_x509") + cross_b_x509 = mocker.Mock(spec=Certificate, name="cross_b_x509") + + _patch_loaders( + monkeypatch, + mocker, + [leaf, intermediate], + bundle_sequence=[[primary_x509, cross_a_x509, cross_b_x509]], + ) + monkeypatch.setattr( + "cert_chain_resolver.resolver._download", + mocker.Mock(side_effect=[b"bundle_bytes"]), + ) + + # Wrap x509 objects into identifiable Cert mocks; the .is_cross_sign_of side-effect + # tells the resolver which ones to record. + def fake_cert_ctor(x509_obj): + wrapped = mocker.Mock(name="Cert(" + x509_obj._mock_name + ")") + wrapped._x509 = x509_obj + wrapped.is_cross_sign_of = lambda _primary: x509_obj in ( + cross_a_x509, + cross_b_x509, + ) + return wrapped + + monkeypatch.setattr( + "cert_chain_resolver.resolver.Cert", + mocker.Mock(load=Cert.load, side_effect=fake_cert_ctor), + ) + + chain = resolve(b"hoi") + + assert list(chain) == [leaf, intermediate] + assert [c._x509 for c in chain.cross_signs] == [cross_a_x509, cross_b_x509] + + +def test_resolve_dedups_cross_signs_across_hops(monkeypatch, mocker): + """A cross-sign seen on multiple AIA hops is recorded only once.""" + leaf = mocker.Mock() + intermediate = mocker.Mock(is_root=False) + root = mocker.Mock() + root.ca_issuer_access_location = None + + primary1_x509 = mocker.Mock(spec=Certificate, name="primary1") + primary2_x509 = mocker.Mock(spec=Certificate, name="primary2") + cross_x509 = mocker.Mock(spec=Certificate, name="cross") + + _patch_loaders( + monkeypatch, + mocker, + [leaf, intermediate, root], + bundle_sequence=[[primary1_x509, cross_x509], [primary2_x509, cross_x509]], + ) + monkeypatch.setattr( + "cert_chain_resolver.resolver._download", + mocker.Mock(side_effect=[b"b1", b"b2"]), + ) + + wrapped_cross = mocker.Mock(name="Cert(cross)", fingerprint="cross-fp") + wrapped_cross.is_cross_sign_of = lambda _primary: True + + def fake_cert_ctor(x509_obj): + if x509_obj is cross_x509: + return wrapped_cross + wrapped = mocker.Mock(_x509=x509_obj) + wrapped.is_cross_sign_of = lambda _primary: False + return wrapped + + monkeypatch.setattr( + "cert_chain_resolver.resolver.Cert", + mocker.Mock(load=Cert.load, side_effect=fake_cert_ctor), + ) + + chain = resolve(b"hoi") + + assert chain.cross_signs == [wrapped_cross] diff --git a/tests/test_utils.py b/tests/test_utils.py index 00915e1..73992bc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,7 +2,12 @@ from cert_chain_resolver.exceptions import ImproperlyFormattedCert from .fixtures import TEST_CERTS_IN_VARIOUS_FORMATS -from cert_chain_resolver.utils import load_bytes_to_x509 +from cert_chain_resolver.utils import ( + load_ascii_to_x509, + load_bytes_to_x509, + load_bytes_to_x509_all, + load_der_to_x509, +) from cryptography.x509 import Certificate @@ -17,3 +22,50 @@ def test_load_bytes_to_x509(file_type, source_file): def test_load_other_text_raises(): with pytest.raises(ImproperlyFormattedCert): load_bytes_to_x509(b"just text") + + +@pytest.mark.parametrize("file_type,source_file", TEST_CERTS_IN_VARIOUS_FORMATS.items()) +def test_load_bytes_to_x509_all_returns_single_for_plain_cert(file_type, source_file): + """Single-cert inputs (PEM, DER) and 1-cert P7Cs yield a 1-element list.""" + with open(source_file, "rb") as f: + certs = load_bytes_to_x509_all(f.read()) + assert isinstance(certs, list) + assert len(certs) == 1 + assert isinstance(certs[0], Certificate) + + +@pytest.mark.filterwarnings( + "ignore:PKCS#7 certificates could not be parsed as DER:UserWarning" +) +def test_load_bytes_to_x509_all_extracts_all_from_p7c(): + """The Sectigo R46 P7C contains a self-signed root plus two cross-signs.""" + with open("tests/certs/ca/sectigo_r46.p7c", "rb") as f: + certs = load_bytes_to_x509_all(f.read()) + assert len(certs) == 3 + for c in certs: + assert isinstance(c, Certificate) + # All three share the Subject "Sectigo Public Server Authentication Root R46". + subjects = {c.subject.rfc4514_string() for c in certs} + assert len(subjects) == 1 + # But the Issuers are distinct (self-signed + 2 cross-signers). + issuers = {c.issuer.rfc4514_string() for c in certs} + assert len(issuers) == 3 + + +def test_load_bytes_to_x509_all_raises_on_junk(): + with pytest.raises(ImproperlyFormattedCert): + load_bytes_to_x509_all(b"just text") + + +def test_load_ascii_to_x509_returns_first_cert(): + """The single-cert wrapper returns the first cert from a PEM input.""" + with open(TEST_CERTS_IN_VARIOUS_FORMATS["pem"], "rb") as f: + res = load_ascii_to_x509(f.read()) + assert isinstance(res, Certificate) + + +def test_load_der_to_x509_returns_first_cert(): + """The single-cert wrapper returns the first cert from a DER input.""" + with open(TEST_CERTS_IN_VARIOUS_FORMATS["der"], "rb") as f: + res = load_der_to_x509(f.read()) + assert isinstance(res, Certificate) From f649153fe56efc8d75a708a0f214f364fb198f6a Mon Sep 17 00:00:00 2001 From: Remco Koopmans Date: Mon, 11 May 2026 13:19:27 +0200 Subject: [PATCH 4/4] chore: bump to 1.5.0 --- CHANGELOG.md | 2 +- cert_chain_resolver/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d767655..eb25c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 1.5.0 * New: discover cross-signed root variants from the issuing CA's AIA bundle and expose them on the chain. * CLI: new `--include-cross-signs` flag appends discovered cross-signs to the bundle output. Cross-signs are also surfaced in `--info` regardless of the flag. diff --git a/cert_chain_resolver/__init__.py b/cert_chain_resolver/__init__.py index f420b1f..db1b96e 100644 --- a/cert_chain_resolver/__init__.py +++ b/cert_chain_resolver/__init__.py @@ -1,4 +1,4 @@ import sys -__version__ = "1.4.1" +__version__ = "1.5.0" __is_py3__ = sys.version_info >= (3, 0)