Skip to content

Add CPS CRL Profile lints - #9129

Merged
ezekiel merged 7 commits into
mainfrom
crl-profile-lints
Oct 29, 2026
Merged

ezekiel merged 7 commits into
mainfrom
crl-profile-lints

Conversation

@aarongable

@aarongable aarongable commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Similar to #8485, add lints which perform line-by-line correspondence checks against the new CRL Profiles in v6.3 of our CP/CPS.

This is part of our remediation for https://bugzilla.mozilla.org/show_bug.cgi?id=2074944

Code written by me, tests written by LLM.

Note

./test.sh --reqs failure expected until after we publish CPS v6.3 and this PR is updated to match the new line numbers.

@aarongable
aarongable marked this pull request as ready for review October 28, 2026 17:05
@aarongable
aarongable requested a review from a team as a code owner October 28, 2026 17:05
@aarongable
aarongable requested a review from jsha October 28, 2026 17:06
@jsha

jsha commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

For the benefit of other reviewers: since the two CRL profile lints are near-identical, it's useful to diff them. Here's how (result looks correct to me):

diff -u <(sed /CP-CPS.md/d lint_ca_crl.go)  <(sed /CP-CPS.md/d lint_subscriber_crl.go)
--- /dev/fd/63	2026-10-28 12:53:49
+++ /dev/fd/62	2026-10-28 12:53:49
@@ -5,7 +5,9 @@
 	"encoding/hex"
 	"fmt"
 	"math/big"
+	"net/url"
 
+	"github.com/weppos/publicsuffix-go/publicsuffix"
 	"github.com/zmap/zcrypto/x509"
 	"github.com/zmap/zlint/v3/lint"
 	"github.com/zmap/zlint/v3/util"
@@ -15,7 +17,7 @@
 	"github.com/letsencrypt/boulder/linter/lints"
 )
 
-type caCRLMatchesCPSProfile struct {
+type subscriberCRLMatchesCPSProfile struct {
 	// Config is filled from the shared [Global] stanza of the lint
 	// configuration; see SharedConfig.
 	Config *SharedConfig
@@ -24,35 +26,35 @@
 func init() {
 	lint.RegisterRevocationListLint(&lint.RevocationListLint{
 		LintMetadata: lint.LintMetadata{
-			Name:          "e_ca_crl_matches_cps_profile",
-			Description:   "Let's Encrypt CA CRLs are issued in accordance with the CP/CPS Profile",
+			Name:          "e_subscriber_crl_matches_cps_profile",
+			Description:   "Let's Encrypt Subscriber CRLs are issued in accordance with the CP/CPS Profile",
 			Citation:      "CPS: 7.2",
 			Source:        lints.LetsEncryptCPS,
 			EffectiveDate: lints.CPSV63Date,
 		},
-		Lint: NewCACRLMatchesCPSProfile,
+		Lint: NewSubscriberCRLMatchesCPSProfile,
 	})
 }
 
-func NewCACRLMatchesCPSProfile() lint.RevocationListLintInterface {
-	return &caCRLMatchesCPSProfile{}
+func NewSubscriberCRLMatchesCPSProfile() lint.RevocationListLintInterface {
+	return &subscriberCRLMatchesCPSProfile{}
 }
 
 // Configure implements the lint.Configurable interface.
-func (l *caCRLMatchesCPSProfile) Configure() any {
+func (l *subscriberCRLMatchesCPSProfile) Configure() any {
 	return l
 }
 
-func (l *caCRLMatchesCPSProfile) CheckApplies(c *x509.RevocationList) bool {
+func (l *subscriberCRLMatchesCPSProfile) CheckApplies(c *x509.RevocationList) bool {
 	// This condition must exactly match the condition in
 	// crlMatchesExactlyOneCPSProfile.Execute().
-	_, onlyContainsCACerts := getCRLScope(c)
-	return onlyContainsCACerts
+	onlyContainsUserCerts, _ := getCRLScope(c)
+	return onlyContainsUserCerts
 }
 
-// Execute checks the given CRL against the CA CRL Profile.
-// ### CA CRL Profile
-func (l *caCRLMatchesCPSProfile) Execute(c *x509.RevocationList) *lint.LintResult {
+// Execute checks the given CRL against the Subscriber CRL Profile, row by row.
+// ### Subscriber CRL Profile
+func (l *subscriberCRLMatchesCPSProfile) Execute(c *x509.RevocationList) *lint.LintResult {
 	// Several rows of the profile require byte-for-byte correspondence with
 	// the Issuing CA's certificate, so this lint must be configured with it.
 	issuer, err := parseConfiguredCertificate(l.Config.issuerPEM())
@@ -64,6 +66,9 @@
 	}
 
 	// |     `version`                          | X.509 v2 (integer value 1) |
+	// zcrypto doesn't expose a CRL's version, so we read it ourselves. RFC 5280
+	// Section 5.1.2.1 makes the field optional, its absence indicating a v1
+	// CRL, so an absent version is left as 0.
 	tbs := cryptobyte.String(c.RawTBSRevocationList)
 	var tbsCertList cryptobyte.String
 	if !tbs.ReadASN1(&tbsCertList, cryptobyte_asn1.SEQUENCE) {
@@ -80,7 +85,8 @@
 	// |     `signature`                        | See [Section 7.1.3.2](#7132-signature-algorithmidentifier) |
 	// Section 7.1.3.2 requires signature AlgorithmIdentifiers to be
 	// byte-for-byte identical with one of the hexadecimal encodings specified
-	// by Section 7.1.3.2 of the Baseline Requirements.
+	// by Section 7.1.3.2 of the Baseline Requirements. The signature field
+	// immediately follows the version field read above.
 	var tbsSignature cryptobyte.String
 	if !tbsCertList.ReadASN1Element(&tbsSignature, cryptobyte_asn1.SEQUENCE) {
 		return errResult("failed to parse tbsCertList.signature")
@@ -98,17 +104,17 @@
 	// Nothing to check here, since zcrypto refuses to parse a CRL without a
 	// thisUpdate, and our CPS places no restrictions on the field.
 
-	// |     `nextUpdate`                       | At most 365 days after the `thisUpdate` |
+	// |     `nextUpdate`                       | At most 10 days after the `thisUpdate` |
 	if c.NextUpdate.IsZero() {
 		return errResult("nextUpdate is not present")
 	}
 	if c.NextUpdate.Before(c.ThisUpdate) {
 		return errResult("nextUpdate is before thisUpdate")
 	}
-	if c.NextUpdate.Sub(c.ThisUpdate) > 365*lints.BRDay {
+	if c.NextUpdate.Sub(c.ThisUpdate) > 10*lints.BRDay {
 		// Note that we don't need the off-by-one-second math here, because CRL
 		// validity intervals are not measured the same way as cert validity periods.
-		return errResult("nextUpdate is more than 365 days after thisUpdate")
+		return errResult("nextUpdate is more than 10 days after thisUpdate")
 	}
 
 	// |     `revokedCertificates`              | Present if and only if there is at least one revoked certificate entry |
@@ -132,21 +138,27 @@
 			return errResult(fmt.Sprintf("entry %x: revocationDate is later than thisUpdate", rc.SerialNumber))
 		}
 
-		// |              `reasonCode`              | Contains the most appropriate revocation reason code from among 1, 4, 5, or 9 |
+		// |              `reasonCode`              | Contains the most appropriate revocation reason code from among 1, 4, 5, or 9, or not present if the revocation reason is unspecified (0) |
 		reasonCodeExt := lints.GetExtWithOID(rc.Extensions, util.ReasonCodeOID)
-		if reasonCodeExt == nil || rc.ReasonCode == nil {
-			return errResult(fmt.Sprintf("entry %x: reasonCode extension is not present", rc.SerialNumber))
+		if reasonCodeExt != nil {
+			if reasonCodeExt.Critical {
+				return errResult(fmt.Sprintf("entry %x: reasonCode extension is critical", rc.SerialNumber))
+			}
+			if rc.ReasonCode == nil {
+				return errResult(fmt.Sprintf("entry %x: failed to parse reasonCode", rc.SerialNumber))
+			}
+			switch *rc.ReasonCode {
+			case 0:
+				return errResult(fmt.Sprintf("entry %x: reasonCode extension is present with value unspecified (0)", rc.SerialNumber))
+			case 1, 4, 5, 9:
+			default:
+				return errResult(fmt.Sprintf("entry %x: reasonCode %d is not one of 1, 4, 5, or 9", rc.SerialNumber, *rc.ReasonCode))
+			}
 		}
-		if reasonCodeExt.Critical {
-			return errResult(fmt.Sprintf("entry %x: reasonCode extension is critical", rc.SerialNumber))
-		}
-		switch *rc.ReasonCode {
-		case 1, 4, 5, 9:
-		default:
-			return errResult(fmt.Sprintf("entry %x: reasonCode %d is not one of 1, 4, 5, or 9", rc.SerialNumber, *rc.ReasonCode))
-		}
 
 		// |              Any other extension       | Not present |
+		// Unlike in the CA CRL Profile, the reasonCode extension is optional
+		// here, so its absence is not an error.
 		entryExtensions := map[string]bool{
 			util.ReasonCodeOID.String(): false,
 		}
@@ -160,11 +172,6 @@
 			}
 			entryExtensions[ext.Id.String()] = true
 		}
-		for oid, seen := range entryExtensions {
-			if !seen {
-				return errResult(fmt.Sprintf("entry %x: missing extension %s", rc.SerialNumber, oid))
-			}
-		}
 	}
 
 	// |         `authorityKeyIdentifier`       | Contains a `keyIdentifier` byte-for-byte identical to the `subjectKeyIdentifier` of the Issuing CA |
@@ -206,7 +213,7 @@
 		return errResult("CRLNumber is not less than 2^159")
 	}
 
-	// |         `issuingDistributionPoint`     | Critical, with only `onlyContainsCACerts` set to true |
+	// |         `issuingDistributionPoint`     | Critical, with only `onlyContainsUserCerts` set to true, and contains an HTTP URI byte-for-byte identical to that contained in the `crlDistributionPoints` extension of each Certificate in the CRL's scope |
 	idpExt := lints.GetExtWithOID(c.Extensions, issuingDistributionPointOID)
 	if idpExt == nil {
 		return errResult("issuingDistributionPoint extension is not present")
@@ -219,18 +226,32 @@
 	if !idpValue.ReadASN1(&idp, cryptobyte_asn1.SEQUENCE) || !idpValue.Empty() {
 		return errResult("failed to parse issuingDistributionPoint")
 	}
-	if idp.PeekASN1Tag(cryptobyte_asn1.Tag(0).ContextSpecific().Constructed()) {
-		return errResult("issuingDistributionPoint distributionPoint is present")
+	var distributionPoint cryptobyte.String
+	if !idp.ReadASN1(&distributionPoint, cryptobyte_asn1.Tag(0).ContextSpecific().Constructed()) {
+		return errResult("issuingDistributionPoint does not contain a distributionPoint")
 	}
-	if idp.PeekASN1Tag(cryptobyte_asn1.Tag(1).ContextSpecific()) {
-		return errResult("issuingDistributionPoint onlyContainsUserCerts is present")
+	var fullName cryptobyte.String
+	if !distributionPoint.ReadASN1(&fullName, cryptobyte_asn1.Tag(0).ContextSpecific().Constructed()) || !distributionPoint.Empty() {
+		return errResult("issuingDistributionPoint distributionPoint does not contain a fullName")
 	}
-	var onlyContainsCACerts cryptobyte.String
-	if !idp.ReadASN1(&onlyContainsCACerts, cryptobyte_asn1.Tag(2).ContextSpecific()) || !bytes.Equal(onlyContainsCACerts, []byte{0xFF}) {
-		return errResult("issuingDistributionPoint onlyContainsCACerts is not set to true")
+	var uri cryptobyte.String
+	if !fullName.ReadASN1(&uri, cryptobyte_asn1.Tag(6).ContextSpecific()) || !fullName.Empty() {
+		return errResult("issuingDistributionPoint distributionPoint does not contain exactly one URI")
 	}
+	idpURL, err := url.Parse(string(uri))
+	if err != nil || idpURL.Scheme != "http" {
+		return errResult("issuingDistributionPoint URI is not an http URL")
+	}
+	_, err = publicsuffix.ParseFromListWithOptions(publicsuffix.DefaultList, idpURL.Hostname(), &publicsuffix.FindOptions{IgnorePrivate: true, DefaultRule: nil})
+	if err != nil {
+		return errResult("issuingDistributionPoint URI hostname is not a domain under a public suffix")
+	}
+	var onlyContainsUserCerts cryptobyte.String
+	if !idp.ReadASN1(&onlyContainsUserCerts, cryptobyte_asn1.Tag(1).ContextSpecific()) || !bytes.Equal(onlyContainsUserCerts, []byte{0xFF}) {
+		return errResult("issuingDistributionPoint onlyContainsUserCerts is not set to true")
+	}
 	if !idp.Empty() {
-		return errResult("issuingDistributionPoint contains fields other than onlyContainsCACerts")
+		return errResult("issuingDistributionPoint contains fields other than distributionPoint and onlyContainsUserCerts")
 	}
 
 	// |         Any other extension            | Not present |

jsha
jsha previously approved these changes Oct 28, 2026

@jsha jsha left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good with a nit and a proposal for refactor (which could come as a separate PR).

Comment thread linter/lints/cpcps/lint_ca_crl.go Resolved
@jsha

jsha commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Okay actually two more things based on the diff command above:

+	// zcrypto doesn't expose a CRL's version, so we read it ourselves. RFC 5280
+	// Section 5.1.2.1 makes the field optional, its absence indicating a v1
+	// CRL, so an absent version is left as 0.
 	tbs := cryptobyte.String(c.RawTBSRevocationList)
 	var tbsCertList cryptobyte.String
 	if !tbs.ReadASN1(&tbsCertList, cryptobyte_asn1.SEQUENCE) {
@@ -80,7 +85,8 @@
 	// |     `signature`                        | See [Section 7.1.3.2](#7132-signature-algorithmidentifier) |
 	// Section 7.1.3.2 requires signature AlgorithmIdentifiers to be
 	// byte-for-byte identical with one of the hexadecimal encodings specified
-	// by Section 7.1.3.2 of the Baseline Requirements.
+	// by Section 7.1.3.2 of the Baseline Requirements. The signature field
+	// immediately follows the version field read above.

These comments are different for no particular reason; might as well harmonize to keep future inter-file diffs clean.

+		// Unlike in the CA CRL Profile, the reasonCode extension is optional
+		// here, so its absence is not an error.
 		entryExtensions := map[string]bool{
 			util.ReasonCodeOID.String(): false,
 		}
@@ -160,11 +172,6 @@
 			}
 			entryExtensions[ext.Id.String()] = true
 		}
-		for oid, seen := range entryExtensions {
-			if !seen {
-				return errResult(fmt.Sprintf("entry %x: missing extension %s", rc.SerialNumber, oid))
-			}
-		}
 	}

This seems like a potential future rake: what if we add a new required extension for subscriber CRLs, but forget to re-add a check that everything was seen?

Also this gets in the way of the refactor I proposed. How about: leave this for now, but as a possible followup factor out a helper that processes a map of extension OID -> validation, where the validation gets run even if the extension is absent, and can error in that condition (for required extensions) or pass in that condition (for optional extensions).

@aarongable

Copy link
Copy Markdown
Contributor Author

Responded to comments in the latest patch. Thanks for spotting that I'd forgotten to sync all of my comment simplifications between the two files.

I think that we'll be able to do a much cleaner job of having an extension-enforcement helper after extension presence is much more explicit in our CPS: letsencrypt/cp-cps#361

@aarongable
aarongable requested review from a team and jsha October 28, 2026 21:14
jsha
jsha previously approved these changes Oct 28, 2026
@aarongable
aarongable requested review from a team and ezekiel and removed request for a team October 29, 2026 04:59
ezekiel
ezekiel previously approved these changes Oct 29, 2026
@aarongable
aarongable dismissed stale reviews from ezekiel and jsha via 94865d8 October 29, 2026 19:40
ezekiel
ezekiel previously approved these changes Oct 29, 2026
ezekiel pushed a commit that referenced this pull request Oct 29, 2026
…9136)

Add two new checks to our CRL ceremony tool:
- that the revocation date of each revoked certificate is before the
CRL's thisUpdate; and
- that each revoked certificate has an acceptable revocation reason
code.

These checks ensure that our ceremony-produced CRLs comply with the
profiles in Section 7.2 of v6.3 of our CP/CPS
(https://letsencrypt.org/documents/isrg-cp-cps-v6.3/#7.2-crl-profile).
They are belt-and-suspenders checks; they are also checked by the lints
in #9129.

Also, update both the crlCeremony and generateCRL functions to build
their respective RevocationListEntry and RevocationList objects once all
values are prepared, rather than constructing them halfway and then
assigning to additional fields afterwards.

@beautifulentropy beautifulentropy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look great, thanks for all of your work getting this across the line. Just a couple of comments.

Comment thread linter/lints/common.go Resolved
Comment thread crl/checker/checker.go Resolved
@ezekiel
ezekiel merged commit 4be60f1 into main Oct 29, 2026
19 checks passed
@ezekiel
ezekiel deleted the crl-profile-lints branch October 29, 2026 22:02
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.

4 participants