Skip to content

Commit

Permalink
issuance: add CRLDistributionPoints to certs (#7974)
Browse files Browse the repository at this point in the history
The CRLDP is included only when the profile's
IncludeCRLDistributionPoints field is true.

Introduce a new config field for issuers, CRLShards. If
IncludeCRLDistributionPoints is true and this is zero, issuance will
error.

The CRL shard is assigned at issuance time based on the (random) low
bits of the serial number.

Part of #7094
  • Loading branch information
jsha authored Jan 30, 2025
1 parent c5a28cd commit f11475c
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 23 deletions.
12 changes: 11 additions & 1 deletion cmd/boulder-ca/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package notmain
import (
"context"
"flag"
"fmt"
"os"
"strconv"
"time"
Expand Down Expand Up @@ -176,10 +177,19 @@ func main() {
}

clk := cmd.Clock()
var crlShards int
issuers := make([]*issuance.Issuer, 0, len(c.CA.Issuance.Issuers))
for _, issuerConfig := range c.CA.Issuance.Issuers {
for i, issuerConfig := range c.CA.Issuance.Issuers {
issuer, err := issuance.LoadIssuer(issuerConfig, clk)
cmd.FailOnError(err, "Loading issuer")
// All issuers should have the same number of CRL shards, because
// crl-updater assumes they all have the same number.
if issuerConfig.CRLShards != 0 && crlShards == 0 {
crlShards = issuerConfig.CRLShards
}
if issuerConfig.CRLShards != crlShards {
cmd.Fail(fmt.Sprintf("issuer %d has %d shards, want %d", i, issuerConfig.CRLShards, crlShards))
}
issuers = append(issuers, issuer)
logger.Infof("Loaded issuer: name=[%s] keytype=[%s] nameID=[%v] isActive=[%t]", issuer.Name(), issuer.KeyType(), issuer.NameID(), issuer.IsActive())
}
Expand Down
37 changes: 25 additions & 12 deletions issuance/cert.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ type Profile struct {
omitClientAuth bool
omitSKID bool

includeCRLDistributionPoints bool

maxBackdate time.Duration
maxValidity time.Duration

Expand Down Expand Up @@ -218,15 +220,16 @@ func NewProfile(profileConfig *ProfileConfigNew) (*Profile, error) {
}

sp := &Profile{
allowMustStaple: profileConfig.AllowMustStaple,
omitCommonName: profileConfig.OmitCommonName,
omitKeyEncipherment: profileConfig.OmitKeyEncipherment,
omitClientAuth: profileConfig.OmitClientAuth,
omitSKID: profileConfig.OmitSKID,
maxBackdate: profileConfig.MaxValidityBackdate.Duration,
maxValidity: profileConfig.MaxValidityPeriod.Duration,
lints: lints,
hash: hash,
allowMustStaple: profileConfig.AllowMustStaple,
omitCommonName: profileConfig.OmitCommonName,
omitKeyEncipherment: profileConfig.OmitKeyEncipherment,
omitClientAuth: profileConfig.OmitClientAuth,
omitSKID: profileConfig.OmitSKID,
includeCRLDistributionPoints: profileConfig.IncludeCRLDistributionPoints,
maxBackdate: profileConfig.MaxValidityBackdate.Duration,
maxValidity: profileConfig.MaxValidityPeriod.Duration,
lints: lints,
hash: hash,
}

return sp, nil
Expand Down Expand Up @@ -311,9 +314,6 @@ func (i *Issuer) generateTemplate() *x509.Certificate {
PolicyIdentifiers: []asn1.ObjectIdentifier{{2, 23, 140, 1, 2, 1}},
}

// TODO(#7294): Use i.crlURLBase and a shard calculation to create a
// crlDistributionPoint.

return template
}

Expand Down Expand Up @@ -488,6 +488,19 @@ func (i *Issuer) Prepare(prof *Profile, req *IssuanceRequest) ([]byte, *issuance
return nil, nil, errors.New("invalid request contains neither sctList nor precertDER")
}

// If explicit CRL sharding is enabled, pick a shard based on the serial number
// modulus the number of shards. This gives us random distribution that is
// nonetheless consistent between precert and cert.
if prof.includeCRLDistributionPoints {
if i.crlShards <= 0 {
return nil, nil, errors.New("IncludeCRLDistributionPoints was set but CRLShards was not set")
}
shardZeroBased := big.NewInt(0).Mod(template.SerialNumber, big.NewInt(int64(i.crlShards)))
shard := int(shardZeroBased.Int64()) + 1
url := i.crlURL(shard)
template.CRLDistributionPoints = []string{url}
}

if req.IncludeMustStaple {
template.ExtraExtensions = append(template.ExtraExtensions, mustStapleExt)
}
Expand Down
58 changes: 58 additions & 0 deletions issuance/cert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"encoding/asn1"
"encoding/base64"
"fmt"
"reflect"
"testing"
"time"

Expand Down Expand Up @@ -70,6 +71,18 @@ func TestGenerateValidity(t *testing.T) {
}
}

func TestCRLURL(t *testing.T) {
issuer, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, clock.NewFake())
if err != nil {
t.Fatalf("newIssuer: %s", err)
}
url := issuer.crlURL(4928)
want := "http://crl-url.example.org/4928.crl"
if url != want {
t.Errorf("crlURL(4928)=%s, want %s", url, want)
}
}

func TestRequestValid(t *testing.T) {
fc := clock.NewFake()
fc.Add(time.Hour * 24)
Expand Down Expand Up @@ -380,10 +393,55 @@ func TestIssue(t *testing.T) {
test.AssertDeepEquals(t, cert.PublicKey, pk.Public())
test.AssertEquals(t, len(cert.Extensions), 9) // Constraints, KU, EKU, SKID, AKID, AIA, SAN, Policies, Poison
test.AssertEquals(t, cert.KeyUsage, tc.ku)
if len(cert.CRLDistributionPoints) > 0 {
t.Errorf("want CRLDistributionPoints=[], got %v", cert.CRLDistributionPoints)
}
})
}
}

func TestIssueWithCRLDP(t *testing.T) {
fc := clock.NewFake()
issuerConfig := defaultIssuerConfig()
issuerConfig.CRLURLBase = "http://crls.example.net/"
issuerConfig.CRLShards = 999
signer, err := newIssuer(issuerConfig, issuerCert, issuerSigner, fc)
if err != nil {
t.Fatalf("newIssuer: %s", err)
}
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %s", err)
}
profile := defaultProfile()
profile.includeCRLDistributionPoints = true
_, issuanceToken, err := signer.Prepare(profile, &IssuanceRequest{
PublicKey: MarshalablePublicKey{pk.Public()},
SubjectKeyId: goodSKID,
Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9},
DNSNames: []string{"example.com"},
NotBefore: fc.Now(),
NotAfter: fc.Now().Add(time.Hour - time.Second),
IncludeCTPoison: true,
})
if err != nil {
t.Fatalf("signer.Prepare: %s", err)
}
certBytes, err := signer.Issue(issuanceToken)
if err != nil {
t.Fatalf("signer.Issue: %s", err)
}
cert, err := x509.ParseCertificate(certBytes)
if err != nil {
t.Fatalf("x509.ParseCertificate: %s", err)
}
// Because CRL shard is calculated deterministically from serial, we know which shard will be chosen.
expectedCRLDP := []string{"http://crls.example.net/919.crl"}
if !reflect.DeepEqual(cert.CRLDistributionPoints, expectedCRLDP) {
t.Errorf("CRLDP=%+v, want %+v", cert.CRLDistributionPoints, expectedCRLDP)
}
}

func TestIssueCommonName(t *testing.T) {
fc := clock.NewFake()
fc.Set(time.Now())
Expand Down
7 changes: 6 additions & 1 deletion issuance/crl.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ type CRLRequest struct {
Entries []x509.RevocationListEntry
}

// crlURL combines the CRL URL base with a shard, and adds a suffix.
func (i *Issuer) crlURL(shard int) string {
return fmt.Sprintf("%s%d.crl", i.crlURLBase, shard)
}

func (i *Issuer) IssueCRL(prof *CRLProfile, req *CRLRequest) ([]byte, error) {
backdatedBy := i.clk.Now().Sub(req.ThisUpdate)
if backdatedBy > prof.maxBackdate {
Expand All @@ -82,7 +87,7 @@ func (i *Issuer) IssueCRL(prof *CRLProfile, req *CRLRequest) ([]byte, error) {
// Concat the base with the shard directly, since we require that the base
// end with a single trailing slash.
idp, err := idp.MakeUserCertsExt([]string{
fmt.Sprintf("%s%d.crl", i.crlURLBase, req.Shard),
i.crlURL(int(req.Shard)),
})
if err != nil {
return nil, fmt.Errorf("creating IDP extension: %w", err)
Expand Down
12 changes: 10 additions & 2 deletions issuance/issuer.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,12 @@ type IssuerConfig struct {

IssuerURL string `validate:"required,url"`
OCSPURL string `validate:"required,url"`
CRLURLBase string `validate:"omitempty,url,startswith=http://,endswith=/"`
CRLURLBase string `validate:"required,url,startswith=http://,endswith=/"`

// Number of CRL shards.
// This must be nonzero if adding CRLDistributionPoints to certificates
// (that is, if profile.IncludeCRLDistributionPoints is true).
CRLShards int

Location IssuerLoc
}
Expand Down Expand Up @@ -204,9 +209,11 @@ type Issuer struct {
// certificates.
ocspURL string
// Used to set the Issuing Distribution Point extension in issued CRLs
// *and* (eventually) the CRL Distribution Point extension in issued certs.
// and the CRL Distribution Point extension in issued certs.
crlURLBase string

crlShards int

clk clock.Clock
}

Expand Down Expand Up @@ -276,6 +283,7 @@ func newIssuer(config IssuerConfig, cert *Certificate, signer crypto.Signer, clk
issuerURL: config.IssuerURL,
ocspURL: config.OCSPURL,
crlURLBase: config.CRLURLBase,
crlShards: config.CRLShards,
clk: clk,
}
return i, nil
Expand Down
31 changes: 31 additions & 0 deletions test/config-next/ca.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@
"defaultCertificateProfileName": "legacy",
"certProfiles": {
"legacy": {
"allowMustStaple": true,
"maxValidityPeriod": "7776000s",
"maxValidityBackdate": "1h5m",
"includeCRLDistributionPoints": true,
"lintConfig": "test/config-next/zlint.toml",
"ignoredLints": [
"w_subject_common_name_included",
"w_ext_subject_key_identifier_not_recommended_subscriber"
]
},
"legacyRenamedPriorToDeletion": {
"allowMustStaple": true,
"maxValidityPeriod": "7776000s",
"maxValidityBackdate": "1h5m",
Expand All @@ -55,6 +66,20 @@
]
},
"modern": {
"allowMustStaple": true,
"omitCommonName": true,
"omitKeyEncipherment": true,
"omitClientAuth": true,
"omitSKID": true,
"includeCRLDistributionPoints": true,
"maxValidityPeriod": "583200s",
"maxValidityBackdate": "1h5m",
"lintConfig": "test/config-next/zlint.toml",
"ignoredLints": [
"w_ext_subject_key_identifier_missing_sub_cert"
]
},
"modernRenamedPriorToDeletion": {
"allowMustStaple": true,
"omitCommonName": true,
"omitKeyEncipherment": true,
Expand All @@ -75,6 +100,7 @@
"issuers": [
{
"active": true,
"crlShards": 10,
"issuerURL": "http://ca.example.org:4502/int-ecdsa-a",
"ocspURL": "http://ca.example.org:4002/",
"crlURLBase": "http://ca.example.org:4501/lets-encrypt-crls/43104258997432926/",
Expand All @@ -86,6 +112,7 @@
},
{
"active": true,
"crlShards": 10,
"issuerURL": "http://ca.example.org:4502/int-ecdsa-b",
"ocspURL": "http://ca.example.org:4002/",
"crlURLBase": "http://ca.example.org:4501/lets-encrypt-crls/17302365692836921/",
Expand All @@ -97,6 +124,7 @@
},
{
"active": false,
"crlShards": 10,
"issuerURL": "http://ca.example.org:4502/int-ecdsa-c",
"ocspURL": "http://ca.example.org:4002/",
"crlURLBase": "http://ca.example.org:4501/lets-encrypt-crls/56560759852043581/",
Expand All @@ -108,6 +136,7 @@
},
{
"active": true,
"crlShards": 10,
"issuerURL": "http://ca.example.org:4502/int-rsa-a",
"ocspURL": "http://ca.example.org:4002/",
"crlURLBase": "http://ca.example.org:4501/lets-encrypt-crls/29947985078257530/",
Expand All @@ -119,6 +148,7 @@
},
{
"active": true,
"crlShards": 10,
"issuerURL": "http://ca.example.org:4502/int-rsa-b",
"ocspURL": "http://ca.example.org:4002/",
"crlURLBase": "http://ca.example.org:4501/lets-encrypt-crls/6762885421992935/",
Expand All @@ -130,6 +160,7 @@
},
{
"active": false,
"crlShards": 10,
"issuerURL": "http://ca.example.org:4502/int-rsa-c",
"ocspURL": "http://ca.example.org:4002/",
"crlURLBase": "http://ca.example.org:4501/lets-encrypt-crls/56183656833365902/",
Expand Down
31 changes: 24 additions & 7 deletions test/integration/revocation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,28 +127,45 @@ func getAllCRLs(t *testing.T) map[string][]*x509.RevocationList {
return ret
}

func checkRevoked(t *testing.T, revocations map[string][]*x509.RevocationList, cert *x509.Certificate, reason int) {
func checkRevoked(t *testing.T, revocations map[string][]*x509.RevocationList, cert *x509.Certificate, expectedReason int) {
t.Helper()
akid := hex.EncodeToString(cert.AuthorityKeyId)
if len(revocations[akid]) == 0 {
t.Errorf("no CRLs found for authorityKeyID %s", akid)
}
var matches []x509.RevocationListEntry
var matchingCRLs []string
var count int
for _, list := range revocations[akid] {
for _, entry := range list.RevokedCertificateEntries {
count++
if entry.SerialNumber.Cmp(cert.SerialNumber) == 0 {
matches = append(matches, entry)
idpURIs, err := idp.GetIDPURIs(list.Extensions)
if err != nil {
t.Errorf("getting IDP URIs: %s", err)
}
idpURI := idpURIs[0]
if entry.ReasonCode != expectedReason {
t.Errorf("revoked certificate %x in CRL %s: revocation reason %d, want %d", cert.SerialNumber, idpURI, entry.ReasonCode, expectedReason)
}
matchingCRLs = append(matchingCRLs, idpURI)
}
}
}
if len(matches) == 0 {
if len(matchingCRLs) == 0 {
t.Errorf("searching for %x in CRLs: no entry on combined CRLs of length %d", cert.SerialNumber, count)
}
for _, match := range matches {
if match.ReasonCode != reason {
t.Errorf("revoked certificate %x: got reason %d, want %d", cert.SerialNumber, match.ReasonCode, reason)

// If the cert has a CRLDP, it must be listed on the CRL served at that URL.
if len(cert.CRLDistributionPoints) > 0 {
expectedCRLDP := cert.CRLDistributionPoints[0]
found := false
for _, crl := range matchingCRLs {
if crl == expectedCRLDP {
found = true
}
}
if !found {
t.Errorf("revoked certificate %x: seen on CRLs %s, want to see on CRL %s", cert.SerialNumber, matchingCRLs, expectedCRLDP)
}
}
}
Expand Down

0 comments on commit f11475c

Please sign in to comment.