github.com/letsencrypt/boulder@v0.20251208.0/crl/updater/updater_test.go (about)

     1  package updater
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"errors"
     8  	"io"
     9  	"testing"
    10  	"time"
    11  
    12  	"google.golang.org/grpc"
    13  	"google.golang.org/protobuf/types/known/emptypb"
    14  	"google.golang.org/protobuf/types/known/timestamppb"
    15  
    16  	"github.com/jmhodges/clock"
    17  	"github.com/prometheus/client_golang/prometheus"
    18  
    19  	capb "github.com/letsencrypt/boulder/ca/proto"
    20  	corepb "github.com/letsencrypt/boulder/core/proto"
    21  	cspb "github.com/letsencrypt/boulder/crl/storer/proto"
    22  	"github.com/letsencrypt/boulder/issuance"
    23  	blog "github.com/letsencrypt/boulder/log"
    24  	"github.com/letsencrypt/boulder/metrics"
    25  	"github.com/letsencrypt/boulder/revocation"
    26  	sapb "github.com/letsencrypt/boulder/sa/proto"
    27  	"github.com/letsencrypt/boulder/test"
    28  )
    29  
    30  // revokedCertsStream is a fake grpc.ClientStreamingClient which can be
    31  // populated with some CRL entries or an error for use as the return value of
    32  // a faked GetRevokedCertsByShard call.
    33  type revokedCertsStream struct {
    34  	grpc.ClientStream
    35  	entries []*corepb.CRLEntry
    36  	nextIdx int
    37  	err     error
    38  }
    39  
    40  func (f *revokedCertsStream) Recv() (*corepb.CRLEntry, error) {
    41  	if f.err != nil {
    42  		return nil, f.err
    43  	}
    44  	if f.nextIdx < len(f.entries) {
    45  		res := f.entries[f.nextIdx]
    46  		f.nextIdx++
    47  		return res, nil
    48  	}
    49  	return nil, io.EOF
    50  }
    51  
    52  // fakeSAC is a fake sapb.StorageAuthorityClient which can be populated with a
    53  // fakeGRCC to be used as the return value for calls to GetRevokedCertsByShard,
    54  // and a fake timestamp to serve as the database's maximum notAfter value.
    55  type fakeSAC struct {
    56  	sapb.StorageAuthorityClient
    57  	revokedCerts revokedCertsStream
    58  	maxNotAfter  time.Time
    59  	leaseError   error
    60  }
    61  
    62  // Return the configured stream.
    63  func (f *fakeSAC) GetRevokedCertsByShard(ctx context.Context, req *sapb.GetRevokedCertsByShardRequest, _ ...grpc.CallOption) (grpc.ServerStreamingClient[corepb.CRLEntry], error) {
    64  	return &f.revokedCerts, nil
    65  }
    66  
    67  func (f *fakeSAC) LeaseCRLShard(_ context.Context, req *sapb.LeaseCRLShardRequest, _ ...grpc.CallOption) (*sapb.LeaseCRLShardResponse, error) {
    68  	if f.leaseError != nil {
    69  		return nil, f.leaseError
    70  	}
    71  	return &sapb.LeaseCRLShardResponse{IssuerNameID: req.IssuerNameID, ShardIdx: req.MinShardIdx}, nil
    72  }
    73  
    74  // generateCRLStream implements the streaming API returned from GenerateCRL.
    75  //
    76  // Specifically it implements grpc.BidiStreamingClient.
    77  //
    78  // If it has non-nil error fields, it returns those on Send() or Recv().
    79  //
    80  // When it receives a CRL entry (on Send()), it records that entry internally, JSON serialized,
    81  // with a newline between JSON objects.
    82  //
    83  // When it is asked for bytes of a signed CRL (Recv()), it sends those JSON serialized contents.
    84  //
    85  // We use JSON instead of CRL format because we're not testing the signing and formatting done
    86  // by the CA, just the plumbing of different components together done by the crl-updater.
    87  type generateCRLStream struct {
    88  	grpc.ClientStream
    89  	chunks  [][]byte
    90  	nextIdx int
    91  	sendErr error
    92  	recvErr error
    93  }
    94  
    95  type crlEntry struct {
    96  	Serial    string
    97  	Reason    int32
    98  	RevokedAt time.Time
    99  }
   100  
   101  func (f *generateCRLStream) Send(req *capb.GenerateCRLRequest) error {
   102  	if f.sendErr != nil {
   103  		return f.sendErr
   104  	}
   105  	if t, ok := req.Payload.(*capb.GenerateCRLRequest_Entry); ok {
   106  		jsonBytes, err := json.Marshal(crlEntry{
   107  			Serial:    t.Entry.Serial,
   108  			Reason:    t.Entry.Reason,
   109  			RevokedAt: t.Entry.RevokedAt.AsTime(),
   110  		})
   111  		if err != nil {
   112  			return err
   113  		}
   114  		f.chunks = append(f.chunks, jsonBytes)
   115  		f.chunks = append(f.chunks, []byte("\n"))
   116  	}
   117  	return f.sendErr
   118  }
   119  
   120  func (f *generateCRLStream) CloseSend() error {
   121  	return nil
   122  }
   123  
   124  func (f *generateCRLStream) Recv() (*capb.GenerateCRLResponse, error) {
   125  	if f.recvErr != nil {
   126  		return nil, f.recvErr
   127  	}
   128  	if f.nextIdx < len(f.chunks) {
   129  		res := f.chunks[f.nextIdx]
   130  		f.nextIdx++
   131  		return &capb.GenerateCRLResponse{Chunk: res}, nil
   132  	}
   133  	return nil, io.EOF
   134  }
   135  
   136  // fakeCA acts as a fake CA (specifically implementing capb.CRLGeneratorClient).
   137  //
   138  // It always returns its field in response to `GenerateCRL`. Because this is a streaming
   139  // RPC, that return value is responsible for most of the work.
   140  type fakeCA struct {
   141  	gcc generateCRLStream
   142  }
   143  
   144  func (f *fakeCA) GenerateCRL(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[capb.GenerateCRLRequest, capb.GenerateCRLResponse], error) {
   145  	return &f.gcc, nil
   146  }
   147  
   148  // recordingUploader acts as the streaming part of UploadCRL.
   149  //
   150  // Records all uploaded chunks in crlBody.
   151  type recordingUploader struct {
   152  	grpc.ClientStream
   153  
   154  	crlBody []byte
   155  }
   156  
   157  func (r *recordingUploader) Send(req *cspb.UploadCRLRequest) error {
   158  	if t, ok := req.Payload.(*cspb.UploadCRLRequest_CrlChunk); ok {
   159  		r.crlBody = append(r.crlBody, t.CrlChunk...)
   160  	}
   161  	return nil
   162  }
   163  
   164  func (r *recordingUploader) CloseAndRecv() (*emptypb.Empty, error) {
   165  	return &emptypb.Empty{}, nil
   166  }
   167  
   168  // noopUploader is a fake grpc.ClientStreamingClient which can be populated with
   169  // an error for use as the return value of a faked UploadCRL call.
   170  //
   171  // It does nothing with uploaded contents.
   172  type noopUploader struct {
   173  	grpc.ClientStream
   174  	sendErr error
   175  	recvErr error
   176  }
   177  
   178  func (f *noopUploader) Send(*cspb.UploadCRLRequest) error {
   179  	return f.sendErr
   180  }
   181  
   182  func (f *noopUploader) CloseAndRecv() (*emptypb.Empty, error) {
   183  	if f.recvErr != nil {
   184  		return nil, f.recvErr
   185  	}
   186  	return &emptypb.Empty{}, nil
   187  }
   188  
   189  // fakeStorer is a fake cspb.CRLStorerClient which can be populated with an
   190  // uploader stream for use as the return value for calls to UploadCRL.
   191  type fakeStorer struct {
   192  	uploaderStream grpc.ClientStreamingClient[cspb.UploadCRLRequest, emptypb.Empty]
   193  }
   194  
   195  func (f *fakeStorer) UploadCRL(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[cspb.UploadCRLRequest, emptypb.Empty], error) {
   196  	return f.uploaderStream, nil
   197  }
   198  
   199  func TestUpdateShard(t *testing.T) {
   200  	e1, err := issuance.LoadCertificate("../../test/hierarchy/int-e1.cert.pem")
   201  	test.AssertNotError(t, err, "loading test issuer")
   202  	r3, err := issuance.LoadCertificate("../../test/hierarchy/int-r3.cert.pem")
   203  	test.AssertNotError(t, err, "loading test issuer")
   204  
   205  	sentinelErr := errors.New("oops")
   206  	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
   207  	defer cancel()
   208  
   209  	clk := clock.NewFake()
   210  	clk.Set(time.Date(2020, time.January, 18, 0, 0, 0, 0, time.UTC))
   211  	cu, err := NewUpdater(
   212  		[]*issuance.Certificate{e1, r3},
   213  		2,
   214  		18*time.Hour, // shardWidth
   215  		24*time.Hour, // lookbackPeriod
   216  		6*time.Hour,  // updatePeriod
   217  		time.Minute,  // updateTimeout
   218  		1, 1,
   219  		"stale-if-error=60",
   220  		5*time.Minute,
   221  		&fakeSAC{
   222  			revokedCerts: revokedCertsStream{},
   223  			maxNotAfter:  clk.Now().Add(90 * 24 * time.Hour),
   224  		},
   225  		&fakeCA{gcc: generateCRLStream{}},
   226  		&fakeStorer{uploaderStream: &noopUploader{}},
   227  		metrics.NoopRegisterer, blog.NewMock(), clk,
   228  	)
   229  	test.AssertNotError(t, err, "building test crlUpdater")
   230  
   231  	// Ensure that getting no results from the SA still works.
   232  	err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1)
   233  	test.AssertNotError(t, err, "empty CRL")
   234  	test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{
   235  		"issuer": "(TEST) Elegant Elephant E1", "result": "success",
   236  	}, 1)
   237  
   238  	// Make a CRL with actual contents. Verify that the information makes it through
   239  	// each of the steps:
   240  	//  - read from SA
   241  	//  - write to CA and read the response
   242  	//  - upload with CRL storer
   243  	//
   244  	// The final response should show up in the bytes recorded by our fake storer.
   245  	recordingUploader := &recordingUploader{}
   246  	now := timestamppb.Now()
   247  	cu.cs = &fakeStorer{uploaderStream: recordingUploader}
   248  	cu.sa = &fakeSAC{
   249  		revokedCerts: revokedCertsStream{
   250  			entries: []*corepb.CRLEntry{
   251  				{
   252  					Serial:    "0311b5d430823cfa25b0fc85d14c54ee35",
   253  					Reason:    int32(revocation.KeyCompromise),
   254  					RevokedAt: now,
   255  				},
   256  				{
   257  					Serial:    "037d6a05a0f6a975380456ae605cee9889",
   258  					Reason:    int32(revocation.AffiliationChanged),
   259  					RevokedAt: now,
   260  				},
   261  				{
   262  					Serial:    "03aa617ab8ee58896ba082bfa25199c884",
   263  					Reason:    int32(revocation.Unspecified),
   264  					RevokedAt: now,
   265  				},
   266  			},
   267  		},
   268  		maxNotAfter: clk.Now().Add(90 * 24 * time.Hour),
   269  	}
   270  	// We ask for shard 2 specifically because GetRevokedCertsByShard only returns our
   271  	// certificate for that shard.
   272  	err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 2)
   273  	test.AssertNotError(t, err, "updateShard")
   274  
   275  	expectedEntries := map[string]int32{
   276  		"0311b5d430823cfa25b0fc85d14c54ee35": int32(revocation.KeyCompromise),
   277  		"037d6a05a0f6a975380456ae605cee9889": int32(revocation.AffiliationChanged),
   278  		"03aa617ab8ee58896ba082bfa25199c884": int32(revocation.Unspecified),
   279  	}
   280  	for r := range bytes.SplitSeq(recordingUploader.crlBody, []byte("\n")) {
   281  		if len(r) == 0 {
   282  			continue
   283  		}
   284  		var entry crlEntry
   285  		err := json.Unmarshal(r, &entry)
   286  		if err != nil {
   287  			t.Fatalf("unmarshaling JSON: %s", err)
   288  		}
   289  		expectedReason, ok := expectedEntries[entry.Serial]
   290  		if !ok {
   291  			t.Errorf("CRL entry for %s was unexpected", entry.Serial)
   292  		}
   293  		if entry.Reason != expectedReason {
   294  			t.Errorf("CRL entry for %s had reason=%d, want %d", entry.Serial, entry.Reason, expectedReason)
   295  		}
   296  		delete(expectedEntries, entry.Serial)
   297  	}
   298  	// At this point the expectedEntries map should be empty; if it's not, emit an error
   299  	// for each remaining expectation.
   300  	for k, v := range expectedEntries {
   301  		t.Errorf("expected cert %s to be revoked for reason=%d, but it was not on the CRL", k, v)
   302  	}
   303  
   304  	cu.updatedCounter.Reset()
   305  
   306  	// Ensure that getting no results from the SA still works.
   307  	err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1)
   308  	test.AssertNotError(t, err, "empty CRL")
   309  	test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{
   310  		"issuer": "(TEST) Elegant Elephant E1", "result": "success",
   311  	}, 1)
   312  	cu.updatedCounter.Reset()
   313  
   314  	// Errors closing the Storer upload stream should bubble up.
   315  	cu.cs = &fakeStorer{uploaderStream: &noopUploader{recvErr: sentinelErr}}
   316  	err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1)
   317  	test.AssertError(t, err, "storer error")
   318  	test.AssertContains(t, err.Error(), "closing CRLStorer upload stream")
   319  	test.AssertErrorIs(t, err, sentinelErr)
   320  	test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{
   321  		"issuer": "(TEST) Elegant Elephant E1", "result": "failed",
   322  	}, 1)
   323  	cu.updatedCounter.Reset()
   324  
   325  	// Errors sending to the Storer should bubble up sooner.
   326  	cu.cs = &fakeStorer{uploaderStream: &noopUploader{sendErr: sentinelErr}}
   327  	err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1)
   328  	test.AssertError(t, err, "storer error")
   329  	test.AssertContains(t, err.Error(), "sending CRLStorer metadata")
   330  	test.AssertErrorIs(t, err, sentinelErr)
   331  	test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{
   332  		"issuer": "(TEST) Elegant Elephant E1", "result": "failed",
   333  	}, 1)
   334  	cu.updatedCounter.Reset()
   335  
   336  	// Errors reading from the CA should bubble up sooner.
   337  	cu.ca = &fakeCA{gcc: generateCRLStream{recvErr: sentinelErr}}
   338  	err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1)
   339  	test.AssertError(t, err, "CA error")
   340  	test.AssertContains(t, err.Error(), "receiving CRL bytes")
   341  	test.AssertErrorIs(t, err, sentinelErr)
   342  	test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{
   343  		"issuer": "(TEST) Elegant Elephant E1", "result": "failed",
   344  	}, 1)
   345  	cu.updatedCounter.Reset()
   346  
   347  	// Errors sending to the CA should bubble up sooner.
   348  	cu.ca = &fakeCA{gcc: generateCRLStream{sendErr: sentinelErr}}
   349  	err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1)
   350  	test.AssertError(t, err, "CA error")
   351  	test.AssertContains(t, err.Error(), "sending CA metadata")
   352  	test.AssertErrorIs(t, err, sentinelErr)
   353  	test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{
   354  		"issuer": "(TEST) Elegant Elephant E1", "result": "failed",
   355  	}, 1)
   356  	cu.updatedCounter.Reset()
   357  
   358  	// Errors reading from the SA should bubble up soonest.
   359  	cu.sa = &fakeSAC{revokedCerts: revokedCertsStream{err: sentinelErr}, maxNotAfter: clk.Now().Add(90 * 24 * time.Hour)}
   360  	err = cu.updateShard(ctx, cu.clk.Now(), e1.NameID(), 1)
   361  	test.AssertError(t, err, "database error")
   362  	test.AssertContains(t, err.Error(), "retrieving entry from SA")
   363  	test.AssertErrorIs(t, err, sentinelErr)
   364  	test.AssertMetricWithLabelsEquals(t, cu.updatedCounter, prometheus.Labels{
   365  		"issuer": "(TEST) Elegant Elephant E1", "result": "failed",
   366  	}, 1)
   367  	cu.updatedCounter.Reset()
   368  }
   369  
   370  func TestUpdateShardWithRetry(t *testing.T) {
   371  	e1, err := issuance.LoadCertificate("../../test/hierarchy/int-e1.cert.pem")
   372  	test.AssertNotError(t, err, "loading test issuer")
   373  	r3, err := issuance.LoadCertificate("../../test/hierarchy/int-r3.cert.pem")
   374  	test.AssertNotError(t, err, "loading test issuer")
   375  
   376  	sentinelErr := errors.New("oops")
   377  	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
   378  	defer cancel()
   379  
   380  	clk := clock.NewFake()
   381  	clk.Set(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC))
   382  
   383  	// Build an updater that will always fail when it talks to the SA.
   384  	cu, err := NewUpdater(
   385  		[]*issuance.Certificate{e1, r3},
   386  		2, 18*time.Hour, 24*time.Hour,
   387  		6*time.Hour, time.Minute, 1, 1,
   388  		"stale-if-error=60",
   389  		5*time.Minute,
   390  		&fakeSAC{revokedCerts: revokedCertsStream{err: sentinelErr}, maxNotAfter: clk.Now().Add(90 * 24 * time.Hour)},
   391  		&fakeCA{gcc: generateCRLStream{}},
   392  		&fakeStorer{uploaderStream: &noopUploader{}},
   393  		metrics.NoopRegisterer, blog.NewMock(), clk,
   394  	)
   395  	test.AssertNotError(t, err, "building test crlUpdater")
   396  
   397  	// Ensure that having MaxAttempts set to 1 results in the clock not moving
   398  	// forward at all.
   399  	startTime := cu.clk.Now()
   400  	err = cu.updateShardWithRetry(ctx, cu.clk.Now(), e1.NameID(), 1)
   401  	test.AssertError(t, err, "database error")
   402  	test.AssertErrorIs(t, err, sentinelErr)
   403  	test.AssertEquals(t, cu.clk.Now(), startTime)
   404  
   405  	// Ensure that having MaxAttempts set to 5 results in the clock moving forward
   406  	// by 1+2+4+8=15 seconds. The core.RetryBackoff system has 20% jitter built
   407  	// in, so we have to be approximate.
   408  	cu.maxAttempts = 5
   409  	startTime = cu.clk.Now()
   410  	err = cu.updateShardWithRetry(ctx, cu.clk.Now(), e1.NameID(), 1)
   411  	test.AssertError(t, err, "database error")
   412  	test.AssertErrorIs(t, err, sentinelErr)
   413  	t.Logf("start: %v", startTime)
   414  	t.Logf("now: %v", cu.clk.Now())
   415  	test.Assert(t, startTime.Add(15*0.8*time.Second).Before(cu.clk.Now()), "retries didn't sleep enough")
   416  	test.Assert(t, startTime.Add(15*1.2*time.Second).After(cu.clk.Now()), "retries slept too much")
   417  }