Repository navigation
Add CPS CRL Profile lints - #9129
Conversation
91d9ddc to
841eaa9
Compare
|
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): --- /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
left a comment
There was a problem hiding this comment.
Looks good with a nit and a proposal for refactor (which could come as a separate PR).
|
Okay actually two more things based on the + // 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 + // 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). |
|
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 |
…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
left a comment
There was a problem hiding this comment.
Look great, thanks for all of your work getting this across the line. Just a couple of comments.
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 --reqsfailure expected until after we publish CPS v6.3 and this PR is updated to match the new line numbers.