github.com/letsencrypt/boulder@v0.20251208.0/issuance/crl_test.go (about)

     1  package issuance
     2  
     3  import (
     4  	"crypto/x509"
     5  	"errors"
     6  	"math/big"
     7  	"testing"
     8  	"time"
     9  
    10  	"github.com/jmhodges/clock"
    11  	"github.com/zmap/zlint/v3/lint"
    12  	"golang.org/x/crypto/cryptobyte"
    13  	cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1"
    14  
    15  	"github.com/letsencrypt/boulder/config"
    16  	"github.com/letsencrypt/boulder/crl/idp"
    17  	"github.com/letsencrypt/boulder/test"
    18  )
    19  
    20  func TestNewCRLProfile(t *testing.T) {
    21  	t.Parallel()
    22  	tests := []struct {
    23  		name        string
    24  		config      CRLProfileConfig
    25  		expected    *CRLProfile
    26  		expectedErr string
    27  	}{
    28  		{
    29  			name:        "validity too long",
    30  			config:      CRLProfileConfig{ValidityInterval: config.Duration{Duration: 30 * 24 * time.Hour}},
    31  			expected:    nil,
    32  			expectedErr: "lifetime cannot be more than 10 days",
    33  		},
    34  		{
    35  			name:        "validity too short",
    36  			config:      CRLProfileConfig{ValidityInterval: config.Duration{Duration: 0}},
    37  			expected:    nil,
    38  			expectedErr: "lifetime must be positive",
    39  		},
    40  		{
    41  			name: "negative backdate",
    42  			config: CRLProfileConfig{
    43  				ValidityInterval: config.Duration{Duration: 7 * 24 * time.Hour},
    44  				MaxBackdate:      config.Duration{Duration: -time.Hour},
    45  			},
    46  			expected:    nil,
    47  			expectedErr: "backdate must be non-negative",
    48  		},
    49  		{
    50  			name: "happy path",
    51  			config: CRLProfileConfig{
    52  				ValidityInterval: config.Duration{Duration: 7 * 24 * time.Hour},
    53  				MaxBackdate:      config.Duration{Duration: time.Hour},
    54  			},
    55  			expected: &CRLProfile{
    56  				validityInterval: 7 * 24 * time.Hour,
    57  				maxBackdate:      time.Hour,
    58  			},
    59  			expectedErr: "",
    60  		},
    61  	}
    62  	for _, tc := range tests {
    63  		t.Run(tc.name, func(t *testing.T) {
    64  			t.Parallel()
    65  			actual, err := NewCRLProfile(tc.config)
    66  			if err != nil {
    67  				if tc.expectedErr == "" {
    68  					t.Errorf("NewCRLProfile expected success but got %q", err)
    69  					return
    70  				}
    71  				test.AssertContains(t, err.Error(), tc.expectedErr)
    72  			} else {
    73  				if tc.expectedErr != "" {
    74  					t.Errorf("NewCRLProfile succeeded but expected error %q", tc.expectedErr)
    75  					return
    76  				}
    77  				test.AssertEquals(t, actual.validityInterval, tc.expected.validityInterval)
    78  				test.AssertEquals(t, actual.maxBackdate, tc.expected.maxBackdate)
    79  				test.AssertNotNil(t, actual.lints, "lint registry should be populated")
    80  			}
    81  		})
    82  	}
    83  }
    84  
    85  func TestIssueCRL(t *testing.T) {
    86  	clk := clock.NewFake()
    87  	clk.Set(time.Now())
    88  
    89  	issuer, err := newIssuer(defaultIssuerConfig(), issuerCert, issuerSigner, clk)
    90  	test.AssertNotError(t, err, "creating test issuer")
    91  
    92  	defaultProfile := CRLProfile{
    93  		validityInterval: 7 * 24 * time.Hour,
    94  		maxBackdate:      1 * time.Hour,
    95  		lints:            lint.GlobalRegistry(),
    96  	}
    97  
    98  	defaultRequest := CRLRequest{
    99  		Number:     big.NewInt(123),
   100  		Shard:      100,
   101  		ThisUpdate: clk.Now().Add(-time.Second),
   102  		Entries: []x509.RevocationListEntry{
   103  			{
   104  				SerialNumber:   big.NewInt(987),
   105  				RevocationTime: clk.Now().Add(-24 * time.Hour),
   106  				ReasonCode:     1,
   107  			},
   108  		},
   109  	}
   110  
   111  	req := defaultRequest
   112  	req.ThisUpdate = clk.Now().Add(-24 * time.Hour)
   113  	_, err = issuer.IssueCRL(&defaultProfile, &req)
   114  	test.AssertError(t, err, "too old crl issuance should fail")
   115  	test.AssertContains(t, err.Error(), "ThisUpdate is too far in the past")
   116  
   117  	req = defaultRequest
   118  	req.ThisUpdate = clk.Now().Add(time.Second)
   119  	_, err = issuer.IssueCRL(&defaultProfile, &req)
   120  	test.AssertError(t, err, "future crl issuance should fail")
   121  	test.AssertContains(t, err.Error(), "ThisUpdate is in the future")
   122  
   123  	req = defaultRequest
   124  	req.Entries = append(req.Entries, x509.RevocationListEntry{
   125  		SerialNumber:   big.NewInt(876),
   126  		RevocationTime: clk.Now().Add(-24 * time.Hour),
   127  		ReasonCode:     6,
   128  	})
   129  	_, err = issuer.IssueCRL(&defaultProfile, &req)
   130  	test.AssertError(t, err, "invalid reason code should result in lint failure")
   131  	test.AssertContains(t, err.Error(), "Reason code not included in BR")
   132  
   133  	req = defaultRequest
   134  	res, err := issuer.IssueCRL(&defaultProfile, &req)
   135  	test.AssertNotError(t, err, "crl issuance should have succeeded")
   136  	parsedRes, err := x509.ParseRevocationList(res)
   137  	test.AssertNotError(t, err, "parsing test crl")
   138  	test.AssertEquals(t, parsedRes.Issuer.CommonName, issuer.Cert.Subject.CommonName)
   139  	test.AssertDeepEquals(t, parsedRes.Number, big.NewInt(123))
   140  	expectUpdate := req.ThisUpdate.Add(-time.Second).Add(defaultProfile.validityInterval).Truncate(time.Second).UTC()
   141  	test.AssertEquals(t, parsedRes.NextUpdate, expectUpdate)
   142  	test.AssertEquals(t, len(parsedRes.Extensions), 3)
   143  	found, err := revokedCertificatesFieldExists(res)
   144  	test.AssertNotError(t, err, "Should have been able to parse CRL")
   145  	test.Assert(t, found, "Expected the revokedCertificates field to exist")
   146  
   147  	idps, err := idp.GetIDPURIs(parsedRes.Extensions)
   148  	test.AssertNotError(t, err, "getting IDP URIs from test CRL")
   149  	test.AssertEquals(t, len(idps), 1)
   150  	test.AssertEquals(t, idps[0], "http://crl-url.example.org/100.crl")
   151  
   152  	req = defaultRequest
   153  	crlURLBase := issuer.crlURLBase
   154  	issuer.crlURLBase = ""
   155  	_, err = issuer.IssueCRL(&defaultProfile, &req)
   156  	test.AssertError(t, err, "crl issuance with no IDP should fail")
   157  	test.AssertContains(t, err.Error(), "must contain an issuingDistributionPoint")
   158  	issuer.crlURLBase = crlURLBase
   159  
   160  	// A CRL with no entries must not have the revokedCertificates field
   161  	req = defaultRequest
   162  	req.Entries = []x509.RevocationListEntry{}
   163  	res, err = issuer.IssueCRL(&defaultProfile, &req)
   164  	test.AssertNotError(t, err, "issuing crl with no entries")
   165  	parsedRes, err = x509.ParseRevocationList(res)
   166  	test.AssertNotError(t, err, "parsing test crl")
   167  	test.AssertEquals(t, parsedRes.Issuer.CommonName, issuer.Cert.Subject.CommonName)
   168  	test.AssertDeepEquals(t, parsedRes.Number, big.NewInt(123))
   169  	test.AssertEquals(t, len(parsedRes.RevokedCertificateEntries), 0)
   170  	found, err = revokedCertificatesFieldExists(res)
   171  	test.AssertNotError(t, err, "Should have been able to parse CRL")
   172  	test.Assert(t, !found, "Violation of RFC 5280 Section 5.1.2.6")
   173  }
   174  
   175  // revokedCertificatesFieldExists is a modified version of
   176  // x509.ParseRevocationList that takes a given sequence of bytes representing a
   177  // CRL and parses away layers until the optional `revokedCertificates` field of
   178  // a TBSCertList is found. It returns a boolean indicating whether the field was
   179  // found or an error if there was an issue processing a CRL.
   180  //
   181  // https://datatracker.ietf.org/doc/html/rfc5280#section-5.1.2.6
   182  //
   183  //	When there are no revoked certificates, the revoked certificates list
   184  //	MUST be absent.
   185  //
   186  // https://datatracker.ietf.org/doc/html/rfc5280#appendix-A.1 page 118
   187  //
   188  //	CertificateList  ::=  SEQUENCE  {
   189  //		tbsCertList          TBSCertList
   190  //	     ..
   191  //	}
   192  //
   193  //	TBSCertList  ::=  SEQUENCE  {
   194  //		..
   195  //		revokedCertificates     SEQUENCE OF SEQUENCE  {
   196  //		..
   197  //		} OPTIONAL,
   198  //	}
   199  func revokedCertificatesFieldExists(der []byte) (bool, error) {
   200  	input := cryptobyte.String(der)
   201  
   202  	// Extract the CertificateList
   203  	if !input.ReadASN1(&input, cryptobyte_asn1.SEQUENCE) {
   204  		return false, errors.New("malformed crl")
   205  	}
   206  
   207  	var tbs cryptobyte.String
   208  	// Extract the TBSCertList from the CertificateList
   209  	if !input.ReadASN1(&tbs, cryptobyte_asn1.SEQUENCE) {
   210  		return false, errors.New("malformed tbs crl")
   211  	}
   212  
   213  	// Skip optional version
   214  	tbs.SkipOptionalASN1(cryptobyte_asn1.INTEGER)
   215  
   216  	// Skip the signature
   217  	tbs.SkipASN1(cryptobyte_asn1.SEQUENCE)
   218  
   219  	// Skip the issuer
   220  	tbs.SkipASN1(cryptobyte_asn1.SEQUENCE)
   221  
   222  	// SkipOptionalASN1 is identical to SkipASN1 except that it also does a
   223  	// peek. We'll handle the non-optional thisUpdate with these double peeks
   224  	// because there's no harm doing so.
   225  	skipTime := func(s *cryptobyte.String) {
   226  		switch {
   227  		case s.PeekASN1Tag(cryptobyte_asn1.UTCTime):
   228  			s.SkipOptionalASN1(cryptobyte_asn1.UTCTime)
   229  		case s.PeekASN1Tag(cryptobyte_asn1.GeneralizedTime):
   230  			s.SkipOptionalASN1(cryptobyte_asn1.GeneralizedTime)
   231  		}
   232  	}
   233  
   234  	// Skip thisUpdate
   235  	skipTime(&tbs)
   236  
   237  	// Skip optional nextUpdate
   238  	skipTime(&tbs)
   239  
   240  	// Finally, the field which we care about: revokedCertificates. This will
   241  	// not trigger on the next field `crlExtensions` because that has
   242  	// context-specific tag [0] and EXPLICIT encoding, not `SEQUENCE` and is
   243  	// therefore a safe place to end this venture.
   244  	if tbs.PeekASN1Tag(cryptobyte_asn1.SEQUENCE) {
   245  		return true, nil
   246  	}
   247  
   248  	return false, nil
   249  }