250 lines
8.0 KiB
Go
250 lines
8.0 KiB
Go
package issuance
|
|
|
|
import (
|
|
"crypto/x509"
|
|
"errors"
|
|
"math/big"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jmhodges/clock"
|
|
"github.com/zmap/zlint/v3/lint"
|
|
"golang.org/x/crypto/cryptobyte"
|
|
cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1"
|
|
|
|
"github.com/letsencrypt/boulder/config"
|
|
"github.com/letsencrypt/boulder/crl/idp"
|
|
"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 {
|
|
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,
|
|
},
|
|
},
|
|
}
|
|
|
|
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)
|
|
found, err := revokedCertificatesFieldExists(res)
|
|
test.AssertNotError(t, err, "Should have been able to parse CRL")
|
|
test.Assert(t, found, "Expected the revokedCertificates field to exist")
|
|
|
|
idps, err := idp.GetIDPURIs(parsedRes.Extensions)
|
|
test.AssertNotError(t, err, "getting IDP URIs from test CRL")
|
|
test.AssertEquals(t, len(idps), 1)
|
|
test.AssertEquals(t, idps[0], "http://crl-url.example.org/100.crl")
|
|
|
|
req = defaultRequest
|
|
crlURLBase := issuer.crlURLBase
|
|
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")
|
|
issuer.crlURLBase = crlURLBase
|
|
|
|
// A CRL with no entries must not have the revokedCertificates field
|
|
req = defaultRequest
|
|
req.Entries = []x509.RevocationListEntry{}
|
|
res, err = issuer.IssueCRL(&defaultProfile, &req)
|
|
test.AssertNotError(t, err, "issuing crl with no entries")
|
|
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))
|
|
test.AssertEquals(t, len(parsedRes.RevokedCertificateEntries), 0)
|
|
found, err = revokedCertificatesFieldExists(res)
|
|
test.AssertNotError(t, err, "Should have been able to parse CRL")
|
|
test.Assert(t, !found, "Violation of RFC 5280 Section 5.1.2.6")
|
|
}
|
|
|
|
// revokedCertificatesFieldExists is a modified version of
|
|
// x509.ParseRevocationList that takes a given sequence of bytes representing a
|
|
// CRL and parses away layers until the optional `revokedCertificates` field of
|
|
// a TBSCertList is found. It returns a boolean indicating whether the field was
|
|
// found or an error if there was an issue processing a CRL.
|
|
//
|
|
// https://datatracker.ietf.org/doc/html/rfc5280#section-5.1.2.6
|
|
//
|
|
// When there are no revoked certificates, the revoked certificates list
|
|
// MUST be absent.
|
|
//
|
|
// https://datatracker.ietf.org/doc/html/rfc5280#appendix-A.1 page 118
|
|
//
|
|
// CertificateList ::= SEQUENCE {
|
|
// tbsCertList TBSCertList
|
|
// ..
|
|
// }
|
|
//
|
|
// TBSCertList ::= SEQUENCE {
|
|
// ..
|
|
// revokedCertificates SEQUENCE OF SEQUENCE {
|
|
// ..
|
|
// } OPTIONAL,
|
|
// }
|
|
func revokedCertificatesFieldExists(der []byte) (bool, error) {
|
|
input := cryptobyte.String(der)
|
|
|
|
// Extract the CertificateList
|
|
if !input.ReadASN1(&input, cryptobyte_asn1.SEQUENCE) {
|
|
return false, errors.New("malformed crl")
|
|
}
|
|
|
|
var tbs cryptobyte.String
|
|
// Extract the TBSCertList from the CertificateList
|
|
if !input.ReadASN1(&tbs, cryptobyte_asn1.SEQUENCE) {
|
|
return false, errors.New("malformed tbs crl")
|
|
}
|
|
|
|
// Skip optional version
|
|
tbs.SkipOptionalASN1(cryptobyte_asn1.INTEGER)
|
|
|
|
// Skip the signature
|
|
tbs.SkipASN1(cryptobyte_asn1.SEQUENCE)
|
|
|
|
// Skip the issuer
|
|
tbs.SkipASN1(cryptobyte_asn1.SEQUENCE)
|
|
|
|
// SkipOptionalASN1 is identical to SkipASN1 except that it also does a
|
|
// peek. We'll handle the non-optional thisUpdate with these double peeks
|
|
// because there's no harm doing so.
|
|
skipTime := func(s *cryptobyte.String) {
|
|
switch {
|
|
case s.PeekASN1Tag(cryptobyte_asn1.UTCTime):
|
|
s.SkipOptionalASN1(cryptobyte_asn1.UTCTime)
|
|
case s.PeekASN1Tag(cryptobyte_asn1.GeneralizedTime):
|
|
s.SkipOptionalASN1(cryptobyte_asn1.GeneralizedTime)
|
|
}
|
|
}
|
|
|
|
// Skip thisUpdate
|
|
skipTime(&tbs)
|
|
|
|
// Skip optional nextUpdate
|
|
skipTime(&tbs)
|
|
|
|
// Finally, the field which we care about: revokedCertificates. This will
|
|
// not trigger on the next field `crlExtensions` because that has
|
|
// context-specific tag [0] and EXPLICIT encoding, not `SEQUENCE` and is
|
|
// therefore a safe place to end this venture.
|
|
if tbs.PeekASN1Tag(cryptobyte_asn1.SEQUENCE) {
|
|
return true, nil
|
|
}
|
|
|
|
return false, nil
|
|
}
|