From aae38c946f1b02fd01ed71969a6824ba140b21a7 Mon Sep 17 00:00:00 2001 From: mushtaf saheb Date: Tue, 27 Oct 2026 07:02:08 +0530 Subject: [PATCH] tls: check basicConstraints in CertTree.verify CertTree chained certificates on issuer/subject matching and signature validity alone, so a CA:FALSE certificate issued by a trusted CA could issue a certificate for any name, and verify() accepted it. Reject chains that go through a certificate that is not a CA, and honor pathLenConstraint. AI-Assisted: yes (Claude Fable 5.1) --- scapy/layers/tls/cert.py | 25 +++++++++++- test/scapy/layers/tls/cert.uts | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/scapy/layers/tls/cert.py b/scapy/layers/tls/cert.py index 10d4b6a4681..258647903ce 100644 --- a/scapy/layers/tls/cert.py +++ b/scapy/layers/tls/cert.py @@ -1090,13 +1090,16 @@ def import_from_asn1pkt(self, cert): self.pubkey = PubKey(bytes(tbsCert.subjectPublicKeyInfo)) self.subjectAltName = None + self.cA = False + self.pathLenConstraint = None if tbsCert.extensions: for extn in tbsCert.extensions: if extn.extnID.oidname == "basicConstraints": - self.cA = False if extn.extnValue.cA: self.cA = not (extn.extnValue.cA.val == 0) + if extn.extnValue.pathLenConstraint is not None: + self.pathLenConstraint = extn.extnValue.pathLenConstraint.val elif extn.extnID.oidname == "keyUsage": self.keyUsage = extn.extnValue.get_keyUsage() elif extn.extnID.oidname == "extKeyUsage": @@ -1876,6 +1879,26 @@ def verify(self, cert, hostname=None, now=None, allow_expired=False): c.subject_str, c.notBefore_str, c.notAfter_str ) ) + # Every certificate that issued another one in the chain must be a + # CA, and may bound the number of CAs below it (RFC 5280 sect 6.1.4 + # (k) to (m)). The chain goes from the certificate right below the + # root down to cert, and ends with the root, which is trusted as is + # so that a non-CA certificate can still be pinned. + intermediates = chain[:-2] + for i, c in enumerate(intermediates): + if not c.cA: + raise ValueError( + "Certificate %s is not a CA but issued %s !" % ( + c.subject_str, chain[i + 1].subject_str + ) + ) + if c.pathLenConstraint is not None and \ + len(intermediates) - i - 1 > c.pathLenConstraint: + raise ValueError( + "Certificate %s allows at most %d CA below it !" % ( + c.subject_str, c.pathLenConstraint + ) + ) if hostname is not None and not cert.matchesHostname(hostname): raise ValueError( "Certificate %s was not issued to %s !" % ( diff --git a/test/scapy/layers/tls/cert.uts b/test/scapy/layers/tls/cert.uts index 3585de56b2f..f942489af4c 100644 --- a/test/scapy/layers/tls/cert.uts +++ b/test/scapy/layers/tls/cert.uts @@ -825,6 +825,76 @@ try: except ValueError: pass += CertTree class : verify rejects an issuer that is not a CA + +# srv_cert.pem is CA:FALSE. A certificate for another name signed with its +# key must not chain up to the CA through it. +pki_ca = Cert(scapy_path("/test/scapy/layers/tls/pki/ca_cert.pem")) +pki_ca_key = PrivKey(scapy_path("/test/scapy/layers/tls/pki/ca_key.pem")) +pki_srv = Cert(scapy_path("/test/scapy/layers/tls/pki/srv_cert.pem")) +pki_srv_key = PrivKey(scapy_path("/test/scapy/layers/tls/pki/srv_key.pem")) +# The test PKI expired in September 2026 +in2025 = (2025, 1, 1, 0, 0, 0, 2, 1, 0) + +assert not pki_srv.cA +CertTree([pki_srv], pki_ca).verify(pki_srv, now=in2025) + +forged = Cert(scapy_path("/test/scapy/layers/tls/pki/srv_cert.pem")) +forged.x509Cert.tbsCertificate.issuer = pki_srv.x509Cert.tbsCertificate.subject +forged.x509Cert.tbsCertificate.subject[-1].rdn[0].value = ASN1_PRINTABLE_STRING(b"victim.example") +forged.setSubjectPublicKeyFromPrivateKey(PrivKeyECDSA()) +forged = Cert(pki_srv_key.resignCert(forged)) +assert forged.isIssuer(pki_srv) + +try: + CertTree([forged, pki_srv], pki_ca).verify(forged, hostname="victim.example", now=in2025) + assert False +except ValueError: + pass + += CertTree class : verify checks pathLenConstraint + +# Make a sub CA out of srv_cert.pem, with pathLenConstraint 0 +subca_key = PrivKeyECDSA() +subca = Cert(scapy_path("/test/scapy/layers/tls/pki/srv_cert.pem")) +subca.x509Cert.tbsCertificate.subject[-1].rdn[0].value = ASN1_PRINTABLE_STRING(b"Scapy Test Sub CA") +subca_bc = next(x for x in subca.x509Cert.tbsCertificate.extensions if x.extnID.oidname == "basicConstraints") +subca_bc.extnValue.cA = ASN1_BOOLEAN(0xff) +subca_bc.extnValue.pathLenConstraint = ASN1_INTEGER(0) +subca.setSubjectPublicKeyFromPrivateKey(subca_key) +subca = Cert(pki_ca_key.resignCert(subca)) +assert subca.cA and subca.pathLenConstraint == 0 + +# A CA below it, which issues a certificate +subsubca_key = PrivKeyECDSA() +subsubca = Cert(subca.der) +subsubca.x509Cert.tbsCertificate.issuer = subca.x509Cert.tbsCertificate.subject +subsubca.x509Cert.tbsCertificate.subject[-1].rdn[0].value = ASN1_PRINTABLE_STRING(b"Scapy Test Sub Sub CA") +subsubca.setSubjectPublicKeyFromPrivateKey(subsubca_key) +subsubca = Cert(subca_key.resignCert(subsubca)) + +leaf = Cert(scapy_path("/test/scapy/layers/tls/pki/srv_cert.pem")) +leaf.x509Cert.tbsCertificate.issuer = subsubca.x509Cert.tbsCertificate.subject +leaf.setSubjectPublicKeyFromPrivateKey(PrivKeyECDSA()) +leaf = Cert(subsubca_key.resignCert(leaf)) + +# The sub sub CA is the only CA under the sub CA: fine +CertTree([subca], pki_ca).verify(subsubca, now=in2025) + +# The leaf has two CAs between it and the root: too many for the sub CA +try: + CertTree([subca, subsubca], pki_ca).verify(leaf, now=in2025) + assert False +except ValueError: + pass + +# With pathLenConstraint 1 the same chain is accepted +subca_bc = next(x for x in subca.x509Cert.tbsCertificate.extensions if x.extnID.oidname == "basicConstraints") +subca_bc.extnValue.pathLenConstraint = ASN1_INTEGER(1) +subca = Cert(pki_ca_key.resignCert(subca)) +assert subca.pathLenConstraint == 1 +CertTree([subca, subsubca], pki_ca).verify(leaf, now=in2025) + = Test GeneralizedTime data = b"MHAwXAIBADANBgkqhkiG9w0BAQ0FADAAMCIYDzIwMTExMDA2MDgzOTU2WhgPMjA0NjEwMDYwODM5NTZaMAAwHDANBgkqhkiG9w0BAQEFAAMLADAIAgEAAgMBAAGjAjAAMA0GCSqGSIb3DQEBDQUAAwEA"