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
 | |
| }
 |