Repository: letsencrypt/boulder
Pull request #8103: ca: Create "OmitOCSP" profile config option
Author: login: jsha
Created: 2025-04-04T22:07:07Z | Merged: 2025-04-09T18:46:58Z

Add a new config field for profiles which causes the profile to omit the AIA OCSP URI. It can only be omitted if the CRLDP extension is configured to be included instead. Enable this flag in config-next.

When a certificate is revoked, if it does not have an AIA OCSP URI, don't bother with an Akamai OCSP purge.

Builds on #8089

Most of the changes in this PR relate to tests. Different from #8089, I chose to keep testing of OCSP in the config-next world. This is because we intend to keep operating OCSP even after we have stopped including it in new certificates. So we should test it in as many environments as possible.

Adds a WithURLFallback option to ocsp_helper. When `ocsp_helper.ReqDer()` is called for a certificate with no OCSP URI, it will query the fallback URL instead. As before, if the certificate has an OCSP URI ocsp_helper will use that. Use that for all places in the integration tests that call ocsp_helper. Note for reviewers: This PR is also acting as a forcing function to _add_ equivalent CRL-based checks to everything that currently uses OCSP. You should be checking that each place that has calls `ocspConf()` (i.e. sets a fallback OCSP URL) has a corresponding runUpdater / fetchAndCheckRevoked or checkUnrevoked call corresponding. A couple of places already call runUpdater and check the CRLs for revocation:

 - TestRevocation has an existing check on revocation_test.go lines 419-425.
 - TestReRevocation has an existing check on revocation_test.go line 519.
 
And two places don't need a call to runUpdater / fetchAndCheckRevoked: In both TestRevocation and TestReRevocation, we have calls starting with the comment "Initially, ...". These verify that OCSP has a "good" status right after issuance. There's no corresponding behavior for CRLs: issuing a certificate does not change the CRLs because unrevoked certificates are simply not listed. These "Initially, ..." calls will simply go away once we fully deprecate OCSP service.

Fixes #8059

Linked issue #8059: Remove AIA OCSP URL from end-entity certificates
Likely in the form of a new profile field `omitAIAOCSP`, and some logic within the issuance package that errors out if the profile is going to omit both the OCSP URI and the CRLDP URI.
Linked issue comments: user: login: jsha
created_at: 2025-04-15T18:18:46Z
body: Fixed in #8103.
author_association: CONTRIBUTOR

Diff:
diff --git a/issuance/cert.go b/issuance/cert.go
index 84e17c7d90f..285a303e97a 100644
--- a/issuance/cert.go
+++ b/issuance/cert.go
@@ -56,6 +56,11 @@ type ProfileConfig struct {
 	OmitClientAuth bool `asn1:"tag:4,optional"`
 	// OmitSKID causes the Subject Key Identifier extension to be omitted.
 	OmitSKID bool `asn1:"tag:5,optional"`
+	// OmitOCSP causes the OCSP URI field to be omitted from the Authority
+	// Information Access extension. This cannot be true unless
+	// IncludeCRLDistributionPoints is also true, to ensure that every
+	// certificate has at least one revocation mechanism included.
+	OmitOCSP bool `asn1:"tag:11,optional"`
 	// IncludeCRLDistributionPoints causes the CRLDistributionPoints extension to
 	// be added to all certificates issued by this profile.
 	IncludeCRLDistributionPoints bool `asn1:"tag:6,optional"`
@@ -91,6 +96,7 @@ type Profile struct {
 	omitKeyEncipherment bool
 	omitClientAuth      bool
 	omitSKID            bool
+	omitOCSP            bool
 
 	includeCRLDistributionPoints bool
 
@@ -116,6 +122,13 @@ func NewProfile(profileConfig *ProfileConfig) (*Profile, error) {
 		return nil, fmt.Errorf("validity period %q is too large", profileConfig.MaxValidityPeriod.Duration)
 	}
 
+	// Although the Baseline Requirements say that revocation information may be
+	// omitted entirely *for short-lived certs*, the Microsoft root program still
+	// requires that at least one revocation mechanism be included in all certs.
+	if profileConfig.OmitOCSP && !profileConfig.IncludeCRLDistributionPoints {
+		return nil, fmt.Errorf("at least one revocation mechanism must be included")
+	}
+
 	lints, err := linter.NewRegistry(profileConfig.IgnoredLints)
 	cmd.FailOnError(err, "Failed to create zlint registry")
 	if profileConfig.LintConfig != "" {
@@ -135,6 +148,7 @@ func NewProfile(profileConfig *ProfileConfig) (*Profile, error) {
 		omitKeyEncipherment:          profileConfig.OmitKeyEncipherment,
 		omitClientAuth:               profileConfig.OmitClientAuth,
 		omitSKID:                     profileConfig.OmitSKID,
+		omitOCSP:                     profileConfig.OmitOCSP,
 		includeCRLDistributionPoints: profileConfig.IncludeCRLDistributionPoints,
 		maxBackdate:                  profileConfig.MaxValidityBackdate.Duration,
 		maxValidity:                  profileConfig.MaxValidityPeriod.Duration,
@@ -227,7 +241,6 @@ var domainValidatedOID = func() x509.OID {
 func (i *Issuer) generateTemplate() *x509.Certificate {
 	template := &x509.Certificate{
 		SignatureAlgorithm:    i.sigAlg,
-		OCSPServer:            []string{i.ocspURL},
 		IssuingCertificateURL: []string{i.issuerURL},
 		BasicConstraintsValid: true,
 		// Baseline Requirements, Section 7.1.6.1: domain-validated
@@ -408,6 +421,10 @@ func (i *Issuer) Prepare(prof *Profile, req *IssuanceRequest) ([]byte, *issuance
 		return nil, nil, errors.New("invalid request contains neither sctList nor precertDER")
 	}
 
+	if !prof.omitOCSP {
+		template.OCSPServer = []string{i.ocspURL}
+	}
+
 	// If explicit CRL sharding is enabled, pick a shard based on the serial number
 	// modulus the number of shards. This gives us random distribution that is
 	// nonetheless consistent between precert and cert.
diff --git a/issuance/cert_test.go b/issuance/cert_test.go
index b4ebf3cde51..e7aece2e962 100644
--- a/issuance/cert_test.go
+++ b/issuance/cert_test.go
@@ -12,6 +12,7 @@ import (
 	"encoding/base64"
 	"fmt"
 	"reflect"
+	"strings"
 	"testing"
 	"time"
 
@@ -333,9 +334,10 @@ func TestGenerateTemplate(t *testing.T) {
 		BasicConstraintsValid: true,
 		SignatureAlgorithm:    x509.SHA256WithRSA,
 		IssuingCertificateURL: []string{"http://issuer"},
-		OCSPServer:            []string{"http://ocsp"},
-		CRLDistributionPoints: nil,
 		Policies:              []x509.OID{domainValidatedOID},
+		// These fields are only included if specified in the profile.
+		OCSPServer:            nil,
+		CRLDistributionPoints: nil,
 	}
 
 	test.AssertDeepEquals(t, actual, expected)
@@ -869,6 +871,73 @@ func TestMismatchedProfiles(t *testing.T) {
 	test.AssertContains(t, err.Error(), "precert does not correspond to linted final cert")
 }
 
+func TestNewProfile(t *testing.T) {
+	for _, tc := range []struct {
+		name    string
+		config  ProfileConfig
+		wantErr string
+	}{
+		{
+			name: "happy path",
+			config: ProfileConfig{
+				MaxValidityBackdate: config.Duration{Duration: 1 * time.Hour},
+				MaxValidityPeriod:   config.Duration{Duration: 90 * 24 * time.Hour},
+			},
+		},
+		{
+			name: "crl but no ocsp",
+			config: ProfileConfig{
+				MaxValidityBackdate:          config.Duration{Duration: 1 * time.Hour},
+				MaxValidityPeriod:            config.Duration{Duration: 90 * 24 * time.Hour},
+				OmitOCSP:                     false,
+				IncludeCRLDistributionPoints: true,
+			},
+		},
+		{
+			name: "large backdate",
+			config: ProfileConfig{
+				MaxValidityBackdate: config.Duration{Duration: 24 * time.Hour},
+				MaxValidityPeriod:   config.Duration{Duration: 90 * 24 * time.Hour},
+			},
+			wantErr: "backdate \"24h0m0s\" is too large",
+		},
+		{
+			name: "large validity",
+			config: ProfileConfig{
+				MaxValidityBackdate: config.Duration{Duration: 1 * time.Hour},
+				MaxValidityPeriod:   config.Duration{Duration: 397 * 24 * time.Hour},
+			},
+			wantErr: "validity period \"9528h0m0s\" is too large",
+		},
+		{
+			name: "no revocation info",
+			config: ProfileConfig{
+				MaxValidityBackdate:          config.Duration{Duration: 1 * time.Hour},
+				MaxValidityPeriod:            config.Duration{Duration: 90 * 24 * time.Hour},
+				OmitOCSP:                     true,
+				IncludeCRLDistributionPoints: false,
+			},
+			wantErr: "revocation mechanism must be included",
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			gotProfile, gotErr := NewProfile(&tc.config)
+			if tc.wantErr != "" {
+				if gotErr == nil {
+					t.Errorf("NewProfile(%#v) = %#v, but want err %q", tc.config, gotProfile, tc.wantErr)
+				}
+				if !strings.Contains(gotErr.Error(), tc.wantErr) {
+					t.Errorf("NewProfile(%#v) = %q, but want %q", tc.config, gotErr, tc.wantErr)
+				}
+			} else {
+				if gotErr != nil {
+					t.Errorf("NewProfile(%#v) = %q, but want no error", tc.config, gotErr)
+				}
+			}
+		})
+	}
+}
+
 func TestProfileHash(t *testing.T) {
 	// A profile _with_ IncludeCRLDistributionPoints.
 	// Hash calculated over the ASN.1 encoding of the `ProfileConfigNew`.
diff --git a/linter/lints/rfc/lint_cert_via_pkimetal.go b/linter/lints/rfc/lint_cert_via_pkimetal.go
index fdcd2d6a8fd..31fc08d8135 100644
--- a/linter/lints/rfc/lint_cert_via_pkimetal.go
+++ b/linter/lints/rfc/lint_cert_via_pkimetal.go
@@ -89,14 +89,16 @@ func (pkim *PKIMetalConfig) execute(endpoint string, der []byte) (*lint.LintResu
 
 	var findings []string
 	for _, finding := range res {
-		id := fmt.Sprintf("%s:%s", finding.Linter, finding.Code)
+		var id string
+		if finding.Code != "" {
+			id = fmt.Sprintf("%s:%s", finding.Linter, finding.Code)
+		} else {
+			id = fmt.Sprintf("%s:%s", finding.Linter, strings.ReplaceAll(strings.ToLower(finding.Finding), " ", "_"))
+		}
 		if slices.Contains(pkim.IgnoreLints, id) {
 			continue
 		}
-		desc := fmt.Sprintf("%s from %s at %s", finding.Severity, id, finding.Field)
-		if finding.Finding != "" {
-			desc = fmt.Sprintf("%s: %s", desc, finding.Finding)
-		}
+		desc := fmt.Sprintf("%s from %s: %s", finding.Severity, id, finding.Finding)
 		findings = append(findings, desc)
 	}
 
diff --git a/ra/ra.go b/ra/ra.go
index c36436afb2b..564cfc452f1 100644
--- a/ra/ra.go
+++ b/ra/ra.go
@@ -1787,6 +1787,12 @@ func (ra *RegistrationAuthorityImpl) updateRevocationForKeyCompromise(ctx contex
 // purgeOCSPCache makes a request to akamai-purger to purge the cache entries
 // for the given certificate.
 func (ra *RegistrationAuthorityImpl) purgeOCSPCache(ctx context.Context, cert *x509.Certificate, issuerID issuance.NameID) error {
+	if len(cert.OCSPServer) == 0 {
+		// We can't purge the cache (and there should be no responses in the cache)
+		// for certs that have no AIA OCSP URI.
+		return nil
+	}
+
 	issuer, ok := ra.issuersByNameID[issuerID]
 	if !ok {
 		return fmt.Errorf("unable to identify issuer of cert with serial %q", core.SerialToString(cert.SerialNumber))
diff --git a/test/config-next/ca.json b/test/config-next/ca.json
index 020b974dcf2..742f686dbb4 100644
--- a/test/config-next/ca.json
+++ b/test/config-next/ca.json
@@ -55,6 +55,7 @@
 			"certProfiles": {
 				"legacy": {
 					"allowMustStaple": true,
+					"omitOCSP": true,
 					"includeCRLDistributionPoints": true,
 					"maxValidityPeriod": "7776000s",
 					"maxValidityBackdate": "1h5m",
@@ -70,6 +71,7 @@
 					"omitKeyEncipherment": true,
 					"omitClientAuth": true,
 					"omitSKID": true,
+					"omitOCSP": true,
 					"includeCRLDistributionPoints": true,
 					"maxValidityPeriod": "583200s",
 					"maxValidityBackdate": "1h5m",
@@ -84,6 +86,7 @@
 					"omitKeyEncipherment": true,
 					"omitClientAuth": true,
 					"omitSKID": true,
+					"omitOCSP": true,
 					"includeCRLDistributionPoints": true,
 					"maxValidityPeriod": "160h",
 					"maxValidityBackdate": "1h5m",
diff --git a/test/config-next/zlint.toml b/test/config-next/zlint.toml
index 26bf997e6cd..3da518a7028 100644
--- a/test/config-next/zlint.toml
+++ b/test/config-next/zlint.toml
@@ -15,6 +15,10 @@ ignore_lints = [
   # issued under the "classic" profile, but have removed it from our "tlsserver"
   # and "shortlived" profiles.
   "pkilint:cabf.serverauth.subscriber_rsa_digitalsignature_and_keyencipherment_present",
+  # Some linters continue to complain about the lack of an AIA OCSP URI, even
+  # when a CRLDP is present.
+  "certlint:br_certificates_must_include_an_http_url_of_the_ocsp_responder",
+  "x509lint:no_ocsp_over_http"
 ]
 
 [e_pkimetal_lint_cabf_serverauth_crl]
diff --git a/test/integration/cert_storage_failed_test.go b/test/integration/cert_storage_failed_test.go
index 12cc19bf2ba..9dac86409f8 100644
--- a/test/integration/cert_storage_failed_test.go
+++ b/test/integration/cert_storage_failed_test.go
@@ -13,6 +13,7 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
+	"path"
 	"strings"
 	"testing"
 	"time"
@@ -63,7 +64,7 @@ func getPrecertByName(db *sql.DB, name string) (*x509.Certificate, error) {
 
 // expectOCSP500 queries OCSP for the given certificate and expects a 500 error.
 func expectOCSP500(cert *x509.Certificate) error {
-	_, err := ocsp_helper.Req(cert, ocsp_helper.DefaultConfig)
+	_, err := ocsp_helper.Req(cert, ocspConf())
 	if err == nil {
 		return errors.New("Expected error getting OCSP for certificate that failed status storage")
 	}
@@ -92,7 +93,6 @@ func expectOCSP500(cert *x509.Certificate) error {
 // that a final certificate exists for any precertificate, though it is
 // similar in spirit).
 func TestIssuanceCertStorageFailed(t *testing.T) {
-	t.Parallel()
 	os.Setenv("DIRECTORY", "http://boulder.service.consul:4001/directory")
 
 	ctx := context.Background()
@@ -182,6 +182,7 @@ func TestIssuanceCertStorageFailed(t *testing.T) {
 	test.AssertNotError(t, err, "issuing second cert")
 
 	successfulCert := res.certs[0]
+	successfulCertIssuer := res.certs[1]
 	err = revokeClient.RevokeCertificate(
 		revokeClient.Account,
 		successfulCert,
@@ -190,9 +191,12 @@ func TestIssuanceCertStorageFailed(t *testing.T) {
 	)
 	test.AssertNotError(t, err, "revoking second certificate")
 
+	runUpdater(t, path.Join(os.Getenv("BOULDER_CONFIG_DIR"), "crl-updater.json"))
+	fetchAndCheckRevoked(t, successfulCert, successfulCertIssuer, ocsp.KeyCompromise)
+
 	for range 300 {
 		_, err = ocsp_helper.Req(successfulCert,
-			ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise))
+			ocspConf().WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise))
 		if err == nil {
 			break
 		}
diff --git a/test/integration/ocsp_test.go b/test/integration/ocsp_test.go
index 1e89fe7d5fd..140dee0220c 100644
--- a/test/integration/ocsp_test.go
+++ b/test/integration/ocsp_test.go
@@ -20,7 +20,7 @@ func TestOCSPHappyPath(t *testing.T) {
 	if err != nil || len(cert.certs) < 1 {
 		t.Fatal("failed to issue cert for OCSP testing")
 	}
-	resp, err := ocsp_helper.Req(cert.certs[0], ocsp_helper.DefaultConfig)
+	resp, err := ocsp_helper.Req(cert.certs[0], ocspConf())
 	if err != nil {
 		t.Fatalf("want ocsp response, but got error: %s", err)
 	}
@@ -42,7 +42,7 @@ func TestOCSPBadSerialPrefix(t *testing.T) {
 	serialStr := []byte(core.SerialToString(cert.SerialNumber))
 	serialStr[0] = serialStr[0] + 1
 	cert.SerialNumber.SetString(string(serialStr), 16)
-	_, err = ocsp_helper.Req(cert, ocsp_helper.DefaultConfig)
+	_, err = ocsp_helper.Req(cert, ocspConf())
 	if err == nil {
 		t.Fatal("Expected error getting OCSP for request with invalid serial")
 	}
@@ -74,7 +74,7 @@ func TestOCSPRejectedPrecertificate(t *testing.T) {
 		t.Fatalf("couldn't find rejected precert for %q", domain)
 	}
 
-	ocspConfig := ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Good)
+	ocspConfig := ocspConf().WithExpectStatus(ocsp.Good)
 	_, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig)
 	if err != nil {
 		t.Errorf("requesting OCSP for rejected precertificate: %s", err)
diff --git a/test/integration/revocation_test.go b/test/integration/revocation_test.go
index 2af74928d3c..70ed84b34ee 100644
--- a/test/integration/revocation_test.go
+++ b/test/integration/revocation_test.go
@@ -43,6 +43,14 @@ func isPrecert(cert *x509.Certificate) bool {
 	return false
 }
 
+// ocspConf returns an OCSP helper config with a fallback URL that matches what is
+// configured for our CA / OCSP responder. If an OCSP URL is present in a certificate,
+// ocsp_helper will use that; otherwise it will use the URLFallback. This allows
+// continuing to test OCSP service even after we stop including OCSP URLs in certificates.
+func ocspConf() ocsp_helper.Config {
+	return ocsp_helper.DefaultConfig.WithURLFallback("http://ca.example.org:4002/")
+}
+
 // getALLCRLs fetches and parses each certificate for each configured CA.
 // Returns a map from issuer SKID (hex) to a list of that issuer's CRLs.
 func getAllCRLs(t *testing.T) map[string][]*x509.RevocationList {
@@ -99,6 +107,7 @@ func getAllCRLs(t *testing.T) map[string][]*x509.RevocationList {
 
 // getCRL fetches a CRL, parses it, and checks the signature.
 func getCRL(t *testing.T, crlURL string, issuerCert *x509.Certificate) *x509.RevocationList {
+	t.Helper()
 	resp, err := http.Get(crlURL)
 	if err != nil {
 		t.Fatalf("getting CRL from %s: %s", crlURL, err)
@@ -154,6 +163,18 @@ func fetchAndCheckRevoked(t *testing.T, cert, issuer *x509.Certificate, expected
 	t.Errorf("serial %x not found on CRL %s, expected it to be revoked with reason %d", cert.SerialNumber, crlURL, expectedReason)
 }
 
+func checkUnrevoked(t *testing.T, revocations map[string][]*x509.RevocationList, cert *x509.Certificate) {
+	for _, singleIssuerCRLs := range revocations {
+		for _, crl := range singleIssuerCRLs {
+			for _, entry := range crl.RevokedCertificateEntries {
+				if entry.SerialNumber == cert.SerialNumber {
+					t.Errorf("expected %x to be unrevoked, but found it on a CRL", cert.SerialNumber)
+				}
+			}
+		}
+	}
+}
+
 func checkRevoked(t *testing.T, revocations map[string][]*x509.RevocationList, cert *x509.Certificate, expectedReason int) {
 	t.Helper()
 	akid := hex.EncodeToString(cert.AuthorityKeyId)
@@ -266,8 +287,8 @@ func TestRevocation(t *testing.T) {
 			t.Fatalf("unrecognized cert kind %q", tc.kind)
 		}
 
-		// Initially, the cert should have a Good OCSP response.
-		ocspConfig := ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Good)
+		// Initially, the cert should have a Good OCSP response (only via OCSP; the CRL is unchanged by issuance).
+		ocspConfig := ocspConf().WithExpectStatus(ocsp.Good)
 		_, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig)
 		test.AssertNotError(t, err, "requesting OCSP for precert")
 
@@ -378,7 +399,8 @@ func TestRevocation(t *testing.T) {
 					}
 
 					check := func(t *testing.T, allCRLs map[string][]*x509.RevocationList) {
-						_, err := ocsp_helper.ReqDER(cert.Raw, ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(expectedReason))
+						ocspConfig := ocspConf().WithExpectStatus(ocsp.Revoked).WithExpectReason(expectedReason)
+						_, err := ocsp_helper.ReqDER(cert.Raw, ocspConfig)
 						test.AssertNotError(t, err, "requesting OCSP for revoked cert")
 
 						checkRevoked(t, allCRLs, cert, expectedReason)
@@ -409,8 +431,6 @@ func TestRevocation(t *testing.T) {
 // In which case the revocation reason (but not revocation date) will be
 // updated to be keyCompromise.
 func TestReRevocation(t *testing.T) {
-	t.Parallel()
-
 	type authMethod string
 	var (
 		byAccount authMethod = "byAccount"
@@ -447,8 +467,8 @@ func TestReRevocation(t *testing.T) {
 			cert := res.certs[0]
 			issuer := res.certs[1]
 
-			// Initially, the cert should have a Good OCSP response.
-			ocspConfig := ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Good)
+			// Initially, the cert should have a Good OCSP response (only via OCSP; the CRL is unchanged by issuance).
+			ocspConfig := ocspConf().WithExpectStatus(ocsp.Good)
 			_, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig)
 			test.AssertNotError(t, err, "requesting OCSP for precert")
 
@@ -520,7 +540,7 @@ func TestReRevocation(t *testing.T) {
 
 				// Check the OCSP response for the certificate again. It should still be
 				// revoked, with the same reason.
-				ocspConfig = ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(tc.reason1)
+				ocspConfig := ocspConf().WithExpectStatus(ocsp.Revoked).WithExpectReason(tc.reason1)
 				_, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig)
 				// Check the CRL for the certificate again. It should still be
 				// revoked, with the same reason.
@@ -532,7 +552,7 @@ func TestReRevocation(t *testing.T) {
 
 				// Check the OCSP response for the certificate again. It should now be
 				// revoked with reason keyCompromise.
-				ocspConfig = ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(tc.reason2)
+				ocspConfig := ocspConf().WithExpectStatus(ocsp.Revoked).WithExpectReason(tc.reason2)
 				_, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig)
 				// Check the CRL for the certificate again. It should now be
 				// revoked with reason keyCompromise.
@@ -544,8 +564,6 @@ func TestReRevocation(t *testing.T) {
 }
 
 func TestRevokeWithKeyCompromiseBlocksKey(t *testing.T) {
-	t.Parallel()
-
 	type authMethod string
 	var (
 		byAccount authMethod = "byAccount"
@@ -565,6 +583,11 @@ func TestRevokeWithKeyCompromiseBlocksKey(t *testing.T) {
 		res, err := authAndIssue(c, certKey, []acme.Identifier{{Type: "dns", Value: random_domain()}}, true, "")
 		test.AssertNotError(t, err, "authAndIssue failed")
 		cert := res.certs[0]
+		issuer := res.certs[1]
+
+		ocspConfig := ocspConf().WithExpectStatus(ocsp.Good)
+		_, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig)
+		test.AssertNotError(t, err, "requesting OCSP for not yet revoked cert")
 
 		// Revoke the cert with reason keyCompromise, either authenticated via the
 		// issuing account, or via the certificate key itself.
@@ -577,10 +600,13 @@ func TestRevokeWithKeyCompromiseBlocksKey(t *testing.T) {
 		test.AssertNotError(t, err, "failed to revoke certificate")
 
 		// Check the OCSP response. It should be revoked with reason = 1 (keyCompromise).
-		ocspConfig := ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise)
+		ocspConfig = ocspConf().WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise)
 		_, err = ocsp_helper.ReqDER(cert.Raw, ocspConfig)
 		test.AssertNotError(t, err, "requesting OCSP for revoked cert")
 
+		runUpdater(t, path.Join(os.Getenv("BOULDER_CONFIG_DIR"), "crl-updater.json"))
+		fetchAndCheckRevoked(t, cert, issuer, ocsp.KeyCompromise)
+
 		// Attempt to create a new account using the compromised key. This should
 		// work when the key was just *reported* as compromised, but fail when
 		// the compromise was demonstrated/proven.
@@ -616,12 +642,12 @@ func TestBadKeyRevoker(t *testing.T) {
 	badCert := res.certs[0]
 	t.Logf("Generated to-be-revoked cert with serial %x", badCert.SerialNumber)
 
-	certs := []*x509.Certificate{}
+	bundles := []*issuanceResult{}
 	for _, c := range []*client{revokerClient, revokeeClient, noContactClient} {
-		cert, err := authAndIssue(c, certKey, []acme.Identifier{{Type: "dns", Value: random_domain()}}, true, "")
-		t.Logf("TestBadKeyRevoker: Issued cert with serial %x", cert.certs[0].SerialNumber)
+		bundle, err := authAndIssue(c, certKey, []acme.Identifier{{Type: "dns", Value: random_domain()}}, true, "")
+		t.Logf("TestBadKeyRevoker: Issued cert with serial %x", bundle.certs[0].SerialNumber)
 		test.AssertNotError(t, err, "authAndIssue failed")
-		certs = append(certs, cert.certs[0])
+		bundles = append(bundles, bundle)
 	}
 
 	err = revokerClient.RevokeCertificate(
@@ -632,11 +658,12 @@ func TestBadKeyRevoker(t *testing.T) {
 	)
 	test.AssertNotError(t, err, "failed to revoke certificate")
 
-	ocspConfig := ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise)
+	ocspConfig := ocspConf().WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise)
 	_, err = ocsp_helper.ReqDER(badCert.Raw, ocspConfig)
 	test.AssertNotError(t, err, "ReqDER failed")
 
-	for _, cert := range certs {
+	for _, bundle := range bundles {
+		cert := bundle.certs[0]
 		for i := range 5 {
 			t.Logf("TestBadKeyRevoker: Requesting OCSP for cert with serial %x (attempt %d)", cert.SerialNumber, i)
 			_, err := ocsp_helper.ReqDER(cert.Raw, ocspConfig)
@@ -652,6 +679,13 @@ func TestBadKeyRevoker(t *testing.T) {
 		}
 	}
 
+	runUpdater(t, path.Join(os.Getenv("BOULDER_CONFIG_DIR"), "crl-updater.json"))
+	for _, bundle := range bundles {
+		cert := bundle.certs[0]
+		issuer := bundle.certs[1]
+		fetchAndCheckRevoked(t, cert, issuer, ocsp.KeyCompromise)
+	}
+
 	revokeeCount, err := http.Get("http://boulder.service.consul:9381/count?to=revokee@letsencrypt.org&from=bad-key-revoker@test.org")
 	test.AssertNotError(t, err, "mail-test-srv GET /count failed")
 	defer func() { _ = revokeeCount.Body.Close() }()
@@ -694,12 +728,12 @@ func TestBadKeyRevokerByAccount(t *testing.T) {
 	badCert := res.certs[0]
 	t.Logf("Generated to-be-revoked cert with serial %x", badCert.SerialNumber)
 
-	certs := []*x509.Certificate{}
+	bundles := []*issuanceResult{}
 	for _, c := range []*client{revokerClient, revokeeClient, noContactClient} {
-		cert, err := authAndIssue(c, certKey, []acme.Identifier{{Type: "dns", Value: random_domain()}}, true, "")
-		t.Logf("TestBadKeyRevokerByAccount: Issued cert with serial %x", cert.certs[0].SerialNumber)
+		bundle, err := authAndIssue(c, certKey, []acme.Identifier{{Type: "dns", Value: random_domain()}}, true, "")
+		t.Logf("TestBadKeyRevokerByAccount: Issued cert with serial %x", bundle.certs[0].SerialNumber)
 		test.AssertNotError(t, err, "authAndIssue failed")
-		certs = append(certs, cert.certs[0])
+		bundles = append(bundles, bundle)
 	}
 
 	err = revokerClient.RevokeCertificate(
@@ -710,25 +744,26 @@ func TestBadKeyRevokerByAccount(t *testing.T) {
 	)
 	test.AssertNotError(t, err, "failed to revoke certificate")
 
-	ocspConfig := ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise)
+	ocspConfig := ocspConf().WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise)
 	_, err = ocsp_helper.ReqDER(badCert.Raw, ocspConfig)
 	test.AssertNotError(t, err, "ReqDER failed")
 
-	ocspConfig = ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Good)
-	for _, cert := range certs {
-		for i := range 5 {
-			t.Logf("TestBadKeyRevoker: Requesting OCSP for cert with serial %x (attempt %d)", cert.SerialNumber, i)
-			_, err := ocsp_helper.ReqDER(cert.Raw, ocspConfig)
-			if err != nil {
-				t.Logf("TestBadKeyRevoker: Got bad response: %s", err.Error())
-				if i >= 4 {
-					t.Fatal("timed out waiting for correct OCSP status")
-				}
-				time.Sleep(time.Second)
-				continue
-			}
-			break
+	// Note: this test is inherently racy because we don't have a signal
+	// for when the bad-key-revoker has completed a run after the revocation. However,
+	// the bad-key-revoker's configured interval is 50ms, so sleeping 1s should be good enough.
+	time.Sleep(time.Second)
+
+	runUpdater(t, path.Join(os.Getenv("BOULDER_CONFIG_DIR"), "crl-updater.json"))
+	allCRLs := getAllCRLs(t)
+	ocspConfig = ocspConf().WithExpectStatus(ocsp.Good)
+	for _, bundle := range bundles {
+		cert := bundle.certs[0]
+		t.Logf("TestBadKeyRevoker: Requesting OCSP for cert with serial %x", cert.SerialNumber)
+		_, err := ocsp_helper.ReqDER(cert.Raw, ocspConfig)
+		if err != nil {
+			t.Error(err)
 		}
+		checkUnrevoked(t, allCRLs, cert)
 	}
 
 	revokeeCount, err := http.Get("http://boulder.service.consul:9381/count?to=revokee-moz@letsencrypt.org&from=bad-key-revoker@test.org")
diff --git a/test/ocsp/helper/helper.go b/test/ocsp/helper/helper.go
index a223f5fa6f4..469c8cec12b 100644
--- a/test/ocsp/helper/helper.go
+++ b/test/ocsp/helper/helper.go
@@ -36,8 +36,11 @@ var (
 // Config contains fields which control various behaviors of the
 // checker's behavior.
 type Config struct {
-	method             string
-	urlOverride        string
+	method string
+	// This URL will always be used in place of the URL in a certificate.
+	urlOverride string
+	// This URL will be used if no urlOverride is present and no OCSP URL is in the certificate.
+	urlFallback        string
 	hostOverride       string
 	tooSoon            int
 	ignoreExpiredCerts bool
@@ -52,6 +55,7 @@ type Config struct {
 var DefaultConfig = Config{
 	method:             "GET",
 	urlOverride:        "",
+	urlFallback:        "",
 	hostOverride:       "",
 	tooSoon:            76,
 	ignoreExpiredCerts: false,
@@ -115,6 +119,12 @@ func (template Config) WithExpectReason(reason int) Config {
 	return ret
 }
 
+func (template Config) WithURLFallback(url string) Config {
+	ret := template
+	ret.urlFallback = url
+	return ret
+}
+
 // WithOutput returns a new Config with the given output,
 // and all other fields the same as the receiver.
 func (template Config) WithOutput(w io.Writer) Config {
@@ -268,7 +278,7 @@ func Req(cert *x509.Certificate, config Config) (*ocsp.Response, error) {
 		return nil, fmt.Errorf("creating OCSP request: %s", err)
 	}
 
-	ocspURL, err := getOCSPURL(cert, config.urlOverride)
+	ocspURL, err := getOCSPURL(cert, config.urlOverride, config.urlFallback)
 	if err != nil {
 		return nil, err
 	}
@@ -341,12 +351,14 @@ func sendHTTPRequest(
 	return client.Do(httpRequest)
 }
 
-func getOCSPURL(cert *x509.Certificate, urlOverride string) (*url.URL, error) {
+func getOCSPURL(cert *x509.Certificate, urlOverride, urlFallback string) (*url.URL, error) {
 	var ocspServer string
 	if urlOverride != "" {
 		ocspServer = urlOverride
 	} else if len(cert.OCSPServer) > 0 {
 		ocspServer = cert.OCSPServer[0]
+	} else if len(urlFallback) > 0 {
+		ocspServer = urlFallback
 	} else {
 		return nil, fmt.Errorf("no ocsp servers in cert")
 	}


Reviews:
user: login: beautifulentropy
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-08T19:27:10Z
user: login: beautifulentropy
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-08T21:22:45Z
user: login: jsha
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T21:44:48Z
user: login: jprenken
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T01:45:43Z

Review comments:
diff_hunk: @@ -333,9 +334,10 @@ func TestGenerateTemplate(t *testing.T) {
 		BasicConstraintsValid: true,
 		SignatureAlgorithm:    x509.SHA256WithRSA,
 		IssuingCertificateURL: []string{"http://issuer"},
-		OCSPServer:            []string{"http://ocsp"},
-		CRLDistributionPoints: nil,
 		Policies:              []x509.OID{domainValidatedOID},
+		// These fields are only included if the profile wants them.
path: issuance/cert_test.go
user: login: beautifulentropy
body: Optional:

```suggestion
		// These fields are only included if specified in the profile.
```
created_at: 2025-04-08T19:02:58Z
author_association: MEMBER
diff_hunk: @@ -15,6 +15,10 @@ ignore_lints = [
   # issued under the "classic" profile, but have removed it from our "tlsserver"
   # and "shortlived" profiles.
   "pkilint:cabf.serverauth.subscriber_rsa_digitalsignature_and_keyencipherment_present",
+  # Some linters continue to complain about the lack of an AIA OCSP URI, even
+  # when a CRLDP is present.
path: test/config-next/zlint.toml
user: login: beautifulentropy
body: This is unfortunate, perhaps we should file issues for these two.
created_at: 2025-04-08T19:06:14Z
author_association: MEMBER
diff_hunk: @@ -43,6 +43,14 @@ func isPrecert(cert *x509.Certificate) bool {
 	return false
 }
 
+// ocspConf returns an OCSP helper config with a fallback URL that matches what is
+// configured for our CA / OCSP responder. If an OCSP URL is present in a certificate,
+// ocsp_helper will use that; otherwise it will use the URLFallback. This allows
+// continuing to test OCSP service even after we stop including OCSP URLs in certificates.
+func ocspConf() ocsp_helper.Config {
path: test/integration/revocation_test.go
user: login: beautifulentropy
body: This is clean, I like it.
created_at: 2025-04-08T19:10:33Z
author_association: MEMBER
diff_hunk: @@ -15,6 +15,10 @@ ignore_lints = [
   # issued under the "classic" profile, but have removed it from our "tlsserver"
   # and "shortlived" profiles.
   "pkilint:cabf.serverauth.subscriber_rsa_digitalsignature_and_keyencipherment_present",
+  # Some linters continue to complain about the lack of an AIA OCSP URI, even
+  # when a CRLDP is present.
path: test/config-next/zlint.toml
user: login: jsha
body: Good call, thanks! https://github.com/certlint/certlint/issues/20

x509lint hasn't been modified in 5 years, so I'm not going to bother filing an issue on that one.
created_at: 2025-04-08T21:44:47Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-07T19:19:54Z
body: <!-- deployment_ticket_check -->
@jsha, this PR appears to contain configuration and/or SQL schema changes. Please ensure that a corresponding deployment ticket has been filed with the new values.

author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-07T19:19:55Z
body: <!-- deployment_ticket_check -->
@jsha, this PR appears to contain configuration and/or SQL schema changes. Please ensure that a corresponding deployment ticket has been filed with the new values.

author_association: CONTRIBUTOR
user: login: jsha
created_at: 2025-04-09T00:23:07Z
body: CP/CPS/BR review:

[BRs 2.1.4](https://cabforum.org/working-groups/server/baseline-requirements/documents/CA-Browser-Forum-TLS-BR-2.1.4.pdf), under "7.1.2.10.3 CA Certificate Authority Information Access", says:

> If present, the AuthorityInfoAccessSyntax MUST contain one or more
AccessDescriptions. Each AccessDescription MUST only contain a permitted
accessMethod, as detailed below, and each accessLocation MUST be encoded as the specified
GeneralName type.

It also lists both `id-ad-ocsp` and `id-ad-caIssuers` as MAY.

Earlier, in "7.1.2.7.6 Subscriber Certificate Extensions", the BRs say that `authorityInformationAccess` is a `MUST`.

Together, these imply that at least one of the OCSP URL or the CA Issuers URL must be present. Note: this is not the same as requiring that one of OCSP or CRL must be present. CRLDistributionPoints is a separate extension.

Fortunately, we unconditionally set IssuingCertificateURL in issuance/cert.go: https://github.com/letsencrypt/boulder/blob/30c5fc4f58754b74c7bc66fccbde0cdec00a2601/issuance/cert.go#L244.

And the Go stdlib's `x509` package will always include the `authorityInfoAccess` extension when either of OCSPServers or IssuingCertificateURL is set:

https://cs.opensource.google/go/go/+/refs/tags/go1.24.2:src/crypto/x509/x509.go;l=1230-1251

Additionally the BRs say under "4.9.7 CRL issuance frequency":

> CAs issuing Subscriber Certificates:
> 1. MUST update and publish a new CRL at least every: ‐ seven (7) days if all Certificates include an
Authority Information Access extension with an id‐ad‐ocsp accessMethod (“AIA OCSP pointer”); or
‐ four (4) days in all other cases; 2. MUST update and publish a new CRL within twenty‐four (24)
hours after recording a Certificate as revoked.

So our requirement on CRL publishing will change from 7 days to 4 days. We are well below that (30 min), so we're good from a config perspective. I'll update our Boulder-internal check to 4 days in a separate PR.

The Let's Encrypt combined CP/CPS 5.7 (https://letsencrypt.org/documents/isrg-cp-cps-v5.7/) says:

> 4.9.9 On-line revocation/status checking availability
> ISRG may provide revocation information via OCSP for Subscriber Certificates and/or Subordinate CA certificates, though ISRG makes no commitment to doing so.

All good there.

> DV-SSL Subscriber Certificate
> Authority Information Access	Contains CA Issuers URL and optionally an OCSP URL; URLs vary based on Issuer

All good.

> 7.3 OCSP profile
> ISRG OCSP responders, if made available, implement the RFC 5019 profile of RFC 6960.

All good.
author_association: CONTRIBUTOR
user: login: jsha
created_at: 2025-04-09T18:46:55Z
body: SRE ticket has been filed (thanks bot!), merging)
author_association: CONTRIBUTOR