Repository: letsencrypt/boulder

Issue:
Include CRL Distribution Point in end-entity certificates
Blocked by https://github.com/letsencrypt/boulder/issues/7094

Once we have a mechanism to calculate the CRL shard for a given certificate at issuance time, we can use that to actually include the appropriate CRL URL in the certificate's CRL Distribution Point extension.

This will eventually allow us to remove the Authority Information Access OCSP URL, since a different revocation mechanism will be available for all certificates.

Since the configuration item (issuer.CRLURLBase) which controls this URL will be already populated for the sake of the Issuing Distribution Point extension in CRLs themselves, this behavior will need to be gated behind a feature flag.

Discussion:
@aarongable: Fixed by https://github.com/letsencrypt/boulder/pull/7985

Solution patch:
diff --git a/ca/crl.go b/ca/crl.go
index 4d62ca5a6..35b7caff7 100644
--- a/ca/crl.go
+++ b/ca/crl.go
@@ -1,18 +1,12 @@
 package ca
 
 import (
-	"crypto/rand"
 	"crypto/sha256"
 	"crypto/x509"
-	"crypto/x509/pkix"
-	"encoding/asn1"
 	"errors"
 	"fmt"
 	"io"
 	"strings"
-	"time"
-
-	"github.com/zmap/zlint/v3/lint"
 
 	capb "github.com/letsencrypt/boulder/ca/proto"
 	"github.com/letsencrypt/boulder/core"
@@ -25,9 +19,8 @@ import (
 type crlImpl struct {
 	capb.UnimplementedCRLGeneratorServer
 	issuers map[issuance.NameID]*issuance.Issuer
-	// TODO(#7159): Move this into the CRL profile
-	lints     lint.Registry
-	lifetime  time.Duration
+	profile *issuance.CRLProfile
+	// TODO(#7094): Remove this once all CRLs have IDPs built from issuer.crlURLBase.
 	idpBase   string
 	maxLogLen int
 	log       blog.Logger
@@ -38,21 +31,24 @@ type crlImpl struct {
 // issue CRLs from. lifetime sets the validity period (inclusive) of the
 // resulting CRLs. idpBase is the base URL from which IssuingDistributionPoint
 // URIs will constructed; it must use the http:// scheme.
-func NewCRLImpl(issuers []*issuance.Issuer, lints lint.Registry, lifetime time.Duration, idpBase string, maxLogLen int, logger blog.Logger) (*crlImpl, error) {
+func NewCRLImpl(
+	issuers []*issuance.Issuer,
+	profileConfig issuance.CRLProfileConfig,
+	idpBase string,
+	maxLogLen int,
+	logger blog.Logger) (*crlImpl, error) {
 	issuersByNameID := make(map[issuance.NameID]*issuance.Issuer, len(issuers))
 	for _, issuer := range issuers {
 		issuersByNameID[issuer.NameID()] = issuer
 	}
 
-	if lifetime == 0 {
-		logger.Warningf("got zero for crl lifetime; setting to default 9 days")
-		lifetime = 9 * 24 * time.Hour
-	} else if lifetime >= 10*24*time.Hour {
-		return nil, fmt.Errorf("crl lifetime cannot be more than 10 days, got %q", lifetime)
-	} else if lifetime <= 0*time.Hour {
-		return nil, fmt.Errorf("crl lifetime must be positive, got %q", lifetime)
+	profile, err := issuance.NewCRLProfile(profileConfig)
+	if err != nil {
+		return nil, fmt.Errorf("loading CRL profile: %w", err)
 	}
 
+	// TODO(#7094): Remove this once all CRLs have IDPs built from their
+	// issuer.crlURLBase instead.
 	if !strings.HasPrefix(idpBase, "http://") {
 		return nil, fmt.Errorf("issuingDistributionPoint base URI must use http:// scheme, got %q", idpBase)
 	}
@@ -62,8 +58,7 @@ func NewCRLImpl(issuers []*issuance.Issuer, lints lint.Registry, lifetime time.D
 
 	return &crlImpl{
 		issuers:   issuersByNameID,
-		lints:     lints,
-		lifetime:  lifetime,
+		profile:   profile,
 		idpBase:   idpBase,
 		maxLogLen: maxLogLen,
 		log:       logger,
@@ -72,8 +67,7 @@ func NewCRLImpl(issuers []*issuance.Issuer, lints lint.Registry, lifetime time.D
 
 func (ci *crlImpl) GenerateCRL(stream capb.CRLGenerator_GenerateCRLServer) error {
 	var issuer *issuance.Issuer
-	var template *x509.RevocationList
-	var shard int64
+	var req *issuance.CRLRequest
 	rcs := make([]x509.RevocationListEntry, 0)
 
 	for {
@@ -87,11 +81,11 @@ func (ci *crlImpl) GenerateCRL(stream capb.CRLGenerator_GenerateCRLServer) error
 
 		switch payload := in.Payload.(type) {
 		case *capb.GenerateCRLRequest_Metadata:
-			if template != nil {
+			if req != nil {
 				return errors.New("got more than one metadata message")
 			}
 
-			template, err = ci.metadataToTemplate(payload.Metadata)
+			req, err = ci.metadataToRequest(payload.Metadata)
 			if err != nil {
 				return err
 			}
@@ -102,8 +96,6 @@ func (ci *crlImpl) GenerateCRL(stream capb.CRLGenerator_GenerateCRLServer) error
 				return fmt.Errorf("got unrecognized IssuerNameID: %d", payload.Metadata.IssuerNameID)
 			}
 
-			shard = payload.Metadata.ShardIdx
-
 		case *capb.GenerateCRLRequest_Entry:
 			rc, err := ci.entryToRevokedCertificate(payload.Entry)
 			if err != nil {
@@ -117,23 +109,23 @@ func (ci *crlImpl) GenerateCRL(stream capb.CRLGenerator_GenerateCRLServer) error
 		}
 	}
 
-	if template == nil {
+	if req == nil {
 		return errors.New("no crl metadata received")
 	}
 
 	// Add the Issuing Distribution Point extension.
-	idp, err := makeIDPExt(ci.idpBase, issuer.NameID(), shard)
-	if err != nil {
-		return fmt.Errorf("creating IDP extension: %w", err)
+	// TODO(#7296): Remove this fallback once all configs have issuer.CRLBaseURL
+	// set and our CRL URLs disclosed in CCADB have been updated.
+	if ci.idpBase != "" {
+		req.DeprecatedIDPBaseURL = ci.idpBase
 	}
-	template.ExtraExtensions = append(template.ExtraExtensions, *idp)
 
 	// Compute a unique ID for this issuer-number-shard combo, to tie together all
 	// the audit log lines related to its issuance.
-	logID := blog.LogLineChecksum(fmt.Sprintf("%d", issuer.NameID()) + template.Number.String() + fmt.Sprintf("%d", shard))
+	logID := blog.LogLineChecksum(fmt.Sprintf("%d", issuer.NameID()) + req.Number.String() + fmt.Sprintf("%d", req.Shard))
 	ci.log.AuditInfof(
-		"Signing CRL: logID=[%s] issuer=[%s] number=[%s] shard=[%d] thisUpdate=[%s] nextUpdate=[%s] numEntries=[%d]",
-		logID, issuer.Cert.Subject.CommonName, template.Number.String(), shard, template.ThisUpdate, template.NextUpdate, len(rcs),
+		"Signing CRL: logID=[%s] issuer=[%s] number=[%s] shard=[%d] thisUpdate=[%s] numEntries=[%d]",
+		logID, issuer.Cert.Subject.CommonName, req.Number.String(), req.Shard, req.ThisUpdate, len(rcs),
 	)
 
 	if len(rcs) > 0 {
@@ -155,19 +147,9 @@ func (ci *crlImpl) GenerateCRL(stream capb.CRLGenerator_GenerateCRLServer) error
 		ci.log.AuditInfo(builder.String())
 	}
 
-	template.RevokedCertificateEntries = rcs
+	req.Entries = rcs
 
-	err = issuer.Linter.CheckCRL(template, ci.lints)
-	if err != nil {
-		return err
-	}
-
-	crlBytes, err := x509.CreateRevocationList(
-		rand.Reader,
-		template,
-		issuer.Cert.Certificate,
-		issuer.Signer,
-	)
+	crlBytes, err := issuer.IssueCRL(ci.profile, req)
 	if err != nil {
 		return fmt.Errorf("signing crl: %w", err)
 	}
@@ -197,18 +179,17 @@ func (ci *crlImpl) GenerateCRL(stream capb.CRLGenerator_GenerateCRLServer) error
 	return nil
 }
 
-func (ci *crlImpl) metadataToTemplate(meta *capb.CRLMetadata) (*x509.RevocationList, error) {
-	// TODO(#7153): Check each value via core.IsAnyNilOrZero
-	if meta.IssuerNameID == 0 || core.IsAnyNilOrZero(meta.ThisUpdate) {
+func (ci *crlImpl) metadataToRequest(meta *capb.CRLMetadata) (*issuance.CRLRequest, error) {
+	if core.IsAnyNilOrZero(meta.IssuerNameID, meta.ThisUpdate, meta.ShardIdx) {
 		return nil, errors.New("got incomplete metadata message")
 	}
 	thisUpdate := meta.ThisUpdate.AsTime()
 	number := bcrl.Number(thisUpdate)
 
-	return &x509.RevocationList{
+	return &issuance.CRLRequest{
 		Number:     number,
+		Shard:      meta.ShardIdx,
 		ThisUpdate: thisUpdate,
-		NextUpdate: thisUpdate.Add(-time.Second).Add(ci.lifetime),
 	}, nil
 }
 
@@ -229,51 +210,3 @@ func (ci *crlImpl) entryToRevokedCertificate(entry *corepb.CRLEntry) (*x509.Revo
 		ReasonCode:     int(entry.Reason),
 	}, nil
 }
-
-// distributionPointName represents the ASN.1 DistributionPointName CHOICE as
-// defined in RFC 5280 Section 4.2.1.13. We only use one of the fields, so the
-// others are omitted.
-type distributionPointName struct {
-	// Technically, FullName is of type GeneralNames, which is of type SEQUENCE OF
-	// GeneralName. But GeneralName itself is of type CHOICE, and the ans1.Marhsal
-	// function doesn't support marshalling structs to CHOICEs, so we have to use
-	// asn1.RawValue and encode the GeneralName ourselves.
-	FullName []asn1.RawValue `asn1:"optional,tag:0"`
-}
-
-// issuingDistributionPoint represents the ASN.1 IssuingDistributionPoint
-// SEQUENCE as defined in RFC 5280 Section 5.2.5. We only use two of the fields,
-// so the others are omitted.
-type issuingDistributionPoint struct {
-	DistributionPoint     distributionPointName `asn1:"optional,tag:0"`
-	OnlyContainsUserCerts bool                  `asn1:"optional,tag:1"`
-}
-
-// makeIDPExt returns a critical IssuingDistributionPoint extension containing a
-// URI built from the base url, the issuer's NameID, and the shard number. It
-// also sets the OnlyContainsUserCerts boolean to true.
-func makeIDPExt(base string, issuer issuance.NameID, shardIdx int64) (*pkix.Extension, error) {
-	val := issuingDistributionPoint{
-		DistributionPoint: distributionPointName{
-			[]asn1.RawValue{ // GeneralNames
-				{ // GeneralName
-					Class: 2, // context-specific
-					Tag:   6, // uniformResourceIdentifier, IA5String
-					Bytes: []byte(fmt.Sprintf("%s/%d/%d.crl", base, issuer, shardIdx)),
-				},
-			},
-		},
-		OnlyContainsUserCerts: true,
-	}
-
-	valBytes, err := asn1.Marshal(val)
-	if err != nil {
-		return nil, err
-	}
-
-	return &pkix.Extension{
-		Id:       asn1.ObjectIdentifier{2, 5, 29, 28}, // id-ce-issuingDistributionPoint
-		Value:    valBytes,
-		Critical: true,
-	}, nil
-}
diff --git a/cmd/boulder-ca/main.go b/cmd/boulder-ca/main.go
index 112418256..e7d43b95b 100644
--- a/cmd/boulder-ca/main.go
+++ b/cmd/boulder-ca/main.go
@@ -34,8 +34,10 @@ type Config struct {
 
 		// Issuance contains all information necessary to load and initialize issuers.
 		Issuance struct {
-			Profile      issuance.ProfileConfig
-			Issuers      []issuance.IssuerConfig `validate:"min=1,dive"`
+			Profile issuance.ProfileConfig
+			// TODO(#7159): Make this required once all live configs are using it.
+			CRLProfile   issuance.CRLProfileConfig `validate:"-"`
+			Issuers      []issuance.IssuerConfig   `validate:"min=1,dive"`
 			IgnoredLints []string
 		}
 
@@ -62,7 +64,8 @@ type Config struct {
 		// LifespanCRL is how long CRLs are valid for. It should be longer than the
 		// `period` field of the CRL Updater. Per the BRs, Section 4.9.7, it MUST
 		// NOT be more than 10 days.
-		LifespanCRL config.Duration
+		// Deprecated: Use Config.CA.Issuance.CRLProfile.ValidityInterval instead.
+		LifespanCRL config.Duration `validate:"-"`
 
 		// GoodKey is an embedded config stanza for the goodkey library.
 		GoodKey goodkey.Config
@@ -93,6 +96,7 @@ type Config struct {
 		// CRLDPBase is the piece of the CRL Distribution Point URI which is common
 		// across all issuers and shards. It must use the http:// scheme, and must
 		// not end with a slash. Example: "http://prod.c.lencr.org".
+		// TODO(#7296): Remove this fallback once all configs have issuer.CRLBaseURL
 		CRLDPBase string `validate:"required,url,startswith=http://,endsnotwith=/"`
 
 		// DisableCertService causes the CertificateAuthority gRPC service to not
@@ -145,6 +149,15 @@ func main() {
 		c.CA.LifespanOCSP.Duration = 96 * time.Hour
 	}
 
+	// TODO(#7159): Remove these fallbacks once all live configs are setting the
+	// CRL validity interval inside the Issuance.CRLProfile Config.
+	if c.CA.Issuance.CRLProfile.ValidityInterval.Duration == 0 && c.CA.LifespanCRL.Duration != 0 {
+		c.CA.Issuance.CRLProfile.ValidityInterval = c.CA.LifespanCRL
+	}
+	if c.CA.Issuance.CRLProfile.MaxBackdate.Duration == 0 && c.CA.Backdate.Duration != 0 {
+		c.CA.Issuance.CRLProfile.MaxBackdate = c.CA.Backdate
+	}
+
 	scope, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.CA.DebugAddr)
 	defer oTelShutdown(context.Background())
 	logger.Info(cmd.VersionString())
@@ -238,8 +251,7 @@ func main() {
 	if !c.CA.DisableCRLService {
 		crli, err := ca.NewCRLImpl(
 			issuers,
-			profile.Lints,
-			c.CA.LifespanCRL.Duration,
+			c.CA.Issuance.CRLProfile,
 			c.CA.CRLDPBase,
 			c.CA.OCSPLogMaxLength,
 			logger,
diff --git a/issuance/cert.go b/issuance/cert.go
index b91e40cd7..9deca4e96 100644
--- a/issuance/cert.go
+++ b/issuance/cert.go
@@ -55,8 +55,7 @@ type Profile struct {
 	maxBackdate time.Duration
 	maxValidity time.Duration
 
-	// TODO(#7159): Make this private when CRLs have their own profile.
-	Lints lint.Registry
+	lints lint.Registry
 }
 
 // NewProfile synthesizes the profile config and issuer config into a single
@@ -74,7 +73,7 @@ func NewProfile(profileConfig ProfileConfig, skipLints []string) (*Profile, erro
 		allowCommonName: profileConfig.AllowCommonName,
 		maxBackdate:     profileConfig.MaxValidityBackdate.Duration,
 		maxValidity:     profileConfig.MaxValidityPeriod.Duration,
-		Lints:           reg,
+		lints:           reg,
 	}
 
 	return sp, nil
@@ -161,9 +160,8 @@ func (i *Issuer) generateTemplate() *x509.Certificate {
 		PolicyIdentifiers: []asn1.ObjectIdentifier{{2, 23, 140, 1, 2, 1}},
 	}
 
-	if i.crlURL != "" {
-		template.CRLDistributionPoints = []string{i.crlURL}
-	}
+	// TODO(#7294): Use i.crlURLBase and a shard calculation to create a
+	// crlDistributionPoint.
 
 	return template
 }
@@ -258,7 +256,7 @@ type issuanceToken struct {
 // private key.
 func (i *Issuer) Prepare(prof *Profile, req *IssuanceRequest) ([]byte, *issuanceToken, error) {
 	// check request is valid according to the issuance profile
-	err := i.requestValid(i.Clk, prof, req)
+	err := i.requestValid(i.clk, prof, req)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -304,7 +302,7 @@ func (i *Issuer) Prepare(prof *Profile, req *IssuanceRequest) ([]byte, *issuance
 
 	// check that the tbsCertificate is properly formed by signing it
 	// with a throwaway key and then linting it using zlint
-	lintCertBytes, err := i.Linter.Check(template, req.PublicKey, prof.Lints)
+	lintCertBytes, err := i.Linter.Check(template, req.PublicKey, prof.lints)
 	if err != nil {
 		return nil, nil, fmt.Errorf("tbsCertificate linting failed: %w", err)
 	}
diff --git a/issuance/crl.go b/issuance/crl.go
new file mode 100644
index 000000000..2f36d695c
--- /dev/null
+++ b/issuance/crl.go
@@ -0,0 +1,165 @@
+package issuance
+
+import (
+	"crypto/rand"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/asn1"
+	"fmt"
+	"math/big"
+	"time"
+
+	"github.com/zmap/zlint/v3/lint"
+
+	"github.com/letsencrypt/boulder/config"
+	"github.com/letsencrypt/boulder/linter"
+)
+
+type CRLProfileConfig struct {
+	ValidityInterval config.Duration
+	MaxBackdate      config.Duration
+}
+
+type CRLProfile struct {
+	validityInterval time.Duration
+	maxBackdate      time.Duration
+
+	lints lint.Registry
+}
+
+func NewCRLProfile(config CRLProfileConfig) (*CRLProfile, error) {
+	lifetime := config.ValidityInterval.Duration
+	if lifetime >= 10*24*time.Hour {
+		return nil, fmt.Errorf("crl lifetime cannot be more than 10 days, got %q", lifetime)
+	} else if lifetime <= 0*time.Hour {
+		return nil, fmt.Errorf("crl lifetime must be positive, got %q", lifetime)
+	}
+
+	if config.MaxBackdate.Duration < 0 {
+		return nil, fmt.Errorf("crl max backdate must be non-negative, got %q", config.MaxBackdate)
+	}
+
+	reg, err := linter.NewRegistry(nil)
+	if err != nil {
+		return nil, fmt.Errorf("creating lint registry: %w", err)
+	}
+
+	return &CRLProfile{
+		validityInterval: config.ValidityInterval.Duration,
+		maxBackdate:      config.MaxBackdate.Duration,
+		lints:            reg,
+	}, nil
+}
+
+type CRLRequest struct {
+	Number *big.Int
+	Shard  int64
+
+	ThisUpdate time.Time
+
+	Entries []x509.RevocationListEntry
+
+	// TODO(#7296): Remove this and instead compute it from Issuer.CRLBaseURL
+	DeprecatedIDPBaseURL string
+}
+
+func (i *Issuer) IssueCRL(prof *CRLProfile, req *CRLRequest) ([]byte, error) {
+	backdatedBy := i.clk.Now().Sub(req.ThisUpdate)
+	if backdatedBy > prof.maxBackdate {
+		return nil, fmt.Errorf("ThisUpdate is too far in the past (%s>%s)", backdatedBy, prof.maxBackdate)
+	}
+	if backdatedBy < 0 {
+		return nil, fmt.Errorf("ThisUpdate is in the future (%s>%s)", req.ThisUpdate, i.clk.Now())
+	}
+
+	template := &x509.RevocationList{
+		RevokedCertificateEntries: req.Entries,
+		Number:                    req.Number,
+		ThisUpdate:                req.ThisUpdate,
+		NextUpdate:                req.ThisUpdate.Add(-time.Second).Add(prof.validityInterval),
+	}
+
+	if i.crlURLBase == "" && req.DeprecatedIDPBaseURL == "" {
+		return nil, fmt.Errorf("CRL must contain an issuingDistributionPoint")
+	}
+
+	var idps []string
+	if i.crlURLBase != "" {
+		idps = append(idps, fmt.Sprintf("%s/%d.crl", i.crlURLBase, req.Shard))
+	}
+	if req.DeprecatedIDPBaseURL != "" {
+		// TODO(#7296): Remove this fallback once CCADB and all non-expired certs
+		// contain the new-style CRLDP URL instead.
+		idps = append(idps, fmt.Sprintf("%s/%d/%d.crl", req.DeprecatedIDPBaseURL, i.NameID(), req.Shard))
+	}
+	idp, err := makeIDPExt(idps)
+	if err != nil {
+		return nil, fmt.Errorf("creating IDP extension: %w", err)
+	}
+	template.ExtraExtensions = append(template.ExtraExtensions, idp)
+
+	err = i.Linter.CheckCRL(template, prof.lints)
+	if err != nil {
+		return nil, err
+	}
+
+	crlBytes, err := x509.CreateRevocationList(
+		rand.Reader,
+		template,
+		i.Cert.Certificate,
+		i.Signer,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	return crlBytes, nil
+}
+
+// distributionPointName represents the ASN.1 DistributionPointName CHOICE as
+// defined in RFC 5280 Section 4.2.1.13. We only use one of the fields, so the
+// others are omitted.
+type distributionPointName struct {
+	// Technically, FullName is of type GeneralNames, which is of type SEQUENCE OF
+	// GeneralName. But GeneralName itself is of type CHOICE, and the ans1.Marhsal
+	// function doesn't support marshalling structs to CHOICEs, so we have to use
+	// asn1.RawValue and encode the GeneralName ourselves.
+	FullName []asn1.RawValue `asn1:"optional,tag:0"`
+}
+
+// issuingDistributionPoint represents the ASN.1 IssuingDistributionPoint
+// SEQUENCE as defined in RFC 5280 Section 5.2.5. We only use two of the fields,
+// so the others are omitted.
+type issuingDistributionPoint struct {
+	DistributionPoint     distributionPointName `asn1:"optional,tag:0"`
+	OnlyContainsUserCerts bool                  `asn1:"optional,tag:1"`
+}
+
+// makeIDPExt returns a critical IssuingDistributionPoint extension containing
+// the given URLs and with the OnlyContainsUserCerts boolean set to true.
+func makeIDPExt(urls []string) (pkix.Extension, error) {
+	var gns []asn1.RawValue
+	for _, url := range urls {
+		gns = append(gns, asn1.RawValue{ // GeneralName
+			Class: 2, // context-specific
+			Tag:   6, // uniformResourceIdentifier, IA5String
+			Bytes: []byte(url),
+		})
+	}
+
+	val := issuingDistributionPoint{
+		DistributionPoint:     distributionPointName{FullName: gns},
+		OnlyContainsUserCerts: true,
+	}
+
+	valBytes, err := asn1.Marshal(val)
+	if err != nil {
+		return pkix.Extension{}, err
+	}
+
+	return pkix.Extension{
+		Id:       asn1.ObjectIdentifier{2, 5, 29, 28}, // id-ce-issuingDistributionPoint
+		Value:    valBytes,
+		Critical: true,
+	}, nil
+}
diff --git a/issuance/issuer.go b/issuance/issuer.go
index 74b67017e..04b16aad7 100644
--- a/issuance/issuer.go
+++ b/issuance/issuer.go
@@ -11,6 +11,7 @@ import (
 	"fmt"
 	"math/big"
 	"os"
+	"strings"
 
 	"github.com/jmhodges/clock"
 	"golang.org/x/crypto/ocsp"
@@ -154,9 +155,9 @@ type IssuerConfig struct {
 	UseForRSALeaves   bool
 	UseForECDSALeaves bool
 
-	IssuerURL string `validate:"required,url"`
-	OCSPURL   string `validate:"required,url"`
-	CRLURL    string `validate:"omitempty,url"`
+	IssuerURL  string `validate:"required,url"`
+	OCSPURL    string `validate:"required,url"`
+	CRLURLBase string `validate:"omitempty,url,startswith=http://,endswith=/"`
 
 	Location IssuerLoc
 }
@@ -191,13 +192,17 @@ type Issuer struct {
 	useForRSALeaves   bool
 	useForECDSALeaves bool
 
+	// Used to set the Authority Information Access caIssuers URL in issued
+	// certificates.
 	issuerURL string
-	ocspURL   string
-	crlURL    string
-
-	// TODO(#7159): Make Clk private by giving ca_test.go a better way to build
-	// in-memory Issuers.
-	Clk clock.Clock
+	// Used to set the Authority Information Access ocsp URL in issued
+	// certificates.
+	ocspURL string
+	// Used to set the Issuing Distribution Point extension in issued CRLs
+	// *and* (eventually) the CRL Distribution Point extension in issued certs.
+	crlURLBase string
+
+	clk clock.Clock
 }
 
 // newIssuer constructs a new Issuer from the in-memory certificate and signer.
@@ -226,6 +231,14 @@ func newIssuer(config IssuerConfig, cert *Certificate, signer crypto.Signer, clk
 	if config.OCSPURL == "" {
 		return nil, errors.New("OCSP URL is required")
 	}
+	if config.CRLURLBase != "" {
+		if !strings.HasPrefix(config.CRLURLBase, "http://") {
+			return nil, fmt.Errorf("crlURLBase must use HTTP scheme, got %q", config.CRLURLBase)
+		}
+		if !strings.HasSuffix(config.CRLURLBase, "/") {
+			return nil, fmt.Errorf("crlURLBase must end with exactly one forward slash, got %q", config.CRLURLBase)
+		}
+	}
 
 	// We require that all of our issuers be capable of both issuing certs and
 	// providing revocation information.
@@ -253,8 +266,8 @@ func newIssuer(config IssuerConfig, cert *Certificate, signer crypto.Signer, clk
 		useForECDSALeaves: config.UseForECDSALeaves,
 		issuerURL:         config.IssuerURL,
 		ocspURL:           config.OCSPURL,
-		crlURL:            config.CRLURL,
-		Clk:               clk,
+		crlURLBase:        config.CRLURLBase,
+		clk:               clk,
 	}
 	return i, nil
 }
diff --git a/ra/proto/ra.pb.go b/ra/proto/ra.pb.go
index d2da05fcc..388b2bcc4 100644
--- a/ra/proto/ra.pb.go
+++ b/ra/proto/ra.pb.go
@@ -452,6 +452,8 @@ type NewOrderRequest struct {
 
 	RegistrationID int64    `protobuf:"varint,1,opt,name=registrationID,proto3" json:"registrationID,omitempty"`
 	Names          []string `protobuf:"bytes,2,rep,name=names,proto3" json:"names,omitempty"`
+	ReplacesSerial string   `protobuf:"bytes,3,opt,name=replacesSerial,proto3" json:"replacesSerial,omitempty"`
+	LimitsExempt   bool     `protobuf:"varint,4,opt,name=limitsExempt,proto3" json:"limitsExempt,omitempty"`
 }
 
 func (x *NewOrderRequest) Reset() {
@@ -500,6 +502,20 @@ func (x *NewOrderRequest) GetNames() []string {
 	return nil
 }
 
+func (x *NewOrderRequest) GetReplacesSerial() string {
+	if x != nil {
+		return x.ReplacesSerial
+	}
+	return ""
+}
+
+func (x *NewOrderRequest) GetLimitsExempt() bool {
+	if x != nil {
+		return x.LimitsExempt
+	}
+	return false
+}
+
 type FinalizeOrderRequest struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -612,71 +628,75 @@ var file_ra_proto_rawDesc = []byte{
 	0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x73, 0x6b, 0x69, 0x70, 0x42, 0x6c, 0x6f, 0x63,
 	0x6b, 0x4b, 0x65, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x6d, 0x61, 0x6c, 0x66, 0x6f, 0x72, 0x6d, 0x65,
 	0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6d, 0x61, 0x6c, 0x66, 0x6f, 0x72, 0x6d,
-	0x65, 0x64, 0x22, 0x4f, 0x0a, 0x0f, 0x4e, 0x65, 0x77, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65,
-	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72,
-	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x72,
-	0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x14, 0x0a,
-	0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61,
-	0x6d, 0x65, 0x73, 0x22, 0x4b, 0x0a, 0x14, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x4f,
-	0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x05, 0x6f,
-	0x72, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x63, 0x6f, 0x72,
-	0x65, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x10,
-	0x0a, 0x03, 0x63, 0x73, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x03, 0x63, 0x73, 0x72,
-	0x32, 0xad, 0x06, 0x0a, 0x15, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f,
-	0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x12, 0x3b, 0x0a, 0x0f, 0x4e, 0x65,
-	0x77, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x2e,
-	0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f,
-	0x6e, 0x1a, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72,
-	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, 0x49, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74,
-	0x65, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x2e,
-	0x72, 0x61, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72,
-	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x63,
-	0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
-	0x22, 0x00, 0x12, 0x48, 0x0a, 0x11, 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, 0x56, 0x61, 0x6c,
-	0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x72, 0x61, 0x2e, 0x50, 0x65, 0x72,
-	0x66, 0x6f, 0x72, 0x6d, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65,
-	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x75, 0x74,
-	0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x16,
-	0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74,
-	0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65,
-	0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f,
-	0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70,
-	0x74, 0x79, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x17, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61,
-	0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12,
-	0x13, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61,
-	0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
-	0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x53,
-	0x0a, 0x15, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x42, 0x79, 0x41, 0x70,
-	0x70, 0x6c, 0x69, 0x63, 0x61, 0x6e, 0x74, 0x12, 0x20, 0x2e, 0x72, 0x61, 0x2e, 0x52, 0x65, 0x76,
-	0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x42, 0x79, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61,
-	0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
-	0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74,
-	0x79, 0x22, 0x00, 0x12, 0x47, 0x0a, 0x0f, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72,
-	0x74, 0x42, 0x79, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x2e, 0x72, 0x61, 0x2e, 0x52, 0x65, 0x76, 0x6f,
-	0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x42, 0x79, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65,
-	0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
-	0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x6b, 0x0a, 0x21,
-	0x41, 0x64, 0x6d, 0x69, 0x6e, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x76, 0x65, 0x6c, 0x79,
-	0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74,
-	0x65, 0x12, 0x2c, 0x2e, 0x72, 0x61, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x69, 0x73, 0x74, 0x72,
-	0x61, 0x74, 0x69, 0x76, 0x65, 0x6c, 0x79, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72,
-	0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+	0x65, 0x64, 0x22, 0x9b, 0x01, 0x0a, 0x0f, 0x4e, 0x65, 0x77, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74,
+	0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e,
+	0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x44, 0x12, 0x14,
+	0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x6e,
+	0x61, 0x6d, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x72, 0x65, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x73,
+	0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x65,
+	0x70, 0x6c, 0x61, 0x63, 0x65, 0x73, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x22, 0x0a, 0x0c,
+	0x6c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x45, 0x78, 0x65, 0x6d, 0x70, 0x74, 0x18, 0x04, 0x20, 0x01,
+	0x28, 0x08, 0x52, 0x0c, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x45, 0x78, 0x65, 0x6d, 0x70, 0x74,
+	0x22, 0x4b, 0x0a, 0x14, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x4f, 0x72, 0x64, 0x65,
+	0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x05, 0x6f, 0x72, 0x64, 0x65,
+	0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x4f,
+	0x72, 0x64, 0x65, 0x72, 0x52, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x10, 0x0a, 0x03, 0x63,
+	0x73, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x03, 0x63, 0x73, 0x72, 0x32, 0xad, 0x06,
+	0x0a, 0x15, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x75,
+	0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x12, 0x3b, 0x0a, 0x0f, 0x4e, 0x65, 0x77, 0x52, 0x65,
+	0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x2e, 0x63, 0x6f, 0x72,
+	0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x12,
+	0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x22, 0x00, 0x12, 0x49, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65,
+	0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x2e, 0x72, 0x61, 0x2e,
+	0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65,
+	0x2e, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12,
+	0x48, 0x0a, 0x11, 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72, 0x6d, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x72, 0x61, 0x2e, 0x50, 0x65, 0x72, 0x66, 0x6f, 0x72,
+	0x6d, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x1a, 0x13, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72,
+	0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x16, 0x44, 0x65, 0x61,
+	0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x12, 0x12, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x52, 0x65, 0x67, 0x69, 0x73,
+	0x74, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22,
+	0x00, 0x12, 0x48, 0x0a, 0x17, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x41,
+	0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x13, 0x2e, 0x63,
+	0x6f, 0x72, 0x65, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x53, 0x0a, 0x15, 0x52,
+	0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x42, 0x79, 0x41, 0x70, 0x70, 0x6c, 0x69,
+	0x63, 0x61, 0x6e, 0x74, 0x12, 0x20, 0x2e, 0x72, 0x61, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65,
+	0x43, 0x65, 0x72, 0x74, 0x42, 0x79, 0x41, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x6e, 0x74, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00,
+	0x12, 0x47, 0x0a, 0x0f, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x42, 0x79,
+	0x4b, 0x65, 0x79, 0x12, 0x1a, 0x2e, 0x72, 0x61, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43,
+	0x65, 0x72, 0x74, 0x42, 0x79, 0x4b, 0x65, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
 	0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
-	0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x2e, 0x0a, 0x08, 0x4e, 0x65, 0x77,
-	0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x13, 0x2e, 0x72, 0x61, 0x2e, 0x4e, 0x65, 0x77, 0x4f, 0x72,
-	0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0b, 0x2e, 0x63, 0x6f, 0x72,
-	0x65, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x22, 0x00, 0x12, 0x38, 0x0a, 0x0d, 0x46, 0x69, 0x6e,
-	0x61, 0x6c, 0x69, 0x7a, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x18, 0x2e, 0x72, 0x61, 0x2e,
-	0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71,
-	0x75, 0x65, 0x73, 0x74, 0x1a, 0x0b, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x4f, 0x72, 0x64, 0x65,
-	0x72, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x0c, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x4f,
-	0x43, 0x53, 0x50, 0x12, 0x17, 0x2e, 0x72, 0x61, 0x2e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74,
-	0x65, 0x4f, 0x43, 0x53, 0x50, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x63,
-	0x61, 0x2e, 0x4f, 0x43, 0x53, 0x50, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
-	0x42, 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c,
-	0x65, 0x74, 0x73, 0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f, 0x75, 0x6c, 0x64,
-	0x65, 0x72, 0x2f, 0x72, 0x61, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f,
-	0x74, 0x6f, 0x33,
+	0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x6b, 0x0a, 0x21, 0x41, 0x64, 0x6d,
+	0x69, 0x6e, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69, 0x76, 0x65, 0x6c, 0x79, 0x52, 0x65, 0x76,
+	0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x2c,
+	0x2e, 0x72, 0x61, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x69, 0x73, 0x74, 0x72, 0x61, 0x74, 0x69,
+	0x76, 0x65, 0x6c, 0x79, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x43, 0x65, 0x72, 0x74, 0x69, 0x66,
+	0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67,
+	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45,
+	0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x2e, 0x0a, 0x08, 0x4e, 0x65, 0x77, 0x4f, 0x72, 0x64,
+	0x65, 0x72, 0x12, 0x13, 0x2e, 0x72, 0x61, 0x2e, 0x4e, 0x65, 0x77, 0x4f, 0x72, 0x64, 0x65, 0x72,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0b, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x4f,
+	0x72, 0x64, 0x65, 0x72, 0x22, 0x00, 0x12, 0x38, 0x0a, 0x0d, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69,
+	0x7a, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x18, 0x2e, 0x72, 0x61, 0x2e, 0x46, 0x69, 0x6e,
+	0x61, 0x6c, 0x69, 0x7a, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x1a, 0x0b, 0x2e, 0x63, 0x6f, 0x72, 0x65, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x22, 0x00,
+	0x12, 0x3b, 0x0a, 0x0c, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x4f, 0x43, 0x53, 0x50,
+	0x12, 0x17, 0x2e, 0x72, 0x61, 0x2e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x4f, 0x43,
+	0x53, 0x50, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x63, 0x61, 0x2e, 0x4f,
+	0x43, 0x53, 0x50, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x29, 0x5a,
+	0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x65, 0x74, 0x73,
+	0x65, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74, 0x2f, 0x62, 0x6f, 0x75, 0x6c, 0x64, 0x65, 0x72, 0x2f,
+	0x72, 0x61, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
diff --git a/ra/proto/ra.proto b/ra/proto/ra.proto
index 4f5f1093d..6235ec11c 100644
--- a/ra/proto/ra.proto
+++ b/ra/proto/ra.proto
@@ -70,6 +70,8 @@ message AdministrativelyRevokeCertificateRequest {
 message NewOrderRequest {
   int64 registrationID = 1;
   repeated string names = 2;
+  string replacesSerial = 3;
+  bool limitsExempt = 4;
 }
 
 message FinalizeOrderRequest {
diff --git a/ra/ra.go b/ra/ra.go
index 8e94d8c74..b046e6c26 100644
--- a/ra/ra.go
+++ b/ra/ra.go
@@ -2401,6 +2401,7 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New
 	newOrder := &sapb.NewOrderRequest{
 		RegistrationID: req.RegistrationID,
 		Names:          core.UniqueLowerNames(req.Names),
+		ReplacesSerial: req.ReplacesSerial,
 	}
 
 	if len(newOrder.Names) > ra.maxNames {
@@ -2444,10 +2445,14 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New
 		return existingOrder, nil
 	}
 
-	// Check if there is rate limit space for issuing a certificate.
-	err = ra.checkNewOrderLimits(ctx, newOrder.Names, newOrder.RegistrationID)
-	if err != nil {
-		return nil, err
+	// Renewal orders, indicated by ARI, are exempt from NewOrder rate limits.
+	if !req.LimitsExempt {
+
+		// Check if there is rate limit space for issuing a certificate.
+		err = ra.checkNewOrderLimits(ctx, newOrder.Names, newOrder.RegistrationID)
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	// An order's lifetime is effectively bound by the shortest remaining lifetime
@@ -2520,11 +2525,12 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New
 		missingAuthzNames = append(missingAuthzNames, name)
 	}
 
-	// If the order isn't fully authorized we need to check that the client has
-	// rate limit room for more pending authorizations
-	if len(missingAuthzNames) > 0 {
+	// Renewal orders, indicated by ARI, are exempt from NewOrder rate limits.
+	if len(missingAuthzNames) > 0 && !req.LimitsExempt {
 		pendingAuthzLimits := ra.rlPolicies.PendingAuthorizationsPerAccount()
 		if pendingAuthzLimits.Enabled() {
+			// The order isn't fully authorized we need to check that the client
+			// has rate limit room for more pending authorizations.
 			started := ra.clk.Now()
 			err := ra.checkPendingAuthorizationLimit(ctx, newOrder.RegistrationID, pendingAuthzLimits)
 			elapsed := ra.clk.Since(started)
diff --git a/va/caa.go b/va/caa.go
index 8dc6331a3..374fa3dea 100644
--- a/va/caa.go
+++ b/va/caa.go
@@ -67,9 +67,11 @@ func (va *ValidationAuthorityImpl) IsCAAValid(ctx context.Context, req *vapb.IsC
 	}
 
 	checkResult := "success"
-	prob := va.checkCAA(ctx, acmeID, params)
+	err := va.checkCAA(ctx, acmeID, params)
 	localCheckLatency := time.Since(checkStartTime)
-	if prob != nil {
+	var prob *probs.ProblemDetails
+	if err != nil {
+		prob = detailedError(err)
 		logEvent.Error = prob.Error()
 		prob.Detail = fmt.Sprintf("While processing CAA for %s: %s", req.Domain, prob.Detail)
 		checkResult = "failure"
@@ -83,7 +85,6 @@ func (va *ValidationAuthorityImpl) IsCAAValid(ctx context.Context, req *vapb.IsC
 					req.Domain,
 					req.AccountURIID,
 					string(validationMethod),
-					prob,
 					remoteCAAResults)
 			}()
 		} else if features.Get().EnforceMultiCAA {
@@ -91,7 +92,6 @@ func (va *ValidationAuthorityImpl) IsCAAValid(ctx context.Context, req *vapb.IsC
 				req.Domain,
 				req.AccountURIID,
 				string(validationMethod),
-				prob,
 				remoteCAAResults)
 
 			// If the remote result was a non-nil problem then fail the CAA check
@@ -118,6 +118,10 @@ func (va *ValidationAuthorityImpl) IsCAAValid(ctx context.Context, req *vapb.IsC
 	va.log.AuditObject("CAA check result", logEvent)
 
 	if prob != nil {
+		// The ProblemDetails will be serialized through gRPC, which requires UTF-8.
+		// It will also later be serialized in JSON, which defaults to UTF-8. Make
+		// sure it is UTF-8 clean now.
+		prob = filterProblemDetails(prob)
 		return &vapb.IsCAAValidResponse{Problem: &corepb.ProblemDetails{
 			ProblemType: string(prob.Type),
 			Detail:      replaceInvalidUTF8([]byte(prob.Detail)),
@@ -148,7 +152,6 @@ func (va *ValidationAuthorityImpl) processRemoteCAAResults(
 	domain string,
 	acctID int64,
 	challengeType string,
-	primaryResult *probs.ProblemDetails,
 	remoteResultsChan <-chan *remoteVAResult) *probs.ProblemDetails {
 
 	state := "failure"
@@ -210,7 +213,6 @@ func (va *ValidationAuthorityImpl) processRemoteCAAResults(
 		domain,
 		acctID,
 		challengeType,
-		primaryResult,
 		remoteResults)
 
 	// Based on the threshold of good/bad return nil or a problem.
@@ -220,12 +222,7 @@ func (va *ValidationAuthorityImpl) processRemoteCAAResults(
 	} else if bad > va.maxRemoteFailures {
 		modifiedProblem := *firstProb
 		modifiedProblem.Detail = "During secondary CAA checking: " + firstProb.Detail
-		// If the primary result was OK and there were more failures than the allowed
-		// threshold increment a stat that indicates this overall validation will have
-		// failed.
-		if primaryResult == nil {
-			va.metrics.prospectiveRemoteCAACheckFailures.Inc()
-		}
+		va.metrics.prospectiveRemoteCAACheckFailures.Inc()
 		return &modifiedProblem
 	}
 
@@ -282,20 +279,20 @@ func (va *ValidationAuthorityImpl) performRemoteCAACheck(
 func (va *ValidationAuthorityImpl) checkCAA(
 	ctx context.Context,
 	identifier identifier.ACMEIdentifier,
-	params *caaParams) *probs.ProblemDetails {
+	params *caaParams) error {
 	if core.IsAnyNilOrZero(params, params.validationMethod, params.accountURIID) {
 		return probs.ServerInternal("expected validationMethod or accountURIID not provided to checkCAA")
 	}
 
 	foundAt, valid, response, err := va.checkCAARecords(ctx, identifier, params)
 	if err != nil {
-		return probs.DNS(err.Error())
+		return berrors.DNSError("%s", err)
 	}
 
 	va.log.AuditInfof("Checked CAA records for %s, [Present: %t, Account ID: %d, Challenge: %s, Valid for issuance: %t, Found at: %q] Response=%q",
 		identifier.Value, foundAt != "", params.accountURIID, params.validationMethod, valid, foundAt, response)
 	if !valid {
-		return probs.CAA(fmt.Sprintf("CAA record for %s prevents issuance", foundAt))
+		return berrors.CAAError("CAA record for %s prevents issuance", foundAt)
 	}
 	return nil
 }
diff --git a/va/dns.go b/va/dns.go
index baf3c57e4..93ce38511 100644
--- a/va/dns.go
+++ b/va/dns.go
@@ -12,7 +12,6 @@ import (
 	"github.com/letsencrypt/boulder/core"
 	berrors "github.com/letsencrypt/boulder/errors"
 	"github.com/letsencrypt/boulder/identifier"
-	"github.com/letsencrypt/boulder/probs"
 )
 
 // getAddr will query for all A/AAAA records associated with hostname and return
@@ -49,10 +48,10 @@ func availableAddresses(allAddrs []net.IP) (v4 []net.IP, v6 []net.IP) {
 	return
 }
 
-func (va *ValidationAuthorityImpl) validateDNS01(ctx context.Context, ident identifier.ACMEIdentifier, challenge core.Challenge) ([]core.ValidationRecord, *probs.ProblemDetails) {
+func (va *ValidationAuthorityImpl) validateDNS01(ctx context.Context, ident identifier.ACMEIdentifier, challenge core.Challenge) ([]core.ValidationRecord, error) {
 	if ident.Type != identifier.DNS {
 		va.log.Infof("Identifier type for DNS challenge was not DNS: %s", ident)
-		return nil, probs.Malformed("Identifier type for DNS was not itself DNS")
+		return nil, berrors.MalformedError("Identifier type for DNS was not itself DNS")
 	}
 
 	// Compute the digest of the key authorization file
@@ -64,14 +63,14 @@ func (va *ValidationAuthorityImpl) validateDNS01(ctx context.Context, ident iden
 	challengeSubdomain := fmt.Sprintf("%s.%s", core.DNSPrefix, ident.Value)
 	txts, resolvers, err := va.dnsClient.LookupTXT(ctx, challengeSubdomain)
 	if err != nil {
-		return nil, probs.DNS(err.Error())
+		return nil, berrors.DNSError("%s", err)
 	}
 
 	// If there weren't any TXT records return a distinct error message to allow
 	// troubleshooters to differentiate between no TXT records and
 	// invalid/incorrect TXT records.
 	if len(txts) == 0 {
-		return nil, probs.Unauthorized(fmt.Sprintf("No TXT record found at %s", challengeSubdomain))
+		return nil, berrors.UnauthorizedError("No TXT record found at %s", challengeSubdomain)
 	}
 
 	for _, element := range txts {
@@ -89,6 +88,6 @@ func (va *ValidationAuthorityImpl) validateDNS01(ctx context.Context, ident iden
 	if len(txts) > 1 {
 		andMore = fmt.Sprintf(" (and %d more)", len(txts)-1)
 	}
-	return nil, probs.Unauthorized(fmt.Sprintf("Incorrect TXT record %q%s found at %s",
-		invalidRecord, andMore, challengeSubdomain))
+	return nil, berrors.UnauthorizedError("Incorrect TXT record %q%s found at %s",
+		invalidRecord, andMore, challengeSubdomain)
 }
diff --git a/va/http.go b/va/http.go
index 8700b2a03..a85c0b715 100644
--- a/va/http.go
+++ b/va/http.go
@@ -19,7 +19,6 @@ import (
 	berrors "github.com/letsencrypt/boulder/errors"
 	"github.com/letsencrypt/boulder/iana"
 	"github.com/letsencrypt/boulder/identifier"
-	"github.com/letsencrypt/boulder/probs"
 )
 
 const (
@@ -395,11 +394,10 @@ func (va *ValidationAuthorityImpl) setupHTTPValidation(
 func (va *ValidationAuthorityImpl) fetchHTTP(
 	ctx context.Context,
 	host string,
-	path string) ([]byte, []core.ValidationRecord, *probs.ProblemDetails) {
+	path string) ([]byte, []core.ValidationRecord, error) {
 	body, records, err := va.processHTTPValidation(ctx, host, path)
 	if err != nil {
-		// Use detailedError to convert the error into a problem
-		return body, records, detailedError(err)
+		return body, records, err
 	}
 	return body, records, nil
 }
@@ -653,24 +651,24 @@ func (va *ValidationAuthorityImpl) processHTTPValidation(
 	return body, records, nil
 }
 
-func (va *ValidationAuthorityImpl) validateHTTP01(ctx context.Context, ident identifier.ACMEIdentifier, challenge core.Challenge) ([]core.ValidationRecord, *probs.ProblemDetails) {
+func (va *ValidationAuthorityImpl) validateHTTP01(ctx context.Context, ident identifier.ACMEIdentifier, challenge core.Challenge) ([]core.ValidationRecord, error) {
 	if ident.Type != identifier.DNS {
 		va.log.Infof("Got non-DNS identifier for HTTP validation: %s", ident)
-		return nil, probs.Malformed("Identifier type for HTTP validation was not DNS")
+		return nil, berrors.MalformedError("Identifier type for HTTP validation was not DNS")
 	}
 
 	// Perform the fetch
 	path := fmt.Sprintf(".well-known/acme-challenge/%s", challenge.Token)
-	body, validationRecords, prob := va.fetchHTTP(ctx, ident.Value, "/"+path)
-	if prob != nil {
-		return validationRecords, prob
+	body, validationRecords, err := va.fetchHTTP(ctx, ident.Value, "/"+path)
+	if err != nil {
+		return validationRecords, err
 	}
 	payload := strings.TrimRightFunc(string(body), unicode.IsSpace)
 
 	if payload != challenge.ProvidedKeyAuthorization {
-		problem := probs.Unauthorized(fmt.Sprintf("The key authorization file from the server did not match this challenge. Expected %q (got %q)",
-			challenge.ProvidedKeyAuthorization, payload))
-		va.log.Infof("%s for %s", problem.Detail, ident)
+		problem := berrors.UnauthorizedError("The key authorization file from the server did not match this challenge. Expected %q (got %q)",
+			challenge.ProvidedKeyAuthorization, payload)
+		va.log.Infof("%s for %s", problem, ident)
 		return validationRecords, problem
 	}
 
diff --git a/va/tlsalpn.go b/va/tlsalpn.go
index f73e8e9e2..052a67ba1 100644
--- a/va/tlsalpn.go
+++ b/va/tlsalpn.go
@@ -17,8 +17,8 @@ import (
 	"strings"
 
 	"github.com/letsencrypt/boulder/core"
+	berrors "github.com/letsencrypt/boulder/errors"
 	"github.com/letsencrypt/boulder/identifier"
-	"github.com/letsencrypt/boulder/probs"
 )
 
 const (
@@ -58,7 +58,7 @@ func certAltNames(cert *x509.Certificate) []string {
 
 func (va *ValidationAuthorityImpl) tryGetChallengeCert(ctx context.Context,
 	identifier identifier.ACMEIdentifier, challenge core.Challenge,
-	tlsConfig *tls.Config) (*x509.Certificate, *tls.ConnectionState, []core.ValidationRecord, *probs.ProblemDetails) {
+	tlsConfig *tls.Config) (*x509.Certificate, *tls.ConnectionState, []core.ValidationRecord, error) {
 
 	allAddrs, resolvers, err := va.getAddrs(ctx, identifier.Value)
 	validationRecords := []core.ValidationRecord{
@@ -70,7 +70,7 @@ func (va *ValidationAuthorityImpl) tryGetChallengeCert(ctx context.Context,
 		},
 	}
 	if err != nil {
-		return nil, nil, validationRecords, detailedError(err)
+		return nil, nil, validationRecords, err
 	}
 	thisRecord := &validationRecords[0]
 
@@ -80,7 +80,7 @@ func (va *ValidationAuthorityImpl) tryGetChallengeCert(ctx context.Context,
 
 	// This shouldn't happen, but be defensive about it anyway
 	if len(addresses) < 1 {
-		return nil, nil, validationRecords, probs.Malformed("no IP addresses found for %q", identifier.Value)
+		return nil, nil, validationRecords, berrors.MalformedError("no IP addresses found for %q", identifier.Value)
 	}
 
 	// If there is at least one IPv6 address then try it first
@@ -88,11 +88,11 @@ func (va *ValidationAuthorityImpl) tryGetChallengeCert(ctx context.Context,
 		address := net.JoinHostPort(v6[0].String(), thisRecord.Port)
 		thisRecord.AddressUsed = v6[0]
 
-		cert, cs, prob := va.getChallengeCert(ctx, address, identifier, challenge, tlsConfig)
+		cert, cs, err := va.getChallengeCert(ctx, address, identifier, challenge, tlsConfig)
 
 		// If there is no problem, return immediately
-		if prob == nil {
-			return cert, cs, validationRecords, prob
+		if err == nil {
+			return cert, cs, validationRecords, nil
 		}
 
 		// Otherwise, we note that we tried an address and fall back to trying IPv4
@@ -103,20 +103,20 @@ func (va *ValidationAuthorityImpl) tryGetChallengeCert(ctx context.Context,
 	// If there are no IPv4 addresses and we tried an IPv6 address return
 	// an error - there's nothing left to try
 	if len(v4) == 0 && len(thisRecord.AddressesTried) > 0 {
-		return nil, nil, validationRecords, probs.Malformed("Unable to contact %q at %q, no IPv4 addresses to try as fallback",
+		return nil, nil, validationRecords, berrors.MalformedError("Unable to contact %q at %q, no IPv4 addresses to try as fallback",
 			thisRecord.Hostname, thisRecord.AddressesTried[0])
 	} else if len(v4) == 0 && len(thisRecord.AddressesTried) == 0 {
 		// It shouldn't be possible that there are no IPv4 addresses and no previous
 		// attempts at an IPv6 address connection but be defensive about it anyway
-		return nil, nil, validationRecords, probs.Malformed("No IP addresses found for %q", thisRecord.Hostname)
+		return nil, nil, validationRecords, berrors.MalformedError("No IP addresses found for %q", thisRecord.Hostname)
 	}
 
 	// Otherwise if there are no IPv6 addresses, or there was an error
 	// talking to the first IPv6 address, try the first IPv4 address
 	thisRecord.AddressUsed = v4[0]
-	cert, cs, prob := va.getChallengeCert(ctx, net.JoinHostPort(v4[0].String(), thisRecord.Port),
+	cert, cs, err := va.getChallengeCert(ctx, net.JoinHostPort(v4[0].String(), thisRecord.Port),
 		identifier, challenge, tlsConfig)
-	return cert, cs, validationRecords, prob
+	return cert, cs, validationRecords, err
 }
 
 func (va *ValidationAuthorityImpl) getChallengeCert(
@@ -125,7 +125,7 @@ func (va *ValidationAuthorityImpl) getChallengeCert(
 	identifier identifier.ACMEIdentifier,
 	challenge core.Challenge,
 	config *tls.Config,
-) (*x509.Certificate, *tls.ConnectionState, *probs.ProblemDetails) {
+) (*x509.Certificate, *tls.ConnectionState, error) {
 	va.log.Info(fmt.Sprintf("%s [%s] Attempting to validate for %s %s", challenge.Type, identifier, hostPort, config.ServerName))
 	// We expect a self-signed challenge certificate, do not verify it here.
 	config.InsecureSkipVerify = true
@@ -142,9 +142,9 @@ func (va *ValidationAuthorityImpl) getChallengeCert(
 			// Wrap the validation error and the IP of the remote host in an
 			// IPError so we can display the IP in the problem details returned
 			// to the client.
-			return nil, nil, detailedError(ipError{net.ParseIP(host), err})
+			return nil, nil, ipError{net.ParseIP(host), err}
 		}
-		return nil, nil, detailedError(err)
+		return nil, nil, err
 	}
 	defer conn.Close()
 
@@ -153,7 +153,7 @@ func (va *ValidationAuthorityImpl) getChallengeCert(
 	certs := cs.PeerCertificates
 	if len(certs) == 0 {
 		va.log.Infof("%s challenge for %s resulted in no certificates", challenge.Type, identifier.Value)
-		return nil, nil, probs.Unauthorized(fmt.Sprintf("No certs presented for %s challenge", challenge.Type))
+		return nil, nil, berrors.UnauthorizedError("No certs presented for %s challenge", challenge.Type)
 	}
 	for i, cert := range certs {
 		va.log.AuditInfof("%s challenge for %s received certificate (%d of %d): cert=[%s]",
@@ -207,10 +207,10 @@ func checkAcceptableExtensions(exts []pkix.Extension, requiredOIDs []asn1.Object
 	return nil
 }
 
-func (va *ValidationAuthorityImpl) validateTLSALPN01(ctx context.Context, identifier identifier.ACMEIdentifier, challenge core.Challenge) ([]core.ValidationRecord, *probs.ProblemDetails) {
+func (va *ValidationAuthorityImpl) validateTLSALPN01(ctx context.Context, identifier identifier.ACMEIdentifier, challenge core.Challenge) ([]core.ValidationRecord, error) {
 	if identifier.Type != "dns" {
 		va.log.Info(fmt.Sprintf("Identifier type for TLS-ALPN-01 was not DNS: %s", identifier))
-		return nil, probs.Malformed("Identifier type for TLS-ALPN-01 was not DNS")
+		return nil, berrors.MalformedError("Identifier type for TLS-ALPN-01 was not DNS")
 	}
 
 	cert, cs, validationRecords, problem := va.tryGetChallengeCert(ctx, identifier, challenge, &tls.Config{
@@ -223,23 +223,19 @@ func (va *ValidationAuthorityImpl) validateTLSALPN01(ctx context.Context, identi
 	}
 
 	if cs.NegotiatedProtocol != ACMETLS1Protocol {
-		errText := fmt.Sprintf(
+		return validationRecords, berrors.UnauthorizedError(
 			"Cannot negotiate ALPN protocol %q for %s challenge",
 			ACMETLS1Protocol,
-			core.ChallengeTypeTLSALPN01,
-		)
-		return validationRecords, probs.Unauthorized(errText)
+			core.ChallengeTypeTLSALPN01)
 	}
 
-	badCertErr := func(msg string) *probs.ProblemDetails {
+	badCertErr := func(msg string) error {
 		hostPort := net.JoinHostPort(validationRecords[0].AddressUsed.String(), validationRecords[0].Port)
 
-		return probs.Unauthorized(fmt.Sprintf(
+		return berrors.UnauthorizedError(
 			"Incorrect validation certificate for %s challenge. "+
-				"Requested %s from %s. "+
-				"%s",
-			challenge.Type, identifier.Value, hostPort, msg,
-		))
+				"Requested %s from %s. %s",
+			challenge.Type, identifier.Value, hostPort, msg)
 	}
 
 	// The certificate must be self-signed.
diff --git a/va/va.go b/va/va.go
index 103896574..1de02588d 100644
--- a/va/va.go
+++ b/va/va.go
@@ -403,6 +403,12 @@ func detailedError(err error) *probs.ProblemDetails {
 	if errors.Is(err, berrors.DNS) {
 		return probs.DNS(err.Error())
 	}
+	if errors.Is(err, berrors.Malformed) {
+		return probs.Malformed(err.Error())
+	}
+	if errors.Is(err, berrors.CAA) {
+		return probs.CAA(err.Error())
+	}
 
 	if h2SettingsFrameErrRegex.MatchString(err.Error()) {
 		return probs.Connection("Server is speaking HTTP/2 over HTTP")
@@ -419,7 +425,7 @@ func (va *ValidationAuthorityImpl) validate(
 	identifier identifier.ACMEIdentifier,
 	regid int64,
 	challenge core.Challenge,
-) ([]core.ValidationRecord, *probs.ProblemDetails) {
+) ([]core.ValidationRecord, error) {
 
 	// If the identifier is a wildcard domain we need to validate the base
 	// domain by removing the "*." wildcard prefix. We create a separate
@@ -432,7 +438,7 @@ func (va *ValidationAuthorityImpl) validate(
 
 	// Create this channel outside of the feature-conditional block so that it is
 	// also in scope for the matching block below.
-	ch := make(chan *probs.ProblemDetails, 1)
+	ch := make(chan error, 1)
 	if !features.Get().CAAAfterValidation {
 		// va.checkCAA accepts wildcard identifiers and handles them appropriately so
 		// we can dispatch `checkCAA` with the provided `identifier` instead of
@@ -447,20 +453,15 @@ func (va *ValidationAuthorityImpl) validate(
 	}
 
 	// TODO(#1292): send into another goroutine
-	validationRecords, prob := va.validateChallenge(ctx, baseIdentifier, challenge)
-	if prob != nil {
-		// The ProblemDetails will be serialized through gRPC, which requires UTF-8.
-		// It will also later be serialized in JSON, which defaults to UTF-8. Make
-		// sure it is UTF-8 clean now.
-		prob = filterProblemDetails(prob)
-
-		return validationRecords, prob
+	validationRecords, err := va.validateChallenge(ctx, baseIdentifier, challenge)
+	if err != nil {
+		return validationRecords, err
 	}
 
 	if !features.Get().CAAAfterValidation {
 		for i := 0; i < cap(ch); i++ {
-			if extraProblem := <-ch; extraProblem != nil {
-				return validationRecords, extraProblem
+			if extraErr := <-ch; extraErr != nil {
+				return validationRecords, extraErr
 			}
 		}
 	} else {
@@ -468,19 +469,19 @@ func (va *ValidationAuthorityImpl) validate(
 			accountURIID:     regid,
 			validationMethod: challenge.Type,
 		}
-		prob := va.checkCAA(ctx, identifier, params)
-		if prob != nil {
-			return validationRecords, prob
+		err := va.checkCAA(ctx, identifier, params)
+		if err != nil {
+			return validationRecords, err
 		}
 	}
 
 	return validationRecords, nil
 }
 
-func (va *ValidationAuthorityImpl) validateChallenge(ctx context.Context, identifier identifier.ACMEIdentifier, challenge core.Challenge) ([]core.ValidationRecord, *probs.ProblemDetails) {
+func (va *ValidationAuthorityImpl) validateChallenge(ctx context.Context, identifier identifier.ACMEIdentifier, challenge core.Challenge) ([]core.ValidationRecord, error) {
 	err := challenge.CheckConsistencyForValidation()
 	if err != nil {
-		return nil, probs.Malformed("Challenge failed consistency check: %s", err)
+		return nil, berrors.MalformedError("Challenge failed consistency check: %s", err)
 	}
 	switch challenge.Type {
 	case core.ChallengeTypeHTTP01:
@@ -490,7 +491,7 @@ func (va *ValidationAuthorityImpl) validateChallenge(ctx context.Context, identi
 	case core.ChallengeTypeTLSALPN01:
 		return va.validateTLSALPN01(ctx, identifier, challenge)
 	}
-	return nil, probs.Malformed("invalid challenge type %s", challenge.Type)
+	return nil, berrors.MalformedError("invalid challenge type %s", challenge.Type)
 }
 
 // performRemoteValidation calls `PerformValidation` for each of the configured
@@ -559,7 +560,6 @@ func (va *ValidationAuthorityImpl) processRemoteValidationResults(
 	domain string,
 	acctID int64,
 	challengeType string,
-	primaryResult *probs.ProblemDetails,
 	remoteResultsChan <-chan *remoteVAResult) *probs.ProblemDetails {
 
 	state := "failure"
@@ -620,7 +620,6 @@ func (va *ValidationAuthorityImpl) processRemoteValidationResults(
 		domain,
 		acctID,
 		challengeType,
-		primaryResult,
 		remoteResults)
 
 	// Based on the threshold of good/bad return nil or a problem.
@@ -630,12 +629,7 @@ func (va *ValidationAuthorityImpl) processRemoteValidationResults(
 	} else if bad > va.maxRemoteFailures {
 		modifiedProblem := *firstProb
 		modifiedProblem.Detail = "During secondary validation: " + firstProb.Detail
-		// If the primary result was OK and there were more failures than the allowed
-		// threshold increment a stat that indicates this overall validation will have
-		// failed.
-		if primaryResult == nil {
-			va.metrics.prospectiveRemoteValidationFailures.Inc()
-		}
+		va.metrics.prospectiveRemoteValidationFailures.Inc()
 		return &modifiedProblem
 	}
 
@@ -652,25 +646,19 @@ func (va *ValidationAuthorityImpl) logRemoteDifferentials(
 	domain string,
 	acctID int64,
 	challengeType string,
-	primaryResult *probs.ProblemDetails,
 	remoteResults []*remoteVAResult) {
 
 	var successes, failures []*remoteVAResult
 
-	allEqual := true
 	for _, result := range remoteResults {
-		if result.Problem != primaryResult {
-			allEqual = false
-		}
-		if result.Problem == nil {
-			successes = append(successes, result)
-		} else {
+		if result.Problem != nil {
 			failures = append(failures, result)
+		} else {
+			successes = append(successes, result)
 		}
 	}
-	if allEqual {
-		// There's no point logging a differential line if the primary VA and remote
-		// VAs all agree.
+	if len(failures) == 0 {
+		// There's no point logging a differential line if everything succeeded.
 		return
 	}
 
@@ -678,14 +666,12 @@ func (va *ValidationAuthorityImpl) logRemoteDifferentials(
 		Domain          string
 		AccountID       int64
 		ChallengeType   string
-		PrimaryResult   *probs.ProblemDetails
 		RemoteSuccesses int
 		RemoteFailures  []*remoteVAResult
 	}{
 		Domain:          domain,
 		AccountID:       acctID,
 		ChallengeType:   challengeType,
-		PrimaryResult:   primaryResult,
 		RemoteSuccesses: len(successes),
 		RemoteFailures:  failures,
 	}
@@ -732,35 +718,33 @@ func (va *ValidationAuthorityImpl) PerformValidation(ctx context.Context, req *v
 
 	challenge, err := bgrpc.PBToChallenge(req.Challenge)
 	if err != nil {
-		return nil, probs.ServerInternal("Challenge failed to deserialize")
+		return nil, errors.New("Challenge failed to deserialize")
 	}
 
-	records, prob := va.validate(ctx, identifier.DNSIdentifier(req.Domain), req.Authz.RegID, challenge)
+	records, err := va.validate(ctx, identifier.DNSIdentifier(req.Domain), req.Authz.RegID, challenge)
 	challenge.ValidationRecord = records
 	localValidationLatency := time.Since(vStart)
 
 	// Check for malformed ValidationRecords
-	if !challenge.RecordsSane() && prob == nil {
-		prob = probs.ServerInternal("Records for validation failed sanity check")
+	if !challenge.RecordsSane() && err == nil {
+		err = errors.New("Records for validation failed sanity check")
 	}
 
 	var problemType string
-	if prob != nil {
+	var prob *probs.ProblemDetails
+	if err != nil {
+		prob = detailedError(err)
 		problemType = string(prob.Type)
 		challenge.Status = core.StatusInvalid
 		challenge.Error = prob
 		logEvent.Error = prob.Error()
 	} else if remoteResults != nil {
 		if !features.Get().EnforceMultiVA && features.Get().MultiVAFullResults {
-			// If we're not going to enforce multi VA but we are logging the
-			// differentials then collect and log the remote results in a separate go
-			// routine to avoid blocking the primary VA.
 			go func() {
 				_ = va.processRemoteValidationResults(
 					req.Domain,
 					req.Authz.RegID,
 					string(challenge.Type),
-					prob,
 					remoteResults)
 			}()
 			// Since prob was nil and we're not enforcing the results from
@@ -773,7 +757,6 @@ func (va *ValidationAuthorityImpl) PerformValidation(ctx context.Context, req *v
 				req.Domain,
 				req.Authz.RegID,
 				string(challenge.Type),
-				prob,
 				remoteResults)
 
 			// If the remote result was a non-nil problem then fail the validation
@@ -810,5 +793,9 @@ func (va *ValidationAuthorityImpl) PerformValidation(ctx context.Context, req *v
 
 	va.log.AuditObject("Validation result", logEvent)
 
+	// The ProblemDetails will be serialized through gRPC, which requires UTF-8.
+	// It will also later be serialized in JSON, which defaults to UTF-8. Make
+	// sure it is UTF-8 clean now.
+	prob = filterProblemDetails(prob)
 	return bgrpc.ValidationResultToPB(records, prob)
 }



Test patch:
diff --git a/ca/ca_test.go b/ca/ca_test.go
index c3f08f927..95677734c 100644
--- a/ca/ca_test.go
+++ b/ca/ca_test.go
@@ -185,7 +185,7 @@ func setup(t *testing.T) *testCtx {
 		UseForECDSALeaves: true,
 		IssuerURL:         "http://not-example.com/issuer-url",
 		OCSPURL:           "http://not-example.com/ocsp",
-		CRLURL:            "http://not-example.com/crl",
+		CRLURLBase:        "http://not-example.com/crl/",
 		Location:          issuance.IssuerLoc{File: ecdsaIntKey, CertFile: ecdsaIntCert},
 	}, fc)
 	test.AssertNotError(t, err, "Couldn't load test issuer")
@@ -195,7 +195,7 @@ func setup(t *testing.T) *testCtx {
 		UseForECDSALeaves: true,
 		IssuerURL:         "http://not-example.com/issuer-url",
 		OCSPURL:           "http://not-example.com/ocsp",
-		CRLURL:            "http://not-example.com/crl",
+		CRLURLBase:        "http://not-example.com/crl/",
 		Location:          issuance.IssuerLoc{File: rsaIntKey, CertFile: rsaIntCert},
 	}, fc)
 	test.AssertNotError(t, err, "Couldn't load test issuer")
@@ -234,8 +234,10 @@ func setup(t *testing.T) *testCtx {
 
 	crl, err := NewCRLImpl(
 		boulderIssuers,
-		boulderProfile.Lints,
-		time.Hour,
+		issuance.CRLProfileConfig{
+			ValidityInterval: config.Duration{Duration: 216 * time.Hour},
+			MaxBackdate:      config.Duration{Duration: time.Hour},
+		},
 		"http://c.boulder.test",
 		100,
 		blog.NewMock(),
@@ -693,8 +695,8 @@ func issueCertificateSubTestUnknownExtension(t *testing.T, i *TestCertificateIss
 	test.AssertMetricWithLabelsEquals(t, i.ca.signatureCount, prometheus.Labels{"purpose": "precertificate"}, 1)
 
 	// NOTE: The hard-coded value here will have to change over time as Boulder
-	// adds new (unrequested) extensions to certificates.
-	expectedExtensionCount := 10
+	// adds or removes (unrequested/default) extensions in certificates.
+	expectedExtensionCount := 9
 	test.AssertEquals(t, len(i.cert.Extensions), expectedExtensionCount)
 }
 
diff --git a/ca/crl_test.go b/ca/crl_test.go
index 1f21edf7a..9ad001e8c 100644
--- a/ca/crl_test.go
+++ b/ca/crl_test.go
@@ -2,11 +2,10 @@ package ca
 
 import (
 	"crypto/x509"
+	"fmt"
 	"io"
 	"testing"
-	"time"
 
-	"github.com/jmhodges/clock"
 	"google.golang.org/grpc"
 	"google.golang.org/protobuf/types/known/timestamppb"
 
@@ -73,13 +72,13 @@ func TestGenerateCRL(t *testing.T) {
 	go func() {
 		errs <- crli.GenerateCRL(mockGenerateCRLBidiStream{input: ins, output: nil})
 	}()
-	// TODO(#7100) Change usage of time.Now() to fakeclock.Now()
-	now := time.Now()
+	now := testCtx.fc.Now()
 	ins <- &capb.GenerateCRLRequest{
 		Payload: &capb.GenerateCRLRequest_Metadata{
 			Metadata: &capb.CRLMetadata{
 				IssuerNameID: 1,
 				ThisUpdate:   timestamppb.New(now),
+				ShardIdx:     1,
 			},
 		},
 	}
@@ -98,6 +97,7 @@ func TestGenerateCRL(t *testing.T) {
 			Metadata: &capb.CRLMetadata{
 				IssuerNameID: int64(testCtx.boulderIssuers[0].NameID()),
 				ThisUpdate:   timestamppb.New(now),
+				ShardIdx:     1,
 			},
 		},
 	}
@@ -106,11 +106,13 @@ func TestGenerateCRL(t *testing.T) {
 			Metadata: &capb.CRLMetadata{
 				IssuerNameID: int64(testCtx.boulderIssuers[0].NameID()),
 				ThisUpdate:   timestamppb.New(now),
+				ShardIdx:     1,
 			},
 		},
 	}
 	close(ins)
 	err = <-errs
+	fmt.Println("done waiting for error")
 	test.AssertError(t, err, "can't generate CRL with duplicate metadata")
 	test.AssertContains(t, err.Error(), "got more than one metadata message")
 
@@ -168,14 +170,12 @@ func TestGenerateCRL(t *testing.T) {
 		}
 		close(done)
 	}()
-	fc := clock.NewFake()
-	fc.Set(time.Date(2020, time.October, 10, 23, 17, 0, 0, time.UTC))
-	now = fc.Now()
 	ins <- &capb.GenerateCRLRequest{
 		Payload: &capb.GenerateCRLRequest_Metadata{
 			Metadata: &capb.CRLMetadata{
 				IssuerNameID: int64(testCtx.boulderIssuers[0].NameID()),
 				ThisUpdate:   timestamppb.New(now),
+				ShardIdx:     1,
 			},
 		},
 	}
@@ -212,6 +212,7 @@ func TestGenerateCRL(t *testing.T) {
 			Metadata: &capb.CRLMetadata{
 				IssuerNameID: int64(testCtx.boulderIssuers[0].NameID()),
 				ThisUpdate:   timestamppb.New(now),
+				ShardIdx:     1,
 			},
 		},
 	}
diff --git a/crl/checker/checker_test.go b/crl/checker/checker_test.go
index d9f192dbf..346e2aef0 100644
--- a/crl/checker/checker_test.go
+++ b/crl/checker/checker_test.go
@@ -60,9 +60,9 @@ func TestDiff(t *testing.T) {
 				File:     "../../test/hierarchy/int-e1.key.pem",
 				CertFile: "../../test/hierarchy/int-e1.cert.pem",
 			},
-			IssuerURL: "http://not-example.com/issuer-url",
-			OCSPURL:   "http://not-example.com/ocsp",
-			CRLURL:    "http://not-example.com/crl",
+			IssuerURL:  "http://not-example.com/issuer-url",
+			OCSPURL:    "http://not-example.com/ocsp",
+			CRLURLBase: "http://not-example.com/crl/",
 		}, clock.NewFake())
 	test.AssertNotError(t, err, "loading test issuer")
 
diff --git a/crl/storer/storer_test.go b/crl/storer/storer_test.go
index c04c2b9cd..a07d6cc12 100644
--- a/crl/storer/storer_test.go
+++ b/crl/storer/storer_test.go
@@ -63,9 +63,9 @@ func setupTestUploadCRL(t *testing.T) (*crlStorer, *issuance.Issuer) {
 				File:     "../../test/hierarchy/int-e1.key.pem",
 				CertFile: "../../test/hierarchy/int-e1.cert.pem",
 			},
-			IssuerURL: "http://not-example.com/issuer-url",
-			OCSPURL:   "http://not-example.com/ocsp",
-			CRLURL:    "http://not-example.com/crl",
+			IssuerURL:  "http://not-example.com/issuer-url",
+			OCSPURL:    "http://not-example.com/ocsp",
+			CRLURLBase: "http://not-example.com/crl/",
 		}, clock.NewFake())
 	test.AssertNotError(t, err, "loading fake ECDSA issuer cert")
 
diff --git a/issuance/cert_test.go b/issuance/cert_test.go
index e5e81d89f..66512b764 100644
--- a/issuance/cert_test.go
+++ b/issuance/cert_test.go
@@ -296,38 +296,29 @@ func TestRequestValid(t *testing.T) {
 }
 
 func TestGenerateTemplate(t *testing.T) {
-	tests := []struct {
-		name             string
-		issuer           *Issuer
-		expectedTemplate *x509.Certificate
-	}{
-		{
-			name: "crl url",
-			issuer: &Issuer{
-				crlURL: "crl-url",
-				sigAlg: x509.SHA256WithRSA,
-			},
-			expectedTemplate: &x509.Certificate{
-				BasicConstraintsValid: true,
-				SignatureAlgorithm:    x509.SHA256WithRSA,
-				ExtKeyUsage: []x509.ExtKeyUsage{
-					x509.ExtKeyUsageServerAuth,
-					x509.ExtKeyUsageClientAuth,
-				},
-				IssuingCertificateURL: []string{""},
-				OCSPServer:            []string{""},
-				CRLDistributionPoints: []string{"crl-url"},
-				PolicyIdentifiers:     []asn1.ObjectIdentifier{{2, 23, 140, 1, 2, 1}},
-			},
-		},
+	issuer := &Issuer{
+		ocspURL:    "http://ocsp",
+		issuerURL:  "http://issuer",
+		crlURLBase: "http://crl/",
+		sigAlg:     x509.SHA256WithRSA,
 	}
 
-	for _, tc := range tests {
-		t.Run(tc.name, func(t *testing.T) {
-			template := tc.issuer.generateTemplate()
-			test.AssertDeepEquals(t, *template, *tc.expectedTemplate)
-		})
+	actual := issuer.generateTemplate()
+
+	expected := &x509.Certificate{
+		BasicConstraintsValid: true,
+		SignatureAlgorithm:    x509.SHA256WithRSA,
+		ExtKeyUsage: []x509.ExtKeyUsage{
+			x509.ExtKeyUsageServerAuth,
+			x509.ExtKeyUsageClientAuth,
+		},
+		IssuingCertificateURL: []string{"http://issuer"},
+		OCSPServer:            []string{"http://ocsp"},
+		CRLDistributionPoints: nil,
+		PolicyIdentifiers:     []asn1.ObjectIdentifier{{2, 23, 140, 1, 2, 1}},
 	}
+
+	test.AssertDeepEquals(t, actual, expected)
 }
 
 func TestIssue(t *testing.T) {
diff --git a/issuance/crl_test.go b/issuance/crl_test.go
new file mode 100644
index 000000000..2f56d8b68
--- /dev/null
+++ b/issuance/crl_test.go
@@ -0,0 +1,182 @@
+package issuance
+
+import (
+	"crypto/x509"
+	"encoding/asn1"
+	"encoding/hex"
+	"math/big"
+	"testing"
+	"time"
+
+	"github.com/jmhodges/clock"
+	"github.com/zmap/zlint/v3/lint"
+
+	"github.com/letsencrypt/boulder/config"
+	"github.com/letsencrypt/boulder/test"
+)
+
+func TestNewCRLProfile(t *testing.T) {
+	t.Parallel()
+	tests := []struct {
+		name        string
+		config      CRLProfileConfig
+		expected    *CRLProfile
+		expectedErr string
+	}{
+		{
+			name:        "validity too long",
+			config:      CRLProfileConfig{ValidityInterval: config.Duration{Duration: 30 * 24 * time.Hour}},
+			expected:    nil,
+			expectedErr: "lifetime cannot be more than 10 days",
+		},
+		{
+			name:        "validity too short",
+			config:      CRLProfileConfig{ValidityInterval: config.Duration{Duration: 0}},
+			expected:    nil,
+			expectedErr: "lifetime must be positive",
+		},
+		{
+			name: "negative backdate",
+			config: CRLProfileConfig{
+				ValidityInterval: config.Duration{Duration: 7 * 24 * time.Hour},
+				MaxBackdate:      config.Duration{Duration: -time.Hour},
+			},
+			expected:    nil,
+			expectedErr: "backdate must be non-negative",
+		},
+		{
+			name: "happy path",
+			config: CRLProfileConfig{
+				ValidityInterval: config.Duration{Duration: 7 * 24 * time.Hour},
+				MaxBackdate:      config.Duration{Duration: time.Hour},
+			},
+			expected: &CRLProfile{
+				validityInterval: 7 * 24 * time.Hour,
+				maxBackdate:      time.Hour,
+			},
+			expectedErr: "",
+		},
+	}
+	for _, tc := range tests {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			actual, err := NewCRLProfile(tc.config)
+			if err != nil {
+				if tc.expectedErr == "" {
+					t.Errorf("NewCRLProfile expected success but got %q", err)
+					return
+				}
+				test.AssertContains(t, err.Error(), tc.expectedErr)
+			} else {
+				if tc.expectedErr != "" {
+					t.Errorf("NewCRLProfile succeeded but expected error %q", tc.expectedErr)
+					return
+				}
+				test.AssertEquals(t, actual.validityInterval, tc.expected.validityInterval)
+				test.AssertEquals(t, actual.maxBackdate, tc.expected.maxBackdate)
+				test.AssertNotNil(t, actual.lints, "lint registry should be populated")
+			}
+		})
+	}
+}
+
+func TestIssueCRL(t *testing.T) {
+	clk := clock.NewFake()
+	clk.Set(time.Now())
+
+	issuer, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, clk)
+	test.AssertNotError(t, err, "creating test issuer")
+
+	defaultProfile := CRLProfile{
+		validityInterval: 7 * 24 * time.Hour,
+		maxBackdate:      1 * time.Hour,
+		lints:            lint.GlobalRegistry(),
+	}
+
+	defaultRequest := CRLRequest{
+		Number:     big.NewInt(123),
+		Shard:      100,
+		ThisUpdate: clk.Now().Add(-time.Second),
+		Entries: []x509.RevocationListEntry{
+			{
+				SerialNumber:   big.NewInt(987),
+				RevocationTime: clk.Now().Add(-24 * time.Hour),
+				ReasonCode:     1,
+			},
+		},
+		DeprecatedIDPBaseURL: "http://old.crl.url",
+	}
+
+	req := defaultRequest
+	req.ThisUpdate = clk.Now().Add(-24 * time.Hour)
+	_, err = issuer.IssueCRL(&defaultProfile, &req)
+	test.AssertError(t, err, "too old crl issuance should fail")
+	test.AssertContains(t, err.Error(), "ThisUpdate is too far in the past")
+
+	req = defaultRequest
+	req.ThisUpdate = clk.Now().Add(time.Second)
+	_, err = issuer.IssueCRL(&defaultProfile, &req)
+	test.AssertError(t, err, "future crl issuance should fail")
+	test.AssertContains(t, err.Error(), "ThisUpdate is in the future")
+
+	req = defaultRequest
+	req.Entries = append(req.Entries, x509.RevocationListEntry{
+		SerialNumber:   big.NewInt(876),
+		RevocationTime: clk.Now().Add(-24 * time.Hour),
+		ReasonCode:     6,
+	})
+	_, err = issuer.IssueCRL(&defaultProfile, &req)
+	test.AssertError(t, err, "invalid reason code should result in lint failure")
+	test.AssertContains(t, err.Error(), "Reason code not included in BR")
+
+	req = defaultRequest
+	res, err := issuer.IssueCRL(&defaultProfile, &req)
+	test.AssertNotError(t, err, "crl issuance should have succeeded")
+	parsedRes, err := x509.ParseRevocationList(res)
+	test.AssertNotError(t, err, "parsing test crl")
+	test.AssertEquals(t, parsedRes.Issuer.CommonName, issuer.Cert.Subject.CommonName)
+	test.AssertDeepEquals(t, parsedRes.Number, big.NewInt(123))
+	expectUpdate := req.ThisUpdate.Add(-time.Second).Add(defaultProfile.validityInterval).Truncate(time.Second).UTC()
+	test.AssertEquals(t, parsedRes.NextUpdate, expectUpdate)
+	test.AssertEquals(t, len(parsedRes.Extensions), 3)
+
+	req = defaultRequest
+	req.DeprecatedIDPBaseURL = ""
+	issuer.crlURLBase = ""
+	_, err = issuer.IssueCRL(&defaultProfile, &req)
+	test.AssertError(t, err, "crl issuance with no IDP should fail")
+	test.AssertContains(t, err.Error(), "must contain an issuingDistributionPoint")
+}
+
+func TestMakeIDPExt(t *testing.T) {
+	t.Parallel()
+	dehex := func(s string) []byte { r, _ := hex.DecodeString(s); return r }
+	tests := []struct {
+		name string
+		urls []string
+		want []byte
+	}{
+		{
+			name: "one (real) url",
+			urls: []string{"http://prod.c.lencr.org/20506757847264211/126.crl"},
+			want: dehex("303AA035A0338631687474703A2F2F70726F642E632E6C656E63722E6F72672F32303530363735373834373236343231312F3132362E63726C8101FF"),
+		},
+		{
+			name: "two urls",
+			urls: []string{"http://old.style/12345678/90.crl", "http://new.style/90.crl"},
+			want: dehex("3042A03DA03B8620687474703A2F2F6F6C642E7374796C652F31323334353637382F39302E63726C8617687474703A2F2F6E65772E7374796C652F39302E63726C8101FF"),
+		},
+	}
+	for _, tc := range tests {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			got, err := makeIDPExt(tc.urls)
+			test.AssertNotError(t, err, "should never fail to marshal asn1 to bytes")
+			test.AssertDeepEquals(t, got.Id, asn1.ObjectIdentifier{2, 5, 29, 28})
+			test.AssertEquals(t, got.Critical, true)
+			test.AssertDeepEquals(t, got.Value, tc.want)
+		})
+	}
+}
diff --git a/issuance/issuer_test.go b/issuance/issuer_test.go
index cd299a3fc..b03a79035 100644
--- a/issuance/issuer_test.go
+++ b/issuance/issuer_test.go
@@ -39,6 +39,7 @@ func defaultIssuerConfig() IssuerConfig {
 		UseForRSALeaves:   true,
 		IssuerURL:         "http://issuer-url.example.org",
 		OCSPURL:           "http://ocsp-url.example.org",
+		CRLURLBase:        "http://crl-url.example.org/",
 	}
 }
 
diff --git a/ra/mock_test.go b/ra/mock_test.go
index d8d3d20f8..26c30266d 100644
--- a/ra/mock_test.go
+++ b/ra/mock_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"time"
 
+	"github.com/letsencrypt/boulder/core"
 	corepb "github.com/letsencrypt/boulder/core/proto"
 	"github.com/letsencrypt/boulder/mocks"
 	sapb "github.com/letsencrypt/boulder/sa/proto"
@@ -63,3 +64,23 @@ func (sa *mockInvalidPlusValidAuthzAuthority) GetAuthorizations2(ctx context.Con
 		},
 	}, nil
 }
+
+// An authority that returns an error from NewOrderAndAuthzs if the
+// "ReplacesSerial" field of the request is empty.
+type mockNewOrderMustBeReplacementAuthority struct {
+	mocks.StorageAuthority
+}
+
+func (sa *mockNewOrderMustBeReplacementAuthority) NewOrderAndAuthzs(ctx context.Context, req *sapb.NewOrderAndAuthzsRequest, _ ...grpc.CallOption) (*corepb.Order, error) {
+	if req.NewOrder.ReplacesSerial == "" {
+		return nil, status.Error(codes.InvalidArgument, "NewOrder is not a replacement")
+	}
+	return &corepb.Order{
+		Id:             1,
+		RegistrationID: req.NewOrder.RegistrationID,
+		Expires:        req.NewOrder.Expires,
+		Status:         string(core.StatusPending),
+		Created:        timestamppb.New(time.Now()),
+		Names:          req.NewOrder.Names,
+	}, nil
+}
diff --git a/ra/ra_test.go b/ra/ra_test.go
index 123b92f29..101799256 100644
--- a/ra/ra_test.go
+++ b/ra/ra_test.go
@@ -4130,3 +4130,100 @@ func TestAdministrativelyRevokeCertificate(t *testing.T) {
 	})
 	test.AssertError(t, err, "AdministrativelyRevokeCertificate should have failed with just serial for keyCompromise")
 }
+
+func TestNewOrderRateLimitingExempt(t *testing.T) {
+	_, _, ra, _, cleanUp := initAuthorities(t)
+	defer cleanUp()
+
+	ra.orderLifetime = 5 * 24 * time.Hour
+
+	// Set up a rate limit policy that allows 1 order every 5 minutes.
+	rateLimitDuration := 5 * time.Minute
+	ra.rlPolicies = &dummyRateLimitConfig{
+		NewOrdersPerAccountPolicy: ratelimit.RateLimitPolicy{
+			Threshold: 1,
+			Window:    config.Duration{Duration: rateLimitDuration},
+		},
+	}
+
+	exampleOrderOne := &rapb.NewOrderRequest{
+		RegistrationID: Registration.Id,
+		Names:          []string{"first.example.com", "second.example.com"},
+	}
+	exampleOrderTwo := &rapb.NewOrderRequest{
+		RegistrationID: Registration.Id,
+		Names:          []string{"first.example.com", "third.example.com"},
+	}
+
+	// Create an order immediately.
+	_, err := ra.NewOrder(ctx, exampleOrderOne)
+	test.AssertNotError(t, err, "orderOne should have succeeded")
+
+	// Create another order immediately. This should fail.
+	_, err = ra.NewOrder(ctx, exampleOrderTwo)
+	test.AssertError(t, err, "orderTwo should have failed")
+
+	// Exempt orderTwo from rate limiting.
+	exampleOrderTwo.LimitsExempt = true
+	_, err = ra.NewOrder(ctx, exampleOrderTwo)
+	test.AssertNotError(t, err, "orderTwo should have succeeded")
+}
+
+func TestNewOrderFailedAuthzRateLimitingExempt(t *testing.T) {
+	_, _, ra, _, cleanUp := initAuthorities(t)
+	defer cleanUp()
+
+	exampleOrder := &rapb.NewOrderRequest{
+		RegistrationID: Registration.Id,
+		Names:          []string{"example.com"},
+	}
+
+	// Create an order, and thus a pending authz, for "example.com".
+	ctx := context.Background()
+	order, err := ra.NewOrder(ctx, exampleOrder)
+	test.AssertNotError(t, err, "adding an initial order for regA")
+	test.AssertNotNil(t, order.Id, "initial order had a nil ID")
+	test.AssertEquals(t, numAuthorizations(order), 1)
+
+	// Mock SA that fails all authorizations for "example.com".
+	ra.SA = &mockInvalidPlusValidAuthzAuthority{
+		mockInvalidAuthorizationsAuthority{
+			domainWithFailures: "example.com"},
+	}
+
+	// Set up a rate limit policy that allows 1 order every 24 hours.
+	ra.rlPolicies = &dummyRateLimitConfig{
+		InvalidAuthorizationsPerAccountPolicy: ratelimit.RateLimitPolicy{
+			Threshold: 1,
+			Window:    config.Duration{Duration: 24 * time.Hour},
+		},
+	}
+
+	// Requesting a new order for "example.com" should fail due to too many
+	// failed authorizations.
+	_, err = ra.NewOrder(ctx, exampleOrder)
+	test.AssertError(t, err, "expected error for domain with too many failures")
+
+	// Exempt the order from rate limiting.
+	exampleOrder.LimitsExempt = true
+	_, err = ra.NewOrder(ctx, exampleOrder)
+	test.AssertNotError(t, err, "limit exempt order should have succeeded")
+}
+
+func TestNewOrderReplacesSerialCarriesThroughToSA(t *testing.T) {
+	_, _, ra, _, cleanUp := initAuthorities(t)
+	defer cleanUp()
+
+	exampleOrder := &rapb.NewOrderRequest{
+		RegistrationID: Registration.Id,
+		Names:          []string{"example.com"},
+		ReplacesSerial: "1234",
+	}
+
+	// Mock SA that returns an error from NewOrderAndAuthzs if the
+	// "ReplacesSerial" field of the request is empty.
+	ra.SA = &mockNewOrderMustBeReplacementAuthority{}
+
+	_, err := ra.NewOrder(ctx, exampleOrder)
+	test.AssertNotError(t, err, "order with ReplacesSerial should have succeeded")
+}
diff --git a/test/config-next/ca.json b/test/config-next/ca.json
index f00682fe7..567368f8a 100644
--- a/test/config-next/ca.json
+++ b/test/config-next/ca.json
@@ -55,12 +55,17 @@
 				"maxValidityPeriod": "7776000s",
 				"maxValidityBackdate": "1h5m"
 			},
+			"crlProfile": {
+				"validityInterval": "216h",
+				"maxBackdate": "1h5m"
+			},
 			"issuers": [
 				{
 					"useForRSALeaves": false,
 					"useForECDSALeaves": true,
 					"issuerURL": "http://127.0.0.1:4001/aia/issuer/5214744660557630",
 					"ocspURL": "http://127.0.0.1:4002/",
+					"crlURLBase": "http://127.0.0.1:7890/e1/",
 					"location": {
 						"configFile": "/hierarchy/intermediate-signing-key-ecdsa.pkcs11.json",
 						"certFile": "/hierarchy/intermediate-cert-ecdsa-a.pem",
@@ -72,6 +77,7 @@
 					"useForECDSALeaves": true,
 					"issuerURL": "http://127.0.0.1:4001/aia/issuer/6605440498369741",
 					"ocspURL": "http://127.0.0.1:4002/",
+					"crlURLBase": "http://127.0.0.1:7890/r3/",
 					"location": {
 						"configFile": "/hierarchy/intermediate-signing-key-rsa.pkcs11.json",
 						"certFile": "/hierarchy/intermediate-cert-rsa-a.pem",
@@ -101,7 +107,6 @@
 		"serialPrefix": 127,
 		"maxNames": 100,
 		"lifespanOCSP": "96h",
-		"lifespanCRL": "216h",
 		"crldpBase": "http://c.boulder.test",
 		"goodkey": {
 			"weakKeyFile": "test/example-weak-keys.json",
diff --git a/va/caa_test.go b/va/caa_test.go
index 758fda4c2..0170dc0ed 100644
--- a/va/caa_test.go
+++ b/va/caa_test.go
@@ -12,6 +12,7 @@ import (
 
 	"github.com/letsencrypt/boulder/bdns"
 	"github.com/letsencrypt/boulder/core"
+	berrors "github.com/letsencrypt/boulder/errors"
 	"github.com/letsencrypt/boulder/features"
 	"github.com/letsencrypt/boulder/identifier"
 	"github.com/letsencrypt/boulder/probs"
@@ -197,14 +198,8 @@ func TestCAATimeout(t *testing.T) {
 	}
 
 	err := va.checkCAA(ctx, identifier.DNSIdentifier("caa-timeout.com"), params)
-	if err.Type != probs.DNSProblem {
-		t.Errorf("Expected timeout error type %s, got %s", probs.DNSProblem, err.Type)
-	}
-
-	expected := "error"
-	if err.Detail != expected {
-		t.Errorf("checkCAA: got %#v, expected %#v", err.Detail, expected)
-	}
+	test.AssertErrorIs(t, err, berrors.DNS)
+	test.AssertContains(t, err.Error(), "error")
 }
 
 func TestCAAChecking(t *testing.T) {
@@ -970,16 +965,17 @@ func TestCAAFailure(t *testing.T) {
 
 	va, _ := setup(hs, 0, "", nil, caaMockDNS{})
 
-	_, prob := va.validate(ctx, dnsi("reserved.com"), 1, chall)
-	if prob == nil {
+	_, err := va.validate(ctx, dnsi("reserved.com"), 1, chall)
+	if err == nil {
 		t.Fatalf("Expected CAA rejection for reserved.com, got success")
 	}
-	test.AssertEquals(t, prob.Type, probs.CAAProblem)
+	test.AssertErrorIs(t, err, berrors.CAA)
 
-	_, prob = va.validate(ctx, dnsi("example.gonetld"), 1, chall)
-	if prob == nil {
+	_, err = va.validate(ctx, dnsi("example.gonetld"), 1, chall)
+	if err == nil {
 		t.Fatalf("Expected CAA rejection for gonetld, got success")
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.DNSProblem)
 	test.AssertContains(t, prob.Error(), "NXDOMAIN")
 }
diff --git a/va/dns_test.go b/va/dns_test.go
index c5f7e1919..edac03b84 100644
--- a/va/dns_test.go
+++ b/va/dns_test.go
@@ -41,37 +41,41 @@ func TestDNSValidationEmpty(t *testing.T) {
 
 func TestDNSValidationWrong(t *testing.T) {
 	va, _ := setup(nil, 0, "", nil, nil)
-	_, prob := va.validateDNS01(context.Background(), dnsi("wrong-dns01.com"), dnsChallenge())
-	if prob == nil {
+	_, err := va.validateDNS01(context.Background(), dnsi("wrong-dns01.com"), dnsChallenge())
+	if err == nil {
 		t.Fatalf("Successful DNS validation with wrong TXT record")
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Error(), "unauthorized :: Incorrect TXT record \"a\" found at _acme-challenge.wrong-dns01.com")
 }
 
 func TestDNSValidationWrongMany(t *testing.T) {
 	va, _ := setup(nil, 0, "", nil, nil)
 
-	_, prob := va.validateDNS01(context.Background(), dnsi("wrong-many-dns01.com"), dnsChallenge())
-	if prob == nil {
+	_, err := va.validateDNS01(context.Background(), dnsi("wrong-many-dns01.com"), dnsChallenge())
+	if err == nil {
 		t.Fatalf("Successful DNS validation with wrong TXT record")
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Error(), "unauthorized :: Incorrect TXT record \"a\" (and 4 more) found at _acme-challenge.wrong-many-dns01.com")
 }
 
 func TestDNSValidationWrongLong(t *testing.T) {
 	va, _ := setup(nil, 0, "", nil, nil)
 
-	_, prob := va.validateDNS01(context.Background(), dnsi("long-dns01.com"), dnsChallenge())
-	if prob == nil {
+	_, err := va.validateDNS01(context.Background(), dnsi("long-dns01.com"), dnsChallenge())
+	if err == nil {
 		t.Fatalf("Successful DNS validation with wrong TXT record")
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Error(), "unauthorized :: Incorrect TXT record \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...\" found at _acme-challenge.long-dns01.com")
 }
 
 func TestDNSValidationFailure(t *testing.T) {
 	va, _ := setup(nil, 0, "", nil, nil)
 
-	_, prob := va.validateDNS01(ctx, dnsi("localhost"), dnsChallenge())
+	_, err := va.validateDNS01(ctx, dnsi("localhost"), dnsChallenge())
+	prob := detailedError(err)
 
 	test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem)
 }
@@ -84,7 +88,8 @@ func TestDNSValidationInvalid(t *testing.T) {
 
 	va, _ := setup(nil, 0, "", nil, nil)
 
-	_, prob := va.validateDNS01(ctx, notDNS, dnsChallenge())
+	_, err := va.validateDNS01(ctx, notDNS, dnsChallenge())
+	prob := detailedError(err)
 
 	test.AssertEquals(t, prob.Type, probs.MalformedProblem)
 }
@@ -94,7 +99,8 @@ func TestDNSValidationNotSane(t *testing.T) {
 
 	chall := dnsChallenge()
 	chall.Token = ""
-	_, prob := va.validateChallenge(ctx, dnsi("localhost"), chall)
+	_, err := va.validateChallenge(ctx, dnsi("localhost"), chall)
+	prob := detailedError(err)
 	if prob.Type != probs.MalformedProblem {
 		t.Errorf("Got wrong error type: expected %s, got %s",
 			prob.Type, probs.MalformedProblem)
@@ -104,7 +110,8 @@ func TestDNSValidationNotSane(t *testing.T) {
 	}
 
 	chall.Token = "yfCBb-bRTLz8Wd1C0lTUQK3qlKj3-t2tYGwx5Hj7r_"
-	_, prob = va.validateChallenge(ctx, dnsi("localhost"), chall)
+	_, err = va.validateChallenge(ctx, dnsi("localhost"), chall)
+	prob = detailedError(err)
 	if prob.Type != probs.MalformedProblem {
 		t.Errorf("Got wrong error type: expected %s, got %s",
 			prob.Type, probs.MalformedProblem)
@@ -114,7 +121,8 @@ func TestDNSValidationNotSane(t *testing.T) {
 	}
 
 	chall.ProvidedKeyAuthorization = "a"
-	_, prob = va.validateChallenge(ctx, dnsi("localhost"), chall)
+	_, err = va.validateChallenge(ctx, dnsi("localhost"), chall)
+	prob = detailedError(err)
 	if prob.Type != probs.MalformedProblem {
 		t.Errorf("Got wrong error type: expected %s, got %s",
 			prob.Type, probs.MalformedProblem)
@@ -128,8 +136,9 @@ func TestDNSValidationNotSane(t *testing.T) {
 func TestDNSValidationServFail(t *testing.T) {
 	va, _ := setup(nil, 0, "", nil, nil)
 
-	_, prob := va.validateChallenge(ctx, dnsi("servfail.com"), dnsChallenge())
+	_, err := va.validateChallenge(ctx, dnsi("servfail.com"), dnsChallenge())
 
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.DNSProblem)
 }
 
@@ -147,8 +156,8 @@ func TestDNSValidationNoServer(t *testing.T) {
 		log,
 		nil)
 
-	_, prob := va.validateChallenge(ctx, dnsi("localhost"), dnsChallenge())
-
+	_, err = va.validateChallenge(ctx, dnsi("localhost"), dnsChallenge())
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.DNSProblem)
 }
 
diff --git a/va/http_test.go b/va/http_test.go
index 19f3ec748..f6320cd1e 100644
--- a/va/http_test.go
+++ b/va/http_test.go
@@ -75,31 +75,33 @@ func TestPreresolvedDialerTimeout(t *testing.T) {
 	// is to connect to an unrouteable IP address. This usually generates
 	// a connection timeout, but will rarely return "Network unreachable" instead.
 	// If we get that, just retry until we get something other than "Network unreachable".
-	var prob *probs.ProblemDetails
+	var err error
 	var took time.Duration
 	for i := 0; i < 20; i++ {
 		started := time.Now()
-		_, _, prob = va.fetchHTTP(ctx, "unroutable.invalid", "/.well-known/acme-challenge/whatever")
+		_, _, err = va.fetchHTTP(ctx, "unroutable.invalid", "/.well-known/acme-challenge/whatever")
 		took = time.Since(started)
-		if prob != nil && strings.Contains(prob.Detail, "Network unreachable") {
+		if err != nil && strings.Contains(err.Error(), "Network unreachable") {
 			continue
 		} else {
 			break
 		}
 	}
-	if prob == nil {
+	if err == nil {
 		t.Fatalf("Connection should've timed out")
 	}
 
 	// Check that the HTTP connection doesn't return too fast, and times
 	// out after the expected time
 	if took < va.singleDialTimeout {
-		t.Fatalf("fetch returned before %s (took: %s) with %#v", va.singleDialTimeout, took, prob)
+		t.Fatalf("fetch returned before %s (took: %s) with %#v", va.singleDialTimeout, took, err)
 	}
 	if took > 2*va.singleDialTimeout {
 		t.Fatalf("fetch didn't timeout after %s (took: %s)", va.singleDialTimeout, took)
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.ConnectionProblem)
+
 	expectMatch := regexp.MustCompile(
 		"Fetching http://unroutable.invalid/.well-known/acme-challenge/.*: Timeout during connect")
 	if !expectMatch.MatchString(prob.Detail) {
@@ -1101,12 +1103,13 @@ func TestFetchHTTP(t *testing.T) {
 		t.Run(tc.Name, func(t *testing.T) {
 			ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*500)
 			defer cancel()
-			body, records, prob := va.fetchHTTP(ctx, tc.Host, tc.Path)
-			if prob != nil && tc.ExpectedProblem == nil {
-				t.Errorf("expected nil prob, got %#v\n", prob)
-			} else if prob == nil && tc.ExpectedProblem != nil {
+			body, records, err := va.fetchHTTP(ctx, tc.Host, tc.Path)
+			if err != nil && tc.ExpectedProblem == nil {
+				t.Errorf("expected nil prob, got %#v\n", err)
+			} else if err == nil && tc.ExpectedProblem != nil {
 				t.Errorf("expected %#v prob, got nil", tc.ExpectedProblem)
-			} else if prob != nil && tc.ExpectedProblem != nil {
+			} else if err != nil && tc.ExpectedProblem != nil {
+				prob := detailedError(err)
 				test.AssertMarshaledEquals(t, prob, tc.ExpectedProblem)
 			} else {
 				test.AssertEquals(t, string(body), tc.ExpectedBody)
@@ -1219,10 +1222,11 @@ func TestHTTPBadPort(t *testing.T) {
 	badPort := 40000 + mrand.Intn(25000)
 	va.httpPort = badPort
 
-	_, prob := va.validateHTTP01(ctx, dnsi("localhost"), httpChallenge())
-	if prob == nil {
+	_, err := va.validateHTTP01(ctx, dnsi("localhost"), httpChallenge())
+	if err == nil {
 		t.Fatalf("Server's down; expected refusal. Where did we connect?")
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.ConnectionProblem)
 	if !strings.Contains(prob.Detail, "Connection refused") {
 		t.Errorf("Expected a connection refused error, got %q", prob.Detail)
@@ -1238,14 +1242,14 @@ func TestHTTPKeyAuthorizationFileMismatch(t *testing.T) {
 	hs.Start()
 
 	va, _ := setup(hs, 0, "", nil, nil)
-	_, prob := va.validateHTTP01(ctx, dnsi("localhost.com"), httpChallenge())
+	_, err := va.validateHTTP01(ctx, dnsi("localhost.com"), httpChallenge())
 
-	if prob == nil {
+	if err == nil {
 		t.Fatalf("Expected validation to fail when file mismatched.")
 	}
 	expected := `The key authorization file from the server did not match this challenge. Expected "LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0.9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI" (got "\xef\xffAABBCC")`
-	if prob.Detail != expected {
-		t.Errorf("validation failed with %s, expected %s", prob.Detail, expected)
+	if err.Error() != expected {
+		t.Errorf("validation failed with %s, expected %s", err, expected)
 	}
 }
 
@@ -1264,36 +1268,37 @@ func TestHTTP(t *testing.T) {
 
 	chall := httpChallenge()
 	t.Logf("Trying to validate: %+v\n", chall)
-	_, prob := va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
-	if prob != nil {
-		t.Errorf("Unexpected failure in HTTP validation: %s", prob)
+	_, err := va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
+	if err != nil {
+		t.Errorf("Unexpected failure in HTTP validation: %s", err)
 	}
 	test.AssertEquals(t, len(log.GetAllMatching(`\[AUDIT\] `)), 1)
 
 	log.Clear()
 	setChallengeToken(&chall, path404)
-	_, prob = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
-	if prob == nil {
+	_, err = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
+	if err == nil {
 		t.Fatalf("Should have found a 404 for the challenge.")
 	}
-	test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem)
+	test.AssertErrorIs(t, err, berrors.Unauthorized)
 	test.AssertEquals(t, len(log.GetAllMatching(`\[AUDIT\] `)), 1)
 
 	log.Clear()
 	setChallengeToken(&chall, pathWrongToken)
 	// The "wrong token" will actually be the expectedToken.  It's wrong
 	// because it doesn't match pathWrongToken.
-	_, prob = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
-	if prob == nil {
+	_, err = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
+	if err == nil {
 		t.Fatalf("Should have found the wrong token value.")
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem)
 	test.AssertEquals(t, len(log.GetAllMatching(`\[AUDIT\] `)), 1)
 
 	log.Clear()
 	setChallengeToken(&chall, pathMoved)
-	_, prob = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
-	if prob != nil {
+	_, err = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
+	if err != nil {
 		t.Fatalf("Failed to follow http.StatusMovedPermanently redirect")
 	}
 	redirectValid := `following redirect to host "" url "http://localhost.com/.well-known/acme-challenge/` + pathValid + `"`
@@ -1302,8 +1307,8 @@ func TestHTTP(t *testing.T) {
 
 	log.Clear()
 	setChallengeToken(&chall, pathFound)
-	_, prob = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
-	if prob != nil {
+	_, err = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
+	if err != nil {
 		t.Fatalf("Failed to follow http.StatusFound redirect")
 	}
 	redirectMoved := `following redirect to host "" url "http://localhost.com/.well-known/acme-challenge/` + pathMoved + `"`
@@ -1312,16 +1317,17 @@ func TestHTTP(t *testing.T) {
 	test.AssertEquals(t, len(matchedMovedRedirect), 1)
 
 	ipIdentifier := identifier.ACMEIdentifier{Type: identifier.IdentifierType("ip"), Value: "127.0.0.1"}
-	_, prob = va.validateHTTP01(ctx, ipIdentifier, chall)
-	if prob == nil {
+	_, err = va.validateHTTP01(ctx, ipIdentifier, chall)
+	if err == nil {
 		t.Fatalf("IdentifierType IP shouldn't have worked.")
 	}
-	test.AssertEquals(t, prob.Type, probs.MalformedProblem)
+	test.AssertErrorIs(t, err, berrors.Malformed)
 
-	_, prob = va.validateHTTP01(ctx, identifier.ACMEIdentifier{Type: identifier.DNS, Value: "always.invalid"}, chall)
-	if prob == nil {
+	_, err = va.validateHTTP01(ctx, identifier.ACMEIdentifier{Type: identifier.DNS, Value: "always.invalid"}, chall)
+	if err == nil {
 		t.Fatalf("Domain name is invalid.")
 	}
+	prob = detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.DNSProblem)
 }
 
@@ -1338,8 +1344,8 @@ func TestHTTPTimeout(t *testing.T) {
 	timeout := 250 * time.Millisecond
 	ctx, cancel := context.WithTimeout(context.Background(), timeout)
 	defer cancel()
-	_, prob := va.validateHTTP01(ctx, dnsi("localhost"), chall)
-	if prob == nil {
+	_, err := va.validateHTTP01(ctx, dnsi("localhost"), chall)
+	if err == nil {
 		t.Fatalf("Connection should've timed out")
 	}
 
@@ -1347,11 +1353,12 @@ func TestHTTPTimeout(t *testing.T) {
 	// Check that the HTTP connection doesn't return before a timeout, and times
 	// out after the expected time
 	if took < timeout-200*time.Millisecond {
-		t.Fatalf("HTTP timed out before %s: %s with %s", timeout, took, prob)
+		t.Fatalf("HTTP timed out before %s: %s with %s", timeout, took, err)
 	}
 	if took > 2*timeout {
 		t.Fatalf("HTTP connection didn't timeout after %s", timeout)
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.ConnectionProblem)
 	test.AssertEquals(t, prob.Detail, "127.0.0.1: Fetching http://localhost/.well-known/acme-challenge/wait-long: Timeout after connect (your server may be slow or overloaded)")
 }
@@ -1383,27 +1390,28 @@ func TestHTTPDialTimeout(t *testing.T) {
 	// connect to an unrouteable IP address. This usually generates a connection
 	// timeout, but will rarely return "Network unreachable" instead. If we get
 	// that, just retry until we get something other than "Network unreachable".
-	var prob *probs.ProblemDetails
+	var err error
 	for i := 0; i < 20; i++ {
-		_, prob = va.validateHTTP01(ctx, dnsi("unroutable.invalid"), httpChallenge())
-		if prob != nil && strings.Contains(prob.Detail, "Network unreachable") {
+		_, err = va.validateHTTP01(ctx, dnsi("unroutable.invalid"), httpChallenge())
+		if err != nil && strings.Contains(err.Error(), "Network unreachable") {
 			continue
 		} else {
 			break
 		}
 	}
-	if prob == nil {
+	if err == nil {
 		t.Fatalf("Connection should've timed out")
 	}
 	took := time.Since(started)
 	// Check that the HTTP connection doesn't return too fast, and times
 	// out after the expected time
 	if took < (timeout-200*time.Millisecond)/2 {
-		t.Fatalf("HTTP returned before %s (%s) with %#v", timeout, took, prob)
+		t.Fatalf("HTTP returned before %s (%s) with %#v", timeout, took, err)
 	}
 	if took > 2*timeout {
 		t.Fatalf("HTTP connection didn't timeout after %s seconds", timeout)
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.ConnectionProblem)
 	expectMatch := regexp.MustCompile(
 		"Fetching http://unroutable.invalid/.well-known/acme-challenge/.*: Timeout during connect")
@@ -1420,9 +1428,9 @@ func TestHTTPRedirectLookup(t *testing.T) {
 
 	chall := httpChallenge()
 	setChallengeToken(&chall, pathMoved)
-	_, prob := va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
-	if prob != nil {
-		t.Fatalf("Unexpected failure in redirect (%s): %s", pathMoved, prob)
+	_, err := va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
+	if err != nil {
+		t.Fatalf("Unexpected failure in redirect (%s): %s", pathMoved, err)
 	}
 	redirectValid := `following redirect to host "" url "http://localhost.com/.well-known/acme-challenge/` + pathValid + `"`
 	matchedValidRedirect := log.GetAllMatching(redirectValid)
@@ -1431,9 +1439,9 @@ func TestHTTPRedirectLookup(t *testing.T) {
 
 	log.Clear()
 	setChallengeToken(&chall, pathFound)
-	_, prob = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
-	if prob != nil {
-		t.Fatalf("Unexpected failure in redirect (%s): %s", pathFound, prob)
+	_, err = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
+	if err != nil {
+		t.Fatalf("Unexpected failure in redirect (%s): %s", pathFound, err)
 	}
 	redirectMoved := `following redirect to host "" url "http://localhost.com/.well-known/acme-challenge/` + pathMoved + `"`
 	matchedMovedRedirect := log.GetAllMatching(redirectMoved)
@@ -1442,16 +1450,17 @@ func TestHTTPRedirectLookup(t *testing.T) {
 
 	log.Clear()
 	setChallengeToken(&chall, pathReLookupInvalid)
-	_, err := va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
+	_, err = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
 	test.AssertError(t, err, chall.Token)
 	test.AssertEquals(t, len(log.GetAllMatching(`Resolved addresses for localhost.com: \[127.0.0.1\]`)), 1)
-	test.AssertDeepEquals(t, err, probs.Connection(`127.0.0.1: Fetching http://invalid.invalid/path: Invalid hostname in redirect target, must end in IANA registered TLD`))
+	prob := detailedError(err)
+	test.AssertDeepEquals(t, prob, probs.Connection(`127.0.0.1: Fetching http://invalid.invalid/path: Invalid hostname in redirect target, must end in IANA registered TLD`))
 
 	log.Clear()
 	setChallengeToken(&chall, pathReLookup)
-	_, prob = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
-	if prob != nil {
-		t.Fatalf("Unexpected error in redirect (%s): %s", pathReLookup, prob)
+	_, err = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
+	if err != nil {
+		t.Fatalf("Unexpected error in redirect (%s): %s", pathReLookup, err)
 	}
 	redirectPattern := `following redirect to host "" url "http://other.valid.com:\d+/path"`
 	test.AssertEquals(t, len(log.GetAllMatching(redirectPattern)), 1)
@@ -1460,8 +1469,9 @@ func TestHTTPRedirectLookup(t *testing.T) {
 
 	log.Clear()
 	setChallengeToken(&chall, pathRedirectInvalidPort)
-	_, prob = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
-	test.AssertNotNil(t, prob, "Problem details for pathRedirectInvalidPort should not be nil")
+	_, err = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
+	test.AssertNotNil(t, err, "error for pathRedirectInvalidPort should not be nil")
+	prob = detailedError(err)
 	test.AssertEquals(t, prob.Detail, fmt.Sprintf(
 		"127.0.0.1: Fetching http://other.valid.com:8080/path: Invalid port in redirect target. "+
 			"Only ports %d and %d are supported, not 8080", va.httpPort, va.httpsPort))
@@ -1471,8 +1481,9 @@ func TestHTTPRedirectLookup(t *testing.T) {
 	// is referencing the redirected to host, instead of the original host.
 	log.Clear()
 	setChallengeToken(&chall, pathRedirectToFailingURL)
-	_, prob = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
-	test.AssertNotNil(t, prob, "Problem Details should not be nil")
+	_, err = va.validateHTTP01(ctx, dnsi("localhost.com"), chall)
+	test.AssertNotNil(t, err, "err should not be nil")
+	prob = detailedError(err)
 	test.AssertDeepEquals(t, prob,
 		probs.Unauthorized(
 			fmt.Sprintf("127.0.0.1: Invalid response from http://other.valid.com:%d/500: 500",
@@ -1549,13 +1560,14 @@ func TestLimitedReader(t *testing.T) {
 	va, _ := setup(hs, 0, "", nil, nil)
 	defer hs.Close()
 
-	_, prob := va.validateChallenge(ctx, dnsi("localhost"), chall)
+	_, err := va.validateChallenge(ctx, dnsi("localhost"), chall)
 
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem)
 	test.Assert(t, strings.HasPrefix(prob.Detail, "127.0.0.1: Invalid response from "),
 		"Expected failure due to truncation")
 
-	if !utf8.ValidString(prob.Detail) {
+	if !utf8.ValidString(err.Error()) {
 		t.Errorf("Problem Detail contained an invalid UTF-8 string")
 	}
 }
diff --git a/va/tlsalpn_test.go b/va/tlsalpn_test.go
index 20cdfe754..846de340d 100644
--- a/va/tlsalpn_test.go
+++ b/va/tlsalpn_test.go
@@ -144,13 +144,14 @@ func TestTLSALPN01FailIP(t *testing.T) {
 	va, _ := setup(hs, 0, "", nil, nil)
 
 	port := getPort(hs)
-	_, prob := va.validateTLSALPN01(ctx, identifier.ACMEIdentifier{
+	_, err = va.validateTLSALPN01(ctx, identifier.ACMEIdentifier{
 		Type:  identifier.IdentifierType("ip"),
 		Value: net.JoinHostPort("127.0.0.1", strconv.Itoa(port)),
 	}, chall)
-	if prob == nil {
+	if err == nil {
 		t.Fatalf("IdentifierType IP shouldn't have worked.")
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.MalformedProblem)
 }
 
@@ -177,8 +178,8 @@ func TestTLSALPNTimeoutAfterConnect(t *testing.T) {
 	defer cancel()
 
 	started := time.Now()
-	_, prob := va.validateTLSALPN01(ctx, dnsi("slow.server"), chall)
-	if prob == nil {
+	_, err := va.validateTLSALPN01(ctx, dnsi("slow.server"), chall)
+	if err == nil {
 		t.Fatalf("Validation should've failed")
 	}
 	// Check that the TLS connection doesn't return before a timeout, and times
@@ -187,15 +188,16 @@ func TestTLSALPNTimeoutAfterConnect(t *testing.T) {
 	// Check that the HTTP connection doesn't return too fast, and times
 	// out after the expected time
 	if took < timeout/2 {
-		t.Fatalf("TLSSNI returned before %s (%s) with %#v", timeout, took, prob)
+		t.Fatalf("TLSSNI returned before %s (%s) with %#v", timeout, took, err)
 	}
 	if took > 2*timeout {
 		t.Fatalf("TLSSNI didn't timeout after %s (took %s to return %#v)", timeout,
-			took, prob)
+			took, err)
 	}
-	if prob == nil {
+	if err == nil {
 		t.Fatalf("Connection should've timed out")
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.ConnectionProblem)
 
 	expected := "127.0.0.1: Timeout after connect (your server may be slow or overloaded)"
@@ -218,17 +220,17 @@ func TestTLSALPN01DialTimeout(t *testing.T) {
 	// connect to an unrouteable IP address. This usually generates a connection
 	// timeout, but will rarely return "Network unreachable" instead. If we get
 	// that, just retry until we get something other than "Network unreachable".
-	var prob *probs.ProblemDetails
+	var err error
 	for i := 0; i < 20; i++ {
-		_, prob = va.validateTLSALPN01(ctx, dnsi("unroutable.invalid"), chall)
-		if prob != nil && strings.Contains(prob.Detail, "Network unreachable") {
+		_, err = va.validateTLSALPN01(ctx, dnsi("unroutable.invalid"), chall)
+		if err != nil && strings.Contains(err.Error(), "Network unreachable") {
 			continue
 		} else {
 			break
 		}
 	}
 
-	if prob == nil {
+	if err == nil {
 		t.Fatalf("Validation should've failed")
 	}
 	// Check that the TLS connection doesn't return before a timeout, and times
@@ -237,14 +239,15 @@ func TestTLSALPN01DialTimeout(t *testing.T) {
 	// Check that the HTTP connection doesn't return too fast, and times
 	// out after the expected time
 	if took < timeout/2 {
-		t.Fatalf("TLSSNI returned before %s (%s) with %#v", timeout, took, prob)
+		t.Fatalf("TLSSNI returned before %s (%s) with %#v", timeout, took, err)
 	}
 	if took > 2*timeout {
 		t.Fatalf("TLSSNI didn't timeout after %s", timeout)
 	}
-	if prob == nil {
+	if err == nil {
 		t.Fatalf("Connection should've timed out")
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.ConnectionProblem)
 	expected := "198.51.100.1: Timeout during connect (likely firewall problem)"
 	if prob.Detail != expected {
@@ -260,10 +263,11 @@ func TestTLSALPN01Refused(t *testing.T) {
 	va, _ := setup(hs, 0, "", nil, nil)
 	// Take down validation server and check that validation fails.
 	hs.Close()
-	_, prob := va.validateTLSALPN01(ctx, dnsi("expected"), chall)
-	if prob == nil {
+	_, err = va.validateTLSALPN01(ctx, dnsi("expected"), chall)
+	if err == nil {
 		t.Fatalf("Server's down; expected refusal. Where did we connect?")
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.ConnectionProblem)
 	expected := "127.0.0.1: Connection refused"
 	if prob.Detail != expected {
@@ -280,11 +284,12 @@ func TestTLSALPN01TalkingToHTTP(t *testing.T) {
 	httpOnly := httpSrv(t, "")
 	va.tlsPort = getPort(httpOnly)
 
-	_, prob := va.validateTLSALPN01(ctx, dnsi("expected"), chall)
-	test.AssertError(t, prob, "TLS-SNI-01 validation passed when talking to a HTTP-only server")
+	_, err = va.validateTLSALPN01(ctx, dnsi("expected"), chall)
+	test.AssertError(t, err, "TLS-SNI-01 validation passed when talking to a HTTP-only server")
+	prob := detailedError(err)
 	expected := "Server only speaks HTTP, not TLS"
-	if !strings.HasSuffix(prob.Detail, expected) {
-		t.Errorf("Got wrong error detail. Expected %q, got %q", expected, prob.Detail)
+	if !strings.HasSuffix(prob.Error(), expected) {
+		t.Errorf("Got wrong error detail. Expected %q, got %q", expected, prob)
 	}
 }
 
@@ -305,10 +310,11 @@ func TestTLSError(t *testing.T) {
 
 	va, _ := setup(hs, 0, "", nil, nil)
 
-	_, prob := va.validateTLSALPN01(ctx, dnsi("expected"), chall)
-	if prob == nil {
+	_, err := va.validateTLSALPN01(ctx, dnsi("expected"), chall)
+	if err == nil {
 		t.Fatalf("TLS validation should have failed: What cert was used?")
 	}
+	prob := detailedError(err)
 	if prob.Type != probs.TLSProblem {
 		t.Errorf("Wrong problem type: got %s, expected type %s",
 			prob, probs.TLSProblem)
@@ -321,10 +327,11 @@ func TestDNSError(t *testing.T) {
 
 	va, _ := setup(hs, 0, "", nil, nil)
 
-	_, prob := va.validateTLSALPN01(ctx, dnsi("always.invalid"), chall)
-	if prob == nil {
+	_, err := va.validateTLSALPN01(ctx, dnsi("always.invalid"), chall)
+	if err == nil {
 		t.Fatalf("TLS validation should have failed: what IP was used?")
 	}
+	prob := detailedError(err)
 	if prob.Type != probs.DNSProblem {
 		t.Errorf("Wrong problem type: got %s, expected type %s",
 			prob, probs.DNSProblem)
@@ -436,19 +443,21 @@ func TestValidateTLSALPN01BadChallenge(t *testing.T) {
 
 	va, _ := setup(hs, 0, "", nil, nil)
 
-	_, prob := va.validateTLSALPN01(ctx, dnsi("expected"), chall)
+	_, err = va.validateTLSALPN01(ctx, dnsi("expected"), chall)
 
-	if prob == nil {
+	if err == nil {
 		t.Fatalf("TLS ALPN validation should have failed.")
 	}
+
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem)
 
 	expectedDigest := sha256.Sum256([]byte(chall.ProvidedKeyAuthorization))
 	badDigest := sha256.Sum256([]byte(chall2.ProvidedKeyAuthorization))
 
-	test.AssertContains(t, prob.Detail, string(core.ChallengeTypeTLSALPN01))
-	test.AssertContains(t, prob.Detail, hex.EncodeToString(expectedDigest[:]))
-	test.AssertContains(t, prob.Detail, hex.EncodeToString(badDigest[:]))
+	test.AssertContains(t, err.Error(), string(core.ChallengeTypeTLSALPN01))
+	test.AssertContains(t, err.Error(), hex.EncodeToString(expectedDigest[:]))
+	test.AssertContains(t, err.Error(), hex.EncodeToString(badDigest[:]))
 }
 
 func TestValidateTLSALPN01BrokenSrv(t *testing.T) {
@@ -457,10 +466,11 @@ func TestValidateTLSALPN01BrokenSrv(t *testing.T) {
 
 	va, _ := setup(hs, 0, "", nil, nil)
 
-	_, prob := va.validateTLSALPN01(ctx, dnsi("expected"), chall)
-	if prob == nil {
+	_, err := va.validateTLSALPN01(ctx, dnsi("expected"), chall)
+	if err == nil {
 		t.Fatalf("TLS ALPN validation should have failed.")
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.TLSProblem)
 }
 
@@ -470,10 +480,11 @@ func TestValidateTLSALPN01UnawareSrv(t *testing.T) {
 
 	va, _ := setup(hs, 0, "", nil, nil)
 
-	_, prob := va.validateTLSALPN01(ctx, dnsi("expected"), chall)
-	if prob == nil {
+	_, err := va.validateTLSALPN01(ctx, dnsi("expected"), chall)
+	if err == nil {
 		t.Fatalf("TLS ALPN validation should have failed.")
 	}
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.TLSProblem)
 }
 
@@ -522,14 +533,15 @@ func TestValidateTLSALPN01MalformedExtnValue(t *testing.T) {
 		hs := tlsalpn01SrvWithCert(t, acmeCert, 0)
 		va, _ := setup(hs, 0, "", nil, nil)
 
-		_, prob := va.validateTLSALPN01(ctx, dnsi("expected"), chall)
+		_, err := va.validateTLSALPN01(ctx, dnsi("expected"), chall)
 		hs.Close()
 
-		if prob == nil {
+		if err == nil {
 			t.Errorf("TLS ALPN validation should have failed for acmeValidation extension %+v.",
 				badExt)
 			continue
 		}
+		prob := detailedError(err)
 		test.AssertEquals(t, prob.Type, probs.UnauthorizedProblem)
 		test.AssertContains(t, prob.Detail, string(core.ChallengeTypeTLSALPN01))
 		test.AssertContains(t, prob.Detail, "malformed acmeValidationV1 extension value")
@@ -660,9 +672,9 @@ func TestTLSALPN01NotSelfSigned(t *testing.T) {
 
 	va, _ := setup(hs, 0, "", nil, nil)
 
-	_, prob := va.validateChallenge(ctx, dnsi("expected"), chall)
-	test.AssertError(t, prob, "validation should have failed")
-	test.AssertContains(t, prob.Detail, "not self-signed")
+	_, err = va.validateChallenge(ctx, dnsi("expected"), chall)
+	test.AssertError(t, err, "validation should have failed")
+	test.AssertContains(t, err.Error(), "not self-signed")
 }
 
 func TestTLSALPN01ExtraIdentifiers(t *testing.T) {
@@ -767,10 +779,11 @@ func TestTLSALPN01ExtraSANs(t *testing.T) {
 
 	va, _ := setup(hs, 0, "", nil, nil)
 
-	_, prob := va.validateChallenge(ctx, dnsi("expected"), chall)
-	test.AssertError(t, prob, "validation should have failed")
+	_, err = va.validateChallenge(ctx, dnsi("expected"), chall)
+	test.AssertError(t, err, "validation should have failed")
 	// In go >= 1.19, the TLS client library detects that the certificate has
 	// a duplicate extension and terminates the connection itself.
+	prob := detailedError(err)
 	test.AssertContains(t, prob.Error(), "Error getting validation data")
 }
 
@@ -822,8 +835,9 @@ func TestTLSALPN01ExtraAcmeExtensions(t *testing.T) {
 
 	va, _ := setup(hs, 0, "", nil, nil)
 
-	_, prob := va.validateChallenge(ctx, dnsi("expected"), chall)
-	test.AssertError(t, prob, "validation should have failed")
+	_, err = va.validateChallenge(ctx, dnsi("expected"), chall)
+	test.AssertError(t, err, "validation should have failed")
+	prob := detailedError(err)
 	// In go >= 1.19, the TLS client library detects that the certificate has
 	// a duplicate extension and terminates the connection itself.
 	test.AssertContains(t, prob.Error(), "Error getting validation data")
diff --git a/va/va_test.go b/va/va_test.go
index 0555b473b..274867607 100644
--- a/va/va_test.go
+++ b/va/va_test.go
@@ -257,8 +257,9 @@ func (inmem inMemVA) IsCAAValid(ctx context.Context, req *vapb.IsCAAValidRequest
 func TestValidateMalformedChallenge(t *testing.T) {
 	va, _ := setup(nil, 0, "", nil, nil)
 
-	_, prob := va.validateChallenge(ctx, dnsi("example.com"), createChallenge("fake-type-01"))
+	_, err := va.validateChallenge(ctx, dnsi("example.com"), createChallenge("fake-type-01"))
 
+	prob := detailedError(err)
 	test.AssertEquals(t, prob.Type, probs.MalformedProblem)
 }
 
@@ -782,14 +783,12 @@ func TestLogRemoteDifferentials(t *testing.T) {
 	egProbB := probs.OrderNotReady("please take a number")
 
 	testCases := []struct {
-		name          string
-		primaryResult *probs.ProblemDetails
-		remoteProbs   []*remoteVAResult
-		expectedLog   string
+		name        string
+		remoteProbs []*remoteVAResult
+		expectedLog string
 	}{
 		{
-			name:          "remote and primary results equal (all nil)",
-			primaryResult: nil,
+			name: "all results equal (nil)",
 			remoteProbs: []*remoteVAResult{
 				{Problem: nil, VAHostname: "remoteA"},
 				{Problem: nil, VAHostname: "remoteB"},
@@ -797,33 +796,22 @@ func TestLogRemoteDifferentials(t *testing.T) {
 			},
 		},
 		{
-			name:          "remote and primary results equal (not nil)",
-			primaryResult: egProbA,
+			name: "all results equal (not nil)",
 			remoteProbs: []*remoteVAResult{
 				{Problem: egProbA, VAHostname: "remoteA"},
 				{Problem: egProbA, VAHostname: "remoteB"},
 				{Problem: egProbA, VAHostname: "remoteC"},
 			},
+			expectedLog: `INFO: remoteVADifferentials JSON={"Domain":"example.com","AccountID":1999,"ChallengeType":"blorpus-01","RemoteSuccesses":0,"RemoteFailures":[{"VAHostname":"remoteA","Problem":{"type":"dns","detail":"root DNS servers closed at 4:30pm","status":400}},{"VAHostname":"remoteB","Problem":{"type":"dns","detail":"root DNS servers closed at 4:30pm","status":400}},{"VAHostname":"remoteC","Problem":{"type":"dns","detail":"root DNS servers closed at 4:30pm","status":400}}]}`,
 		},
 		{
-			name:          "remote and primary differ (primary nil)",
-			primaryResult: nil,
-			remoteProbs: []*remoteVAResult{
-				{Problem: egProbA, VAHostname: "remoteA"},
-				{Problem: nil, VAHostname: "remoteB"},
-				{Problem: egProbB, VAHostname: "remoteC"},
-			},
-			expectedLog: `INFO: remoteVADifferentials JSON={"Domain":"example.com","AccountID":1999,"ChallengeType":"blorpus-01","PrimaryResult":null,"RemoteSuccesses":1,"RemoteFailures":[{"VAHostname":"remoteA","Problem":{"type":"dns","detail":"root DNS servers closed at 4:30pm","status":400}},{"VAHostname":"remoteC","Problem":{"type":"orderNotReady","detail":"please take a number","status":403}}]}`,
-		},
-		{
-			name:          "remote and primary differ (primary not nil)",
-			primaryResult: egProbA,
+			name: "differing results, some non-nil",
 			remoteProbs: []*remoteVAResult{
 				{Problem: nil, VAHostname: "remoteA"},
 				{Problem: egProbB, VAHostname: "remoteB"},
 				{Problem: nil, VAHostname: "remoteC"},
 			},
-			expectedLog: `INFO: remoteVADifferentials JSON={"Domain":"example.com","AccountID":1999,"ChallengeType":"blorpus-01","PrimaryResult":{"type":"dns","detail":"root DNS servers closed at 4:30pm","status":400},"RemoteSuccesses":2,"RemoteFailures":[{"VAHostname":"remoteB","Problem":{"type":"orderNotReady","detail":"please take a number","status":403}}]}`,
+			expectedLog: `INFO: remoteVADifferentials JSON={"Domain":"example.com","AccountID":1999,"ChallengeType":"blorpus-01","RemoteSuccesses":2,"RemoteFailures":[{"VAHostname":"remoteB","Problem":{"type":"orderNotReady","detail":"please take a number","status":403}}]}`,
 		},
 	}
 
@@ -832,7 +820,7 @@ func TestLogRemoteDifferentials(t *testing.T) {
 			mockLog.Clear()
 
 			localVA.logRemoteDifferentials(
-				"example.com", 1999, "blorpus-01", tc.primaryResult, tc.remoteProbs)
+				"example.com", 1999, "blorpus-01", tc.remoteProbs)
 
 			lines := mockLog.GetAllMatching("remoteVADifferentials JSON=.*")
 			if tc.expectedLog != "" {