diff --git a/test/integration/key_rollover_test.go b/test/integration/key_rollover_test.go index 1873864e309..9a44956fb0c 100644 --- a/test/integration/key_rollover_test.go +++ b/test/integration/key_rollover_test.go @@ -41,7 +41,7 @@ func TestAccountKeyChange(t *testing.T) { key3, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) test.AssertNotError(t, err, "creating P-384 account key") - acct3, err := c.AccountKeyChange(acct1, key3) + acct3, err := c.AccountKeyChange(acct2, key3) test.AssertNotError(t, err, "rolling over account key") test.AssertEquals(t, acct3.URL, acct1.URL) } diff --git a/wfe2/cache.go b/wfe2/cache.go index 442594440ef..d59dec9e747 100644 --- a/wfe2/cache.go +++ b/wfe2/cache.go @@ -98,6 +98,12 @@ func (ac *accountCache) GetRegistration(ctx context.Context, regID *sapb.Registr return copied, nil } +func (ac *accountCache) purgeRegistration(regID int64) { + ac.Lock() + ac.cache.Remove(regID) + ac.Unlock() +} + func (ac *accountCache) queryAndStore(ctx context.Context, regID *sapb.RegistrationID) (*corepb.Registration, error) { account, err := ac.under.GetRegistration(ctx, regID) if err != nil { diff --git a/wfe2/cache_test.go b/wfe2/cache_test.go index f4f0fafe1e4..56ff36f88af 100644 --- a/wfe2/cache_test.go +++ b/wfe2/cache_test.go @@ -107,6 +107,22 @@ func TestCacheExpires(t *testing.T) { test.AssertEquals(t, len(backend.requests), 2) } +func TestCachePurgeRegistration(t *testing.T) { + ctx := context.Background() + backend := &recordingBackend{} + cache := NewAccountCache(backend, 10, time.Second, clock.NewFake(), metrics.NoopRegisterer) + + _, err := cache.GetRegistration(ctx, &sapb.RegistrationID{Id: 1234}) + test.AssertNotError(t, err, "getting registration") + test.AssertEquals(t, len(backend.requests), 1) + + cache.purgeRegistration(1234) + + _, err = cache.GetRegistration(ctx, &sapb.RegistrationID{Id: 1234}) + test.AssertNotError(t, err, "getting registration") + test.AssertEquals(t, len(backend.requests), 2) +} + type wrongIDBackend struct{} func (wib wrongIDBackend) GetRegistration( diff --git a/wfe2/verify.go b/wfe2/verify.go index a6f84d057b4..f6550c443df 100644 --- a/wfe2/verify.go +++ b/wfe2/verify.go @@ -489,9 +489,10 @@ func (wfe *WebFrontEndImpl) acctIDFromURL(acctURL string, request *http.Request) // authentication and does not contain an embedded JWK. Callers should have // acquired headers from a bJSONWebSignature. func (wfe *WebFrontEndImpl) lookupJWK( - header jose.Header, ctx context.Context, + header jose.Header, request *http.Request, + accountGetter AccountGetter, logEvent *web.RequestEvent) (*jose.JSONWebKey, *core.Registration, error) { // We expect the request to be using an embedded Key ID auth type and to not // contain the mutually exclusive embedded JWK. @@ -506,8 +507,8 @@ func (wfe *WebFrontEndImpl) lookupJWK( return nil, nil, err } - // Try to find the account for this account ID - account, err := wfe.accountGetter.GetRegistration(ctx, &sapb.RegistrationID{Id: accountID}) + // Try to find the account for this account ID. + account, err := accountGetter.GetRegistration(ctx, &sapb.RegistrationID{Id: accountID}) if err != nil { // If the account isn't found, return a suitable error if errors.Is(err, berrors.NotFound) { @@ -601,12 +602,13 @@ func (wfe *WebFrontEndImpl) validJWSForKey( // JSONWebSignature, and a pointer to the JWK's associated account. If any of // these conditions are not met or an error occurs only a error is returned. func (wfe *WebFrontEndImpl) validJWSForAccount( + ctx context.Context, jws *bJSONWebSignature, request *http.Request, - ctx context.Context, + accountGetter AccountGetter, logEvent *web.RequestEvent) ([]byte, *bJSONWebSignature, *core.Registration, error) { // Lookup the account and JWK for the key ID that authenticated the JWS - pubKey, account, err := wfe.lookupJWK(jws.Signatures[0].Header, ctx, request, logEvent) + pubKey, account, err := wfe.lookupJWK(ctx, jws.Signatures[0].Header, request, accountGetter, logEvent) if err != nil { return nil, nil, nil, err } @@ -620,43 +622,62 @@ func (wfe *WebFrontEndImpl) validJWSForAccount( return payload, jws, account, nil } -// validPOSTForAccount checks that a given POST request has a valid JWS -// using `validJWSForAccount`. If valid, the authenticated JWS body and the -// registration that authenticated the body are returned. Otherwise a error is -// returned. The returned JWS body may be empty if the request is a POST-as-GET -// request. +// validPOSTForAccount checks that a given POST request has a valid JWS using +// `validJWSForAccount` and the WFE's database/SA connection. If valid, the +// authenticated JWS body and the registration that authenticated the body are +// returned. Otherwise an error is returned. +// +// This function is not ideal for validating POST-as-GET requests; although it +// will work correctly, it will not validate that the request body is empty. +// Those requests should be validated via validPOSTAsGETForAccount. func (wfe *WebFrontEndImpl) validPOSTForAccount( - request *http.Request, ctx context.Context, + request *http.Request, logEvent *web.RequestEvent) ([]byte, *bJSONWebSignature, *core.Registration, error) { // Parse the JWS from the POST request jws, err := wfe.parseJWSRequest(request) if err != nil { return nil, nil, nil, err } - return wfe.validJWSForAccount(jws, request, ctx, logEvent) + + // Use wfe.sa (i.e. the real database) as the AccountGetter for all POST + // requests, as POSTs may modify the database. This prevents a stale account + // cache from letting an old key perform mutating operations, like rotating + // to a new key. + return wfe.validJWSForAccount(ctx, jws, request, wfe.sa, logEvent) } // validPOSTAsGETForAccount checks that a given POST request is valid using -// `validPOSTForAccount`. It additionally validates that the JWS request payload -// is empty, indicating that it is a POST-as-GET request per ACME draft 15+ -// section 6.3 "GET and POST-as-GET requests". If a non empty payload is -// provided in the JWS the invalidPOSTAsGETErr error is returned. This -// function is useful only for endpoints that do not need to handle both POSTs -// with a body and POST-as-GET requests (e.g. Order, Certificate). +// `validJWSForAccount` and the wfe's read-through account cache. It +// additionally validates that the JWS request payload is empty. If a non empty +// payload is provided, it returns MalformedError. +// +// This function must only be used to validate POST-as-GET requests, it must +// not be used to validate mutating POST requests. func (wfe *WebFrontEndImpl) validPOSTAsGETForAccount( - request *http.Request, ctx context.Context, + request *http.Request, logEvent *web.RequestEvent) (*core.Registration, error) { - // Call validPOSTForAccount to verify the JWS and extract the body. - body, _, reg, err := wfe.validPOSTForAccount(request, ctx, logEvent) + // Parse the JWS from the POST request + jws, err := wfe.parseJWSRequest(request) if err != nil { return nil, err } + + // Use the account cache to look up the account. We are willing to accept + // idempotent read-only POST-as-GET requests authenticated by a key in the + // possibly-stale account cache, because such requests are relatively safe + // and relatively high-volume. + body, _, reg, err := wfe.validJWSForAccount(ctx, jws, request, wfe.accountCache, logEvent) + if err != nil { + return nil, err + } + // Verify the POST-as-GET payload is empty if string(body) != "" { return nil, berrors.MalformedError("POST-as-GET requests must have an empty payload") } + // To make log analysis easier we choose to elevate the pseudo ACME HTTP // method "POST-as-GET" to the logEvent's Method, replacing the // http.MethodPost value. diff --git a/wfe2/verify_test.go b/wfe2/verify_test.go index 1b62c3cfa5f..a2a81dd9a63 100644 --- a/wfe2/verify_test.go +++ b/wfe2/verify_test.go @@ -24,9 +24,12 @@ import ( "github.com/letsencrypt/boulder/goodkey" bgrpc "github.com/letsencrypt/boulder/grpc" "github.com/letsencrypt/boulder/grpc/noncebalancer" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/nonce" noncepb "github.com/letsencrypt/boulder/nonce/proto" sapb "github.com/letsencrypt/boulder/sa/proto" "github.com/letsencrypt/boulder/test" + inmemnonce "github.com/letsencrypt/boulder/test/inmem/nonce" "github.com/letsencrypt/boulder/web" ) @@ -1175,7 +1178,7 @@ func TestLookupJWK(t *testing.T) { in := tc.JWS.Signatures[0].Header inputLogEvent := newRequestEvent() - gotJWK, gotAcct, gotErr := wfe.lookupJWK(in, context.Background(), tc.Request, inputLogEvent) + gotJWK, gotAcct, gotErr := wfe.lookupJWK(t.Context(), in, tc.Request, wfe.accountCache, inputLogEvent) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("lookupJWK(%#v) = %#v, want nil", in, gotErr) @@ -1392,7 +1395,7 @@ func TestValidPOSTForAccount(t *testing.T) { wfe.stats.joseErrorCount.Reset() inputLogEvent := newRequestEvent() - gotPayload, gotJWS, gotAcct, gotErr := wfe.validPOSTForAccount(tc.Request, context.Background(), inputLogEvent) + gotPayload, gotJWS, gotAcct, gotErr := wfe.validPOSTForAccount(t.Context(), tc.Request, inputLogEvent) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("validPOSTForAccount(%#v) = %#v, want nil", tc.Request, gotErr) @@ -1420,8 +1423,103 @@ func TestValidPOSTForAccount(t *testing.T) { } } +type staticRegistrationGetter struct { + sapb.StorageAuthorityReadOnlyClient + registration *corepb.Registration + calls int +} + +func (getter *staticRegistrationGetter) GetRegistration( + _ context.Context, + in *sapb.RegistrationID, + _ ...grpc.CallOption, +) (*corepb.Registration, error) { + getter.calls++ + if in.Id == getter.registration.Id { + return getter.registration, nil + } + return nil, berrors.NotFound +} + +type rejectingRegistrationGetter struct { + sapb.StorageAuthorityReadOnlyClient + t *testing.T +} + +func (getter rejectingRegistrationGetter) GetRegistration( + _ context.Context, + _ *sapb.RegistrationID, + _ ...grpc.CallOption, +) (*corepb.Registration, error) { + getter.t.Fatal("validPOSTForCurrentAccount used cached account getter") + return nil, nil +} + +func registrationForAuthTest(key []byte, status core.AcmeStatus) *corepb.Registration { + return &corepb.Registration{ + Id: 1, + Key: key, + Agreement: agreementURL, + Status: string(status), + } +} + +func setupAuthOnlyWFE( + t *testing.T, + freshAccount *corepb.Registration, +) (WebFrontEndImpl, requestSigner, *staticRegistrationGetter) { + t.Helper() + + rncKey := []byte("b8c758dd85e113ea340ce0b3a99f389d40a308548af94d1730a7692c1874f1f") + noncePrefix := nonce.DerivePrefix("192.168.1.1:8080", rncKey) + nonceService, err := nonce.NewNonceService(metrics.NoopRegisterer, 100, noncePrefix) + test.AssertNotError(t, err, "making nonceService") + + inmemNonceService := &inmemnonce.NonceService{Impl: nonceService} + freshGetter := &staticRegistrationGetter{registration: freshAccount} + wfe := WebFrontEndImpl{ + sa: freshGetter, + rnc: inmemNonceService, + rncKey: rncKey, + accountCache: rejectingRegistrationGetter{t: t}, + stats: initStats(metrics.NoopRegisterer), + } + + return wfe, requestSigner{t, inmemNonceService.AsSource()}, freshGetter +} + +func TestValidPOSTForCurrentAccountRejectsCachedDeactivatedAccount(t *testing.T) { + wfe, signer, freshGetter := setupAuthOnlyWFE( + t, + registrationForAuthTest([]byte(test1KeyPublicJSON), core.StatusDeactivated), + ) + + _, _, body := signer.byKeyID(1, nil, "http://localhost/test", "{}") + request := makePostRequestWithPath("test", body) + + _, _, _, err := wfe.validPOSTForAccount(ctx, request, newRequestEvent()) + test.AssertErrorIs(t, err, berrors.Unauthorized) + test.AssertContains(t, err.Error(), `Account is not valid, has status "deactivated"`) + test.AssertEquals(t, freshGetter.calls, 1) +} + +func TestValidPOSTForCurrentAccountRejectsCachedPreRolloverKey(t *testing.T) { + wfe, signer, freshGetter := setupAuthOnlyWFE( + t, + registrationForAuthTest([]byte(test2KeyPublicJSON), core.StatusValid), + ) + + _, _, body := signer.byKeyID(1, nil, "http://localhost/test", "{}") + request := makePostRequestWithPath("test", body) + + _, _, _, err := wfe.validPOSTForAccount(ctx, request, newRequestEvent()) + test.AssertErrorIs(t, err, berrors.Malformed) + test.AssertContains(t, err.Error(), "JWS verification error") + test.AssertEquals(t, freshGetter.calls, 1) +} + // TestValidPOSTAsGETForAccount tests POST-as-GET processing. Because -// wfe.validPOSTAsGETForAccount calls `wfe.validPOSTForAccount` to do all +// wfe.validPOSTAsGETForAccount uses the configured account getter for all // processing except the empty body test we do not duplicate the // `TestValidPOSTForAccount` testcases here. func TestValidPOSTAsGETForAccount(t *testing.T) { @@ -1459,7 +1557,7 @@ func TestValidPOSTAsGETForAccount(t *testing.T) { for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { ev := newRequestEvent() - _, gotErr := wfe.validPOSTAsGETForAccount(tc.Request, context.Background(), ev) + _, gotErr := wfe.validPOSTAsGETForAccount(t.Context(), tc.Request, ev) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("validPOSTAsGETForAccount(%#v) = %#v, want nil", tc.Request, gotErr) @@ -1497,7 +1595,7 @@ func (sa mockSADifferentStoredKey) GetRegistration(_ context.Context, _ *sapb.Re func TestValidPOSTForAccountSwappedKey(t *testing.T) { wfe, _, signer := setupWFE(t) wfe.sa = &mockSADifferentStoredKey{} - wfe.accountGetter = wfe.sa + wfe.accountCache = wfe.sa event := newRequestEvent() payload := `{"resource":"ima-payload"}` @@ -1508,7 +1606,7 @@ func TestValidPOSTForAccountSwappedKey(t *testing.T) { // Ensure that ValidPOSTForAccount produces an error since the // mockSADifferentStoredKey will return a different key than the one we used to // sign the request - _, _, _, err := wfe.validPOSTForAccount(request, ctx, event) + _, _, _, err := wfe.validPOSTForAccount(ctx, request, event) test.AssertError(t, err, "No error returned for request signed by wrong key") test.AssertErrorIs(t, err, berrors.Malformed) test.AssertContains(t, err.Error(), "JWS verification error") diff --git a/wfe2/wfe.go b/wfe2/wfe.go index 0792763af88..432fbfef20b 100644 --- a/wfe2/wfe.go +++ b/wfe2/wfe.go @@ -106,11 +106,11 @@ type WebFrontEndImpl struct { rnc nonce.Redeemer // rncKey is the HMAC key used to derive the prefix of nonce backends used // for nonce redemption. - rncKey []byte - accountGetter AccountGetter - log blog.Logger - clk clock.Clock - stats wfe2Stats + rncKey []byte + accountCache AccountGetter + log blog.Logger + clk clock.Clock + stats wfe2Stats // certificateChains maps IssuerNameIDs to slice of []byte containing a leading // newline and one or more PEM encoded certificates separated by a newline, @@ -193,6 +193,17 @@ type WebFrontEndImpl struct { certProfiles map[string]string } +type accountCachePurger interface { + purgeRegistration(regID int64) +} + +func (wfe *WebFrontEndImpl) purgeCachedAccount(regID int64) { + cache, ok := wfe.accountCache.(accountCachePurger) + if ok { + cache.purgeRegistration(regID) + } +} + // AccountBlocker defines an interface that can check whether a given ID is // blocked, and return an error if so. type AccountBlocker interface { @@ -217,7 +228,7 @@ func NewWebFrontEndImpl( gnc nonce.Getter, rnc nonce.Redeemer, rncKey []byte, - accountGetter AccountGetter, + accountCache AccountGetter, limiter *ratelimits.Limiter, txnBuilder *ratelimits.TransactionBuilder, certProfiles map[string]string, @@ -271,7 +282,7 @@ func NewWebFrontEndImpl( gnc: gnc, rnc: rnc, rncKey: rncKey, - accountGetter: accountGetter, + accountCache: accountCache, limiter: limiter, txnBuilder: txnBuilder, certProfiles: certProfiles, @@ -556,7 +567,7 @@ func (wfe *WebFrontEndImpl) Directory( directoryEndpoints["renewalInfo"] = strings.TrimRight(renewalInfoPath, "/") if request.Method == http.MethodPost { - acct, err := wfe.validPOSTAsGETForAccount(request, ctx, logEvent) + acct, err := wfe.validPOSTAsGETForAccount(ctx, request, logEvent) if err != nil { wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err) return @@ -616,7 +627,7 @@ func (wfe *WebFrontEndImpl) Nonce( response http.ResponseWriter, request *http.Request) { if request.Method == http.MethodPost { - acct, err := wfe.validPOSTAsGETForAccount(request, ctx, logEvent) + acct, err := wfe.validPOSTAsGETForAccount(ctx, request, logEvent) if err != nil { wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err) return @@ -1026,8 +1037,8 @@ func (wfe *WebFrontEndImpl) revokeCertBySubscriberKey( request *http.Request, logEvent *web.RequestEvent) error { // For Key ID revocations we authenticate the outer JWS by using - // `validJWSForAccount` similar to other WFE endpoints - jwsBody, _, acct, err := wfe.validJWSForAccount(outerJWS, request, ctx, logEvent) + // `validJWSForAccount` similar to other WFE endpoints, bypassing the cache. + jwsBody, _, acct, err := wfe.validJWSForAccount(ctx, outerJWS, request, wfe.sa, logEvent) if err != nil { return err } @@ -1337,7 +1348,7 @@ func (wfe *WebFrontEndImpl) postChallenge( authz core.Authorization, challengeIndex int, logEvent *web.RequestEvent) { - body, _, currAcct, err := wfe.validPOSTForAccount(request, ctx, logEvent) + body, _, currAcct, err := wfe.validPOSTForAccount(ctx, request, logEvent) addRequesterHeader(response, logEvent.Requester) if err != nil { // validPOSTForAccount handles its own setting of logEvent.Errors @@ -1429,7 +1440,7 @@ func (wfe *WebFrontEndImpl) Account( logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { - body, _, currAcct, err := wfe.validPOSTForAccount(request, ctx, logEvent) + body, _, currAcct, err := wfe.validPOSTForAccount(ctx, request, logEvent) addRequesterHeader(response, logEvent.Requester) if err != nil { // validPOSTForAccount handles its own setting of logEvent.Errors @@ -1464,6 +1475,7 @@ func (wfe *WebFrontEndImpl) Account( wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to update account"), nil) return } + wfe.purgeCachedAccount(acct.ID) } if len(wfe.SubscriberAgreementURL) > 0 { @@ -1584,7 +1596,7 @@ func (wfe *WebFrontEndImpl) Authorization( // B) a POST-as-GET to query the authorization details if request.Method == "POST" { // Both POST options need to be authenticated by an account - body, _, acct, err := wfe.validPOSTForAccount(request, ctx, logEvent) + body, _, acct, err := wfe.validPOSTForAccount(ctx, request, logEvent) addRequesterHeader(response, logEvent.Requester) if err != nil { wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err) @@ -1700,7 +1712,7 @@ func (wfe *WebFrontEndImpl) Certificate(ctx context.Context, logEvent *web.Reque // Any POSTs to the Certificate endpoint should be POST-as-GET requests. There are // no POSTs with a body allowed for this endpoint. if request.Method == "POST" { - acct, err := wfe.validPOSTAsGETForAccount(request, ctx, logEvent) + acct, err := wfe.validPOSTAsGETForAccount(ctx, request, logEvent) if err != nil { wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err) return @@ -1954,9 +1966,8 @@ func (wfe *WebFrontEndImpl) KeyRollover( logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { - // Validate the outer JWS on the key rollover in standard fashion using - // validPOSTForAccount - outerBody, outerJWS, acct, err := wfe.validPOSTForAccount(request, ctx, logEvent) + // Validate the outer JWS on the key rollover in standard fashion. + outerBody, outerJWS, acct, err := wfe.validPOSTForAccount(ctx, request, logEvent) addRequesterHeader(response, logEvent.Requester) if err != nil { wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err) @@ -2055,6 +2066,7 @@ func (wfe *WebFrontEndImpl) KeyRollover( wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling proto to registration"), err) return } + wfe.purgeCachedAccount(updatedAcct.ID) err = wfe.writeJsonResponse(ctx, response, logEvent, http.StatusOK, updatedAcct) if err != nil { @@ -2342,7 +2354,7 @@ func (wfe *WebFrontEndImpl) NewOrder( logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { - body, _, acct, err := wfe.validPOSTForAccount(request, ctx, logEvent) + body, _, acct, err := wfe.validPOSTForAccount(ctx, request, logEvent) addRequesterHeader(response, logEvent.Requester) if err != nil { // validPOSTForAccount handles its own setting of logEvent.Errors @@ -2538,7 +2550,7 @@ func (wfe *WebFrontEndImpl) GetOrder(ctx context.Context, logEvent *web.RequestE // Any POSTs to the Order endpoint should be POST-as-GET requests. There are // no POSTs with a body allowed for this endpoint. if request.Method == http.MethodPost { - acct, err := wfe.validPOSTAsGETForAccount(request, ctx, logEvent) + acct, err := wfe.validPOSTAsGETForAccount(ctx, request, logEvent) if err != nil { wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err) return @@ -2615,7 +2627,7 @@ func (wfe *WebFrontEndImpl) GetOrder(ctx context.Context, logEvent *web.RequestE func (wfe *WebFrontEndImpl) FinalizeOrder(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { // Validate the POST body signature and get the authenticated account for this // finalize order request - body, _, acct, err := wfe.validPOSTForAccount(request, ctx, logEvent) + body, _, acct, err := wfe.validPOSTForAccount(ctx, request, logEvent) addRequesterHeader(response, logEvent.Requester) if err != nil { wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err) diff --git a/wfe2/wfe_test.go b/wfe2/wfe_test.go index 38501994122..5c9b16c480a 100644 --- a/wfe2/wfe_test.go +++ b/wfe2/wfe_test.go @@ -4090,6 +4090,7 @@ func TestOrderMatchesReplacement(t *testing.T) { test.AssertNotError(t, err, "failed to create test certificate") wfe.sa = &mockSAForARI{ + StorageAuthorityReadOnlyClient: wfe.sa, cert: &corepb.Certificate{ RegistrationID: 1, Serial: expectSerial.String(), @@ -4257,6 +4258,7 @@ func TestCountNewOrderWithReplaces(t *testing.T) { // MockSA that returns the certificate with the expected serial. wfe.sa = &mockSAForARI{ + StorageAuthorityReadOnlyClient: wfe.sa, cert: &corepb.Certificate{ RegistrationID: 1, Serial: core.SerialToString(expectSerial), @@ -4323,6 +4325,7 @@ func TestNewOrderRateLimits(t *testing.T) { // Mock SA that returns the certificate with the expected serial. wfe.sa = &mockSAForARI{ + StorageAuthorityReadOnlyClient: wfe.sa, cert: &corepb.Certificate{ RegistrationID: 1, Serial: core.SerialToString(extantCert.SerialNumber),