Skip to content

Commit 868214b

Browse files
authored
CRLs: include IssuingDistributionPoint extension (#6412)
Add the Issuing Distribution Point extension to all of our end-entity CRLs. The extension contains the Distribution Point, the URL from which this CRL is meant to be downloaded. Because our CRLs are sharded, this URL prevents an on-path attacker from substituting a different shard than the client expected in order to hide a revocation. The extension also contains the OnlyContainsUserCerts boolean, because our CRLs only contain end-entity certificates. The Distribution Point url is constructed from a configurable base URI, the issuer's NameID, the shard index, and the suffix ".crl". The base URI must use the "http://" scheme and must not end with a slash. openssl displays the IDP extension as: ``` X509v3 Issuing Distribution Point: critical Full Name: URI:http://c.boulder.test/66283756913588288/0.crl Only User Certificates ``` Fixes #6410
1 parent ab4b1eb commit 868214b

File tree

14 files changed

+297
-36
lines changed

14 files changed

+297
-36
lines changed

ca/ca_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ func setup(t *testing.T) *testCtx {
263263
crl, err := NewCRLImpl(
264264
boulderIssuers,
265265
time.Hour,
266+
"http://c.boulder.test",
266267
100,
267268
blog.NewMock(),
268269
)

ca/crl.go

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package ca
33
import (
44
"crypto/rand"
55
"crypto/sha256"
6+
"crypto/x509/pkix"
7+
"encoding/asn1"
68
"errors"
79
"fmt"
810
"io"
@@ -22,11 +24,17 @@ type crlImpl struct {
2224
capb.UnimplementedCRLGeneratorServer
2325
issuers map[issuance.IssuerNameID]*issuance.Issuer
2426
lifetime time.Duration
27+
idpBase string
2528
maxLogLen int
2629
log blog.Logger
2730
}
2831

29-
func NewCRLImpl(issuers []*issuance.Issuer, lifetime time.Duration, maxLogLen int, logger blog.Logger) (*crlImpl, error) {
32+
// NewCRLImpt returns a new object which fulfils the ca.proto CRLGenerator
33+
// interface. It uses the list of issuers to determine what issuers it can
34+
// issue CRLs from. lifetime sets the validity period (inclusive) of the
35+
// resulting CRLs. idpBase is the base URL from which IssuingDistributionPoint
36+
// URIs will constructed; it must use the http:// scheme.
37+
func NewCRLImpl(issuers []*issuance.Issuer, lifetime time.Duration, idpBase string, maxLogLen int, logger blog.Logger) (*crlImpl, error) {
3038
issuersByNameID := make(map[issuance.IssuerNameID]*issuance.Issuer, len(issuers))
3139
for _, issuer := range issuers {
3240
issuersByNameID[issuer.Cert.NameID()] = issuer
@@ -41,9 +49,17 @@ func NewCRLImpl(issuers []*issuance.Issuer, lifetime time.Duration, maxLogLen in
4149
return nil, fmt.Errorf("crl lifetime must be positive, got %q", lifetime)
4250
}
4351

52+
if !strings.HasPrefix(idpBase, "http://") {
53+
return nil, fmt.Errorf("issuingDistributionPoint base URI must use http:// scheme, got %q", idpBase)
54+
}
55+
if strings.HasSuffix(idpBase, "/") {
56+
return nil, fmt.Errorf("issuingDistributionPoint base URI must not end with a slash, got %q", idpBase)
57+
}
58+
4459
return &crlImpl{
4560
issuers: issuersByNameID,
4661
lifetime: lifetime,
62+
idpBase: idpBase,
4763
maxLogLen: maxLogLen,
4864
log: logger,
4965
}, nil
@@ -100,6 +116,13 @@ func (ci *crlImpl) GenerateCRL(stream capb.CRLGenerator_GenerateCRLServer) error
100116
return errors.New("no crl metadata received")
101117
}
102118

119+
// Add the Issuing Distribution Point extension.
120+
idp, err := makeIDPExt(ci.idpBase, issuer.Cert.NameID(), shard)
121+
if err != nil {
122+
return fmt.Errorf("creating IDP extension: %w", err)
123+
}
124+
template.ExtraExtensions = append(template.ExtraExtensions, *idp)
125+
103126
// Compute a unique ID for this issuer-number-shard combo, to tie together all
104127
// the audit log lines related to its issuance.
105128
logID := blog.LogLineChecksum(fmt.Sprintf("%d", issuer.Cert.NameID()) + template.Number.String() + fmt.Sprintf("%d", shard))
@@ -133,7 +156,7 @@ func (ci *crlImpl) GenerateCRL(stream capb.CRLGenerator_GenerateCRLServer) error
133156

134157
template.RevokedCertificates = rcs
135158

136-
err := issuer.Linter.CheckCRL(template)
159+
err = issuer.Linter.CheckCRL(template)
137160
if err != nil {
138161
return err
139162
}
@@ -185,7 +208,6 @@ func (ci *crlImpl) metadataToTemplate(meta *capb.CRLMetadata) (*crl_x509.Revocat
185208
ThisUpdate: thisUpdate,
186209
NextUpdate: thisUpdate.Add(-time.Second).Add(ci.lifetime),
187210
}, nil
188-
189211
}
190212

191213
func (ci *crlImpl) entryToRevokedCertificate(entry *corepb.CRLEntry) (*crl_x509.RevokedCertificate, error) {
@@ -211,3 +233,51 @@ func (ci *crlImpl) entryToRevokedCertificate(entry *corepb.CRLEntry) (*crl_x509.
211233
ReasonCode: reason,
212234
}, nil
213235
}
236+
237+
// distributionPointName represents the ASN.1 DistributionPointName CHOICE as
238+
// defined in RFC 5280 Section 4.2.1.13. We only use one of the fields, so the
239+
// others are omitted.
240+
type distributionPointName struct {
241+
// Technically, FullName is of type GeneralNames, which is of type SEQUENCE OF
242+
// GeneralName. But GeneralName itself is of type CHOICE, and the ans1.Marhsal
243+
// function doesn't support marshalling structs to CHOICEs, so we have to use
244+
// asn1.RawValue and encode the GeneralName ourselves.
245+
FullName []asn1.RawValue `asn1:"optional,tag:0"`
246+
}
247+
248+
// issuingDistributionPoint represents the ASN.1 IssuingDistributionPoint
249+
// SEQUENCE as defined in RFC 5280 Section 5.2.5. We only use two of the fields,
250+
// so the others are omitted.
251+
type issuingDistributionPoint struct {
252+
DistributionPoint distributionPointName `asn1:"optional,tag:0"`
253+
OnlyContainsUserCerts bool `asn1:"optional,tag:1"`
254+
}
255+
256+
// makeIDPExt returns a critical IssuingDistributionPoint extension containing a
257+
// URI built from the base url, the issuer's NameID, and the shard number. It
258+
// also sets the OnlyContainsUserCerts boolean to true.
259+
func makeIDPExt(base string, issuer issuance.IssuerNameID, shardIdx int64) (*pkix.Extension, error) {
260+
val := issuingDistributionPoint{
261+
DistributionPoint: distributionPointName{
262+
[]asn1.RawValue{ // GeneralNames
263+
{ // GeneralName
264+
Class: 2, // context-specific
265+
Tag: 6, // uniformResourceIdentifier, IA5String
266+
Bytes: []byte(fmt.Sprintf("%s/%d/%d.crl", base, issuer, shardIdx)),
267+
},
268+
},
269+
},
270+
OnlyContainsUserCerts: true,
271+
}
272+
273+
valBytes, err := asn1.Marshal(val)
274+
if err != nil {
275+
return nil, err
276+
}
277+
278+
return &pkix.Extension{
279+
Id: asn1.ObjectIdentifier{2, 5, 29, 28}, // id-ce-issuingDistributionPoint
280+
Value: valBytes,
281+
Critical: true,
282+
}, nil
283+
}

cmd/boulder-ca/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ func main() {
294294
crli, err := ca.NewCRLImpl(
295295
boulderIssuers,
296296
c.CA.LifespanCRL.Duration,
297+
c.CA.CRLDPBase,
297298
c.CA.OCSPLogMaxLength,
298299
logger,
299300
)

linter/lints/crl/lints.go

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/asn1"
66
"errors"
77
"fmt"
8+
"net/url"
89
"time"
910

1011
"github.com/zmap/zlint/v3"
@@ -36,7 +37,7 @@ func init() {
3637
"hasAKI": hasAKI,
3738
"hasNumber": hasNumber,
3839
"isNotDelta": isNotDelta,
39-
"hasNoIDP": hasNoIDP,
40+
"checkIDP": checkIDP,
4041
"hasNoFreshest": hasNoFreshest,
4142
"hasNoAIA": hasNoAIA,
4243
"noZeroReasonCodes": noZeroReasonCodes,
@@ -219,17 +220,125 @@ func isNotDelta(crl *crl_x509.RevocationList) *lint.LintResult {
219220
return &lint.LintResult{Status: lint.Pass}
220221
}
221222

222-
// hasNoIDP checks that the CRL does not have an Issuing Distribution Point
223-
// extension (RFC 5280, Section 5.2.5). There's no requirement against this, but
224-
// IDPs come with extra requirements we don't want to deal with.
225-
func hasNoIDP(crl *crl_x509.RevocationList) *lint.LintResult {
223+
// checkIDP checks that the CRL does have an Issuing Distribution Point, that it
224+
// is critical, that it contains a single http distributionPointName, that it
225+
// asserts the onlyContainsUserCerts boolean, and that it does not contain any
226+
// of the other fields. (RFC 5280, Section 5.2.5).
227+
func checkIDP(crl *crl_x509.RevocationList) *lint.LintResult {
226228
idpOID := asn1.ObjectIdentifier{2, 5, 29, 28} // id-ce-issuingDistributionPoint
227-
if getExtWithOID(crl.Extensions, idpOID) != nil {
229+
idpe := getExtWithOID(crl.Extensions, idpOID)
230+
if idpe == nil {
228231
return &lint.LintResult{
229-
Status: lint.Notice,
230-
Details: "CRL has an Issuing Distribution Point url",
232+
Status: lint.Warn,
233+
Details: "CRL missing IDP",
234+
}
235+
}
236+
if !idpe.Critical {
237+
return &lint.LintResult{
238+
Status: lint.Error,
239+
Details: "IDP MUST be critical",
240+
}
241+
}
242+
243+
// Step inside the outer issuingDistributionPoint sequence to get access to
244+
// its constituent fields, DistributionPoint and OnlyContainsUserCerts.
245+
idpv := cryptobyte.String(idpe.Value)
246+
if !idpv.ReadASN1(&idpv, cryptobyte_asn1.SEQUENCE) {
247+
return &lint.LintResult{
248+
Status: lint.Warn,
249+
Details: "Failed to read issuingDistributionPoint",
250+
}
251+
}
252+
253+
// Ensure that the DistributionPoint is a reasonable URI. To get to the URI,
254+
// we have to step inside the DistributionPointName, then step inside that's
255+
// FullName, and finally read the singular SEQUENCE OF GeneralName element.
256+
if !idpv.PeekASN1Tag(cryptobyte_asn1.Tag(0).ContextSpecific().Constructed()) {
257+
return &lint.LintResult{
258+
Status: lint.Warn,
259+
Details: "IDP should contain distributionPoint",
260+
}
261+
}
262+
263+
var dpName cryptobyte.String
264+
if !idpv.ReadASN1(&dpName, cryptobyte_asn1.Tag(0).ContextSpecific().Constructed()) {
265+
return &lint.LintResult{
266+
Status: lint.Warn,
267+
Details: "Failed to read IDP distributionPoint",
268+
}
269+
}
270+
271+
if !dpName.ReadASN1(&dpName, cryptobyte_asn1.Tag(0).ContextSpecific().Constructed()) {
272+
return &lint.LintResult{
273+
Status: lint.Warn,
274+
Details: "Failed to read IDP distributionPoint fullName",
231275
}
232276
}
277+
278+
fmt.Printf("%x\n", dpName)
279+
uriBytes := make([]byte, 0)
280+
if !dpName.ReadASN1Bytes(&uriBytes, cryptobyte_asn1.Tag(6).ContextSpecific()) {
281+
return &lint.LintResult{
282+
Status: lint.Warn,
283+
Details: "Failed to read IDP URI",
284+
}
285+
}
286+
287+
uri, err := url.Parse(string(uriBytes))
288+
if err != nil {
289+
return &lint.LintResult{
290+
Status: lint.Error,
291+
Details: "Failed to parse IDP URI",
292+
}
293+
}
294+
295+
if uri.Scheme != "http" {
296+
return &lint.LintResult{
297+
Status: lint.Error,
298+
Details: "IDP URI MUST use http scheme",
299+
}
300+
}
301+
302+
if !dpName.Empty() {
303+
return &lint.LintResult{
304+
Status: lint.Warn,
305+
Details: "IDP should contain only one distributionPoint",
306+
}
307+
}
308+
309+
// Ensure that OnlyContainsUserCerts is True. We have to read this boolean as
310+
// a byte and ensure its value is 0xFF because cryptobyte.ReadASN1Boolean
311+
// can't handle custom encoding rules like this field's [1] tag.
312+
if !idpv.PeekASN1Tag(cryptobyte_asn1.Tag(1).ContextSpecific()) {
313+
return &lint.LintResult{
314+
Status: lint.Warn,
315+
Details: "IDP should contain onlyContainsUserCerts",
316+
}
317+
}
318+
319+
onlyContainsUserCerts := make([]byte, 0)
320+
if !idpv.ReadASN1Bytes(&onlyContainsUserCerts, cryptobyte_asn1.Tag(1).ContextSpecific()) {
321+
return &lint.LintResult{
322+
Status: lint.Error,
323+
Details: "Failed to read IDP onlyContainsUserCerts",
324+
}
325+
}
326+
327+
if len(onlyContainsUserCerts) != 1 || onlyContainsUserCerts[0] != 0xFF {
328+
return &lint.LintResult{
329+
Status: lint.Error,
330+
Details: "IDP should set onlyContainsUserCerts: TRUE",
331+
}
332+
}
333+
334+
// Ensure that no other fields are set.
335+
if !idpv.Empty() {
336+
return &lint.LintResult{
337+
Status: lint.Warn,
338+
Details: "IDP should not contain fields other than distributionPoint and onlyContainsUserCerts",
339+
}
340+
}
341+
233342
return &lint.LintResult{Status: lint.Pass}
234343
}
235344

linter/lints/crl/lints_test.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,35 @@ func TestIsNotDelta(t *testing.T) {
107107
test.AssertContains(t, res.Details, "Delta")
108108
}
109109

110-
func TestHasNoIDP(t *testing.T) {
110+
func TestCheckIDP(t *testing.T) {
111111
crl := loadPEMCRL(t, "testdata/good.pem")
112-
res := hasNoIDP(crl)
112+
res := checkIDP(crl)
113113
test.AssertEquals(t, res.Status, lint.Pass)
114114

115-
crl = loadPEMCRL(t, "testdata/idp.pem")
116-
res = hasNoIDP(crl)
117-
test.AssertEquals(t, res.Status, lint.Notice)
118-
test.AssertContains(t, res.Details, "Issuing Distribution Point")
115+
crl = loadPEMCRL(t, "testdata/no_idp.pem")
116+
res = checkIDP(crl)
117+
test.AssertEquals(t, res.Status, lint.Warn)
118+
test.AssertContains(t, res.Details, "missing IDP")
119+
120+
crl = loadPEMCRL(t, "testdata/idp_no_uri.pem")
121+
res = checkIDP(crl)
122+
test.AssertEquals(t, res.Status, lint.Warn)
123+
test.AssertContains(t, res.Details, "should contain distributionPoint")
124+
125+
crl = loadPEMCRL(t, "testdata/idp_two_uris.pem")
126+
res = checkIDP(crl)
127+
test.AssertEquals(t, res.Status, lint.Warn)
128+
test.AssertContains(t, res.Details, "only one distributionPoint")
129+
130+
crl = loadPEMCRL(t, "testdata/idp_no_usercerts.pem")
131+
res = checkIDP(crl)
132+
test.AssertEquals(t, res.Status, lint.Warn)
133+
test.AssertContains(t, res.Details, "should contain onlyContainsUserCerts")
134+
135+
crl = loadPEMCRL(t, "testdata/idp_some_reasons.pem")
136+
res = checkIDP(crl)
137+
test.AssertEquals(t, res.Status, lint.Warn)
138+
test.AssertContains(t, res.Details, "should not contain fields other than")
119139
}
120140

121141
func TestHasNoFreshest(t *testing.T) {

linter/lints/crl/testdata/good.pem

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
-----BEGIN X509 CRL-----
2-
MIIBRTCBzQIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM
3-
Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF
4-
MRcNMjIwNzA2MTY0MzM4WhcNMjIwNzE1MTY0MzM4WjAbMBkCCAOuUdtRFVo8Fw0y
5-
MjA3MDYxNTQzMzhaoDYwNDAfBgNVHSMEGDAWgBQB2rt6yyUgjl551vmWQi8CQSkH
6-
vjARBgNVHRQECgIIFv9LJt+yGA8wCgYIKoZIzj0EAwMDZwAwZAIwVrITRYutGjFp
7-
fNht08CLsAQSvnc4i6UM0Pi8+U3T8DRHImIiuB9cQ+qxULB6pKhBAjBbuGCwTop7
8-
vCfGO7Fz6N0ruITInFtt6BDR5izWUMfXXa7mXhSQ6ig9hOHOWRxR00I=
2+
MIIBmDCCAR8CAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT
3+
DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg
4+
RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN
5+
MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoHoweDAfBgNVHSMEGDAWgBQB2rt6
6+
yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwQgYDVR0cAQH/BDgw
7+
NqAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w
8+
LmNybIEB/zAKBggqhkjOPQQDAwNnADBkAjAvDkIUnTYavJ6h8606MDyFh2uw/cF+
9+
OVnM4sE8nUdGy0XYg0hGfbR4MY+kRxRQayICMFeQPpcpIr0zgXpP6lUXU0rcLSva
10+
tuaeQSVr24nGjZ7Py0vc94w0n7idZ8wje5+/Mw==
911
-----END X509 CRL-----
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-----BEGIN X509 CRL-----
2+
MIIBZDCB7AIBATAKBggqhkjOPQQDAzBJMQswCQYDVQQGEwJYWDEVMBMGA1UEChMM
3+
Qm91bGRlciBUZXN0MSMwIQYDVQQDExooVEVTVCkgRWxlZ2FudCBFbGVwaGFudCBF
4+
MRcNMjIxMDEwMjAxMjA3WhcNMjIxMDE5MjAxMjA2WjApMCcCCAOuUdtRFVo8Fw0y
5+
MjEwMTAxOTEyMDdaMAwwCgYDVR0VBAMKAQGgRzBFMB8GA1UdIwQYMBaAFAHau3rL
6+
JSCOXnnW+ZZCLwJBKQe+MBEGA1UdFAQKAggXHM495IK6YTAPBgNVHRwBAf8EBTAD
7+
gQH/MAoGCCqGSM49BAMDA2cAMGQCMC8OQhSdNhq8nqHzrTowPIWHa7D9wX45Wczi
8+
wTydR0bLRdiDSEZ9tHgxj6RHFFBrIgIwV5A+lykivTOBek/qVRdTStwtK9q25p5B
9+
JWvbicaNns/LS9z3jDSfuJ1nzCN7n78z
10+
-----END X509 CRL-----
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-----BEGIN X509 CRL-----
2+
MIIBlTCCARwCAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT
3+
DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg
4+
RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN
5+
MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoHcwdTAfBgNVHSMEGDAWgBQB2rt6
6+
yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwPwYDVR0cAQH/BDUw
7+
M6AxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w
8+
LmNybDAKBggqhkjOPQQDAwNnADBkAjAvDkIUnTYavJ6h8606MDyFh2uw/cF+OVnM
9+
4sE8nUdGy0XYg0hGfbR4MY+kRxRQayICMFeQPpcpIr0zgXpP6lUXU0rcLSvatuae
10+
QSVr24nGjZ7Py0vc94w0n7idZ8wje5+/Mw==
11+
-----END X509 CRL-----
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-----BEGIN X509 CRL-----
2+
MIIBnDCCASMCAQEwCgYIKoZIzj0EAwMwSTELMAkGA1UEBhMCWFgxFTATBgNVBAoT
3+
DEJvdWxkZXIgVGVzdDEjMCEGA1UEAxMaKFRFU1QpIEVsZWdhbnQgRWxlcGhhbnQg
4+
RTEXDTIyMTAxMDIwMTIwN1oXDTIyMTAxOTIwMTIwNlowKTAnAggDrlHbURVaPBcN
5+
MjIxMDEwMTkxMjA3WjAMMAoGA1UdFQQDCgEBoH4wfDAfBgNVHSMEGDAWgBQB2rt6
6+
yyUgjl551vmWQi8CQSkHvjARBgNVHRQECgIIFxzOPeSCumEwRgYDVR0cAQH/BDww
7+
OqAxoC+GLWh0dHA6Ly9jLmJvdWxkZXIudGVzdC82NjI4Mzc1NjkxMzU4ODI4OC8w
8+
LmNybIEB/6MCBkAwCgYIKoZIzj0EAwMDZwAwZAIwLw5CFJ02GryeofOtOjA8hYdr
9+
sP3BfjlZzOLBPJ1HRstF2INIRn20eDGPpEcUUGsiAjBXkD6XKSK9M4F6T+pVF1NK
10+
3C0r2rbmnkEla9uJxo2ez8tL3PeMNJ+4nWfMI3ufvzM=
11+
-----END X509 CRL-----

0 commit comments

Comments
 (0)