diff --git a/cmd/boulder-wfe2/main.go b/cmd/boulder-wfe2/main.go index f2686a53159..d4bf7ac9322 100644 --- a/cmd/boulder-wfe2/main.go +++ b/cmd/boulder-wfe2/main.go @@ -22,6 +22,7 @@ import ( "github.com/letsencrypt/boulder/grpc/noncebalancer" noncebalancerv1 "github.com/letsencrypt/boulder/grpc/noncebalancerv1" "github.com/letsencrypt/boulder/issuance" + mtcbpb "github.com/letsencrypt/boulder/mtcb/proto" "github.com/letsencrypt/boulder/nonce" rapb "github.com/letsencrypt/boulder/ra/proto" "github.com/letsencrypt/boulder/ratelimits" @@ -68,6 +69,7 @@ type Config struct { RAService *cmd.GRPCClientConfig SAService *cmd.GRPCClientConfig EmailExporter *cmd.GRPCClientConfig + MTCBService *cmd.GRPCClientConfig // GetNonceService is a gRPC config which contains a single SRV name // used to lookup nonce-service instances used exclusively for nonce @@ -382,6 +384,13 @@ func main() { eec = emailpb.NewExporterClient(emailExporterConn) } + var mtcbc mtcbpb.MTCBClient + if c.WFE.MTCBService != nil { + mtcbConn, err := bgrpc.ClientSetup(c.WFE.MTCBService, tlsConfig, stats, clk) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to MTCB") + mtcbc = mtcbpb.NewMTCBClient(mtcbConn) + } + if c.WFE.RedeemNonceService == nil { cmd.Fail("'redeemNonceService' must be configured.") } @@ -465,6 +474,7 @@ func main() { rac, sac, eec, + mtcbc, gnc, rnc, noncePrefixKey, diff --git a/metrics/measured_http/http.go b/metrics/measured_http/http.go index 64b17e143e8..ede721f9055 100644 --- a/metrics/measured_http/http.go +++ b/metrics/measured_http/http.go @@ -32,11 +32,12 @@ func (r *responseWriterWithStatus) Write(body []byte) (int, error) { return r.ResponseWriter.Write(body) } -// serveMux is a partial interface wrapper for the one method http.ServeMux +// serveMux is a partial interface wrapper for the methods http.ServeMux // exposes that we use. This prevents us from accidentally developing an // overly-specific reliance on that concrete type. type serveMux interface { Handler(*http.Request) (http.Handler, string) + ServeHTTP(w http.ResponseWriter, r *http.Request) } // MeasuredHandler wraps an http.Handler and records prometheus stats @@ -50,6 +51,16 @@ type MeasuredHandler struct { inFlightRequestsGauge *prometheus.GaugeVec } +// spanName is a helper used by otelhttp, as configured below, to name server +// spans according to the ServeMux pattern that matched the request, with a +// fallback to a span named according to the "operation". +func spanName(operation string, r *http.Request) string { + if r.Pattern != "" { + return r.Pattern + } + return operation +} + func New(m serveMux, clk clock.Clock, stats prometheus.Registerer, opts ...otelhttp.Option) http.Handler { responseTime := promauto.With(stats).NewHistogramVec(prometheus.HistogramOpts{ Name: "response_time", @@ -66,14 +77,14 @@ func New(m serveMux, clk clock.Clock, stats prometheus.Registerer, opts ...otelh clk: clk, stat: responseTime, inFlightRequestsGauge: inFlightRequestsGauge, - }, "server", opts...) + }, "server", append([]otelhttp.Option{otelhttp.WithSpanNameFormatter(spanName)}, opts...)...) } func (h *MeasuredHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { begin := h.clk.Now() rwws := &responseWriterWithStatus{w, 0} - subHandler, pattern := h.Handler(r) + _, pattern := h.serveMux.Handler(r) h.inFlightRequestsGauge.WithLabelValues(pattern).Inc() defer h.inFlightRequestsGauge.WithLabelValues(pattern).Dec() @@ -97,5 +108,5 @@ func (h *MeasuredHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { }).Observe(h.clk.Since(begin).Seconds()) }() - subHandler.ServeHTTP(rwws, r) + h.serveMux.ServeHTTP(rwws, r) } diff --git a/test/config-next/mtcb.json b/test/config-next/mtcb.json index 9aa43ac45df..b012026e8a1 100644 --- a/test/config-next/mtcb.json +++ b/test/config-next/mtcb.json @@ -10,7 +10,8 @@ "services": { "mtcb.MTCB": { "clientNames": [ - "ra.boulder" + "ra.boulder", + "wfe.boulder" ] }, "grpc.health.v1.Health": { diff --git a/test/config-next/wfe2.json b/test/config-next/wfe2.json index acec82e7965..d2a710d91e8 100644 --- a/test/config-next/wfe2.json +++ b/test/config-next/wfe2.json @@ -77,6 +77,16 @@ "noWaitForReady": true, "hostOverride": "nonce.boulder" }, + "mtcbService": { + "dnsAuthority": "consul.service.consul", + "srvLookup": { + "service": "mtcb", + "domain": "service.consul" + }, + "timeout": "20s", + "noWaitForReady": true, + "hostOverride": "mtcb.boulder" + }, "nonceHMACKey": { "keyFile": "test/secrets/nonce_prefix_key" }, diff --git a/wfe2/wfe.go b/wfe2/wfe.go index 8c5e4c4d1c8..5b5ee820857 100644 --- a/wfe2/wfe.go +++ b/wfe2/wfe.go @@ -36,6 +36,7 @@ import ( "github.com/letsencrypt/boulder/issuance" blog "github.com/letsencrypt/boulder/log" "github.com/letsencrypt/boulder/metrics/measured_http" + mtcbpb "github.com/letsencrypt/boulder/mtcb/proto" "github.com/letsencrypt/boulder/nonce" "github.com/letsencrypt/boulder/policy" "github.com/letsencrypt/boulder/probs" @@ -66,6 +67,7 @@ const ( finalizeOrderPath = "/acme/finalize/" certPath = "/acme/cert/" renewalInfoPath = "/acme/renewal-info/" + mtcPath = "/acme/mtc/" // Non-ACME paths. getCertPath = "/get/cert/" @@ -89,9 +91,10 @@ var errIncompleteGRPCResponse = errors.New("incomplete gRPC response message") // plus a few other data items used in ACME. Its methods are primarily handlers // for HTTPS requests for the various ACME functions. type WebFrontEndImpl struct { - ra rapb.RegistrationAuthorityClient - sa sapb.StorageAuthorityReadOnlyClient - ee emailpb.ExporterClient + ra rapb.RegistrationAuthorityClient + sa sapb.StorageAuthorityReadOnlyClient + ee emailpb.ExporterClient + mtcb mtcbpb.MTCBClient // gnc is a nonce-service client used exclusively for the issuance of // nonces. It's configured to route requests to backends colocated with the // WFE. @@ -211,6 +214,7 @@ func NewWebFrontEndImpl( rac rapb.RegistrationAuthorityClient, sac sapb.StorageAuthorityReadOnlyClient, eec emailpb.ExporterClient, + mtcbc mtcbpb.MTCBClient, gnc nonce.Getter, rnc nonce.Redeemer, rncKey []byte, @@ -264,6 +268,7 @@ func NewWebFrontEndImpl( ra: rac, sa: sac, ee: eec, + mtcb: mtcbc, gnc: gnc, rnc: rnc, rncKey: rncKey, @@ -310,7 +315,7 @@ func (wfe *WebFrontEndImpl) HandleFunc(mux *http.ServeMux, pattern string, h web methodsMap["HEAD"] = true } methodsStr := strings.Join(methods, ", ") - handler := http.StripPrefix(pattern, web.NewTopHandler(wfe.log, + var handler http.Handler = web.NewTopHandler(wfe.log, web.WFEHandlerFunc(func(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { span := trace.SpanFromContext(ctx) span.SetName(pattern) @@ -366,7 +371,14 @@ func (wfe *WebFrontEndImpl) HandleFunc(mux *http.ServeMux, pattern string, h web h(ctx, logEvent, response, request) cancel() }), - )) + ) + // Detect the lack of at least one PAIR of curly braces as a signal that + // there is no wildcard pattern. This gives us a way to migrate handlers + // individually to use wildcard patterns, as our non-wildcard handlers need + // prefixes stripped. + if !strings.Contains(pattern, `{`) && !strings.Contains(pattern, `}`) { + handler = http.StripPrefix(pattern, handler) + } mux.Handle(pattern, handler) } @@ -466,6 +478,9 @@ func (wfe *WebFrontEndImpl) Handler(stats prometheus.Registerer) http.Handler { wfe.HandleFunc(m, certPath, wfe.Certificate, "GET", "POST") wfe.HandleFunc(m, renewalInfoPath, wfe.RenewalInfo, "GET", "POST") + // POST-as-GETable ACME endpoints + wfe.HandleFunc(m, mtcPath+"{acctID}/{orderID}/{logID}/{serial}", wfe.MerkleTreeCertificate, "POST") + // Boulder specific endpoints wfe.HandleFunc(m, getCertPath, wfe.Certificate, "GET") wfe.HandleFunc(m, getCertInfoPath, wfe.CertificateInfo, "GET") @@ -1855,6 +1870,126 @@ func (wfe *WebFrontEndImpl) Certificate(ctx context.Context, logEvent *web.Reque } } +// MerkleTreeCertificate is used by client to request a copy of their merkle tree certificate. +func (wfe *WebFrontEndImpl) MerkleTreeCertificate(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { + acct, err := wfe.validPOSTAsGETForAccount(request, ctx, logEvent) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err) + return + } + + // wfe instance not configured with an MTCB Client + if wfe.mtcb == nil { + wfe.sendError(response, logEvent, probs.Malformed("Not Supported"), nil) + return + } + + reqAcctID, err := parsePositiveInt(request.PathValue("acctID")) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Request URL does not include a valid accountID"), nil) + return + } + reqOrderID, err := parsePositiveInt(request.PathValue("orderID")) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Request URL does not include a valid orderID"), nil) + return + } + reqSerial, err := parsePositiveUint(request.PathValue("serial")) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Request URL does not include a valid serial number"), nil) + return + } + + // Verify that the request path account ID matches the POST-as-GET-validated account ID + if reqAcctID != acct.ID { + wfe.sendError(response, logEvent, probs.Unauthorized(fmt.Sprintf("Not allowed for ID %d", acct.ID)), nil) + return + } + + // MerkleTreeCertificate does not need deployability protection via + // WFECallsRAForGetOrder, and always looks up orders via the RA. + order, err := wfe.ra.GetOrder(ctx, &rapb.GetOrderRequest{OrderID: reqOrderID}) + if err != nil { + if errors.Is(err, berrors.NotFound) { + wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order for ID %d", reqOrderID)), nil) + return + } + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, + fmt.Sprintf("Failed to retrieve order for ID %d", reqOrderID)), err) + return + } + + if core.IsAnyNilOrZero(order.Id, order.Status, order.RegistrationID, order.Identifiers, order.Created, order.Expires) { + wfe.sendError(response, logEvent, probs.ServerInternal(fmt.Sprintf("Failed to retrieve order for ID %d", reqOrderID)), errIncompleteGRPCResponse) + return + } + + // If the order is not yet valid, then we did not find what the requester + // wants. + if order.Status != string(core.StatusValid) { + wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order for ID %d", reqOrderID)), nil) + return + } + + // If request Path ID does not match order registration ID, then we did not + // find what the requester wants. + if acct.ID != order.RegistrationID { + wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order for ID %d", reqOrderID)), nil) + return + } + + // If the order does not have merkle tree certificate information, then we did + // not find what the requester wants. + if core.IsAnyNilOrZero(order.MtcLogID, order.MtcSerialNumber, order.MtcSubtreeID) { + wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No merkle tree certificate for order ID %d", reqOrderID)), nil) + return + } + + // If the request path is looking for an MTC that doesn't match the + // retrieved order, their request is malformed. + if request.PathValue("logID") != order.MtcLogID || reqSerial != order.MtcSerialNumber { + wfe.sendError(response, logEvent, probs.Malformed("MTC request URL details do not match order details"), nil) + return + } + + logEvent.Extra["RequestedMtcSerial"] = reqSerial + + // Now ask the MTCB for the standalone certificate. + standalone, err := wfe.mtcb.GetStandalone(ctx, + &mtcbpb.StandaloneRequest{ + MtcLogID: order.MtcLogID, + MtcSerialNumber: order.MtcSerialNumber, + MtcSubtreeID: order.MtcSubtreeID, + }) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Failed to retrieve merkle tree certificate"), err) + return + } + + if standalone.CertDER == nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Problem getting merkle tree certificate"), errIncompleteGRPCResponse) + return + } + + // And PEM-encode the retrieved certificate for return to the client. + standalonePEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: standalone.CertDER, + }) + + // NOTE(@ezekiel): Similar to Certificate(), we must explicitly set the + // Content-Length header here. The Go HTTP library will only add this header + // if the body is below a certain size and a PEM encoded merkle tree + // certificate will likely exceed this threshold. Since we know the length + // we can reliably set it ourselves and not worry. + response.Header().Set("Content-Length", strconv.Itoa(len(standalonePEM))) + response.Header().Set("Content-Type", "application/pem-certificate-chain") + response.WriteHeader(http.StatusOK) + if _, err = response.Write(standalonePEM); err != nil { + wfe.log.Warningf("Could not write response: %s", err) + } +} + // BuildID tells the requester what build we're running. func (wfe *WebFrontEndImpl) BuildID(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) { response.Header().Set("Content-Type", "text/plain") @@ -2114,9 +2249,19 @@ func (wfe *WebFrontEndImpl) orderToOrderJSON(request *http.Request, order *corep respObj.Authorizations = append(respObj.Authorizations, endpoint) } if respObj.Status == core.StatusValid { - certURL := web.RelativeEndpoint(request, - certPath, order.CertificateSerial) - respObj.Certificate = certURL + if order.CertificateSerial != "" { + respObj.Certificate = web.RelativeEndpoint(request, + certPath, order.CertificateSerial) + } + if !core.IsAnyNilOrZero(order.MtcLogID, order.MtcSerialNumber, order.MtcSubtreeID) && wfe.mtcb != nil { + respObj.Certificate = web.RelativeEndpoint(request, + mtcPath, + fmt.Sprintf("%d", order.RegistrationID), + fmt.Sprintf("%d", order.Id), + order.MtcLogID, + fmt.Sprintf("%d", order.MtcSerialNumber), + ) + } } return respObj } @@ -2891,3 +3036,13 @@ func parsePositiveInt(v string) (int64, error) { } return parsed, nil } + +// parsePositiveUint returns the input parsed as an uint64, or a +// berrors.NotFoundError on parse failure, zero, or negative numbers. +func parsePositiveUint(v string) (uint64, error) { + parsed, err := strconv.ParseUint(v, 10, 64) + if err != nil || parsed == 0 { + return 0, berrors.NotFoundError("") + } + return parsed, nil +} diff --git a/wfe2/wfe_test.go b/wfe2/wfe_test.go index 63ef8abdb65..df8b0a9b89c 100644 --- a/wfe2/wfe_test.go +++ b/wfe2/wfe_test.go @@ -48,6 +48,7 @@ import ( blog "github.com/letsencrypt/boulder/log" "github.com/letsencrypt/boulder/metrics" "github.com/letsencrypt/boulder/mocks" + mtcbpb "github.com/letsencrypt/boulder/mtcb/proto" "github.com/letsencrypt/boulder/must" "github.com/letsencrypt/boulder/nonce" noncepb "github.com/letsencrypt/boulder/nonce/proto" @@ -433,6 +434,7 @@ func setupWFE(t *testing.T) (WebFrontEndImpl, clock.FakeClock, requestSigner) { &MockRegistrationAuthority{clk: fc}, mockSA, nil, + nil, gnc, rnc, rncKey, @@ -1130,6 +1132,11 @@ func TestHTTPMethods(t *testing.T) { Path: newNoncePath, Allowed: getOrPost, }, + { + Name: "MTC path should be POST only", + Path: mtcPath + "1/2/3/4", + Allowed: postOnly, + }, } // NOTE: We omit http.MethodOptions because all requests with this method are @@ -4622,3 +4629,304 @@ func TestMaxCumulativeIdentifierLength(t *testing.T) { t.Errorf("newOrder with too long identifiers: got %q, want %q", detail, expected) } } + +// mockRAWithGetOrder returns a specially-crafted order from a set of fixed +// values in response to GetOrder() +type mockRAWithGetOrder struct { + rapb.RegistrationAuthorityClient + clk clock.Clock +} + +// testOrderPB is a mock-internal helper for creating order proto structures. +// Includes important dates in the returned order structure using the mock RA's +// clock. Test should match these timestamp calculations using the same clock. +func (ra *mockRAWithGetOrder) testOrderPB(id int64, typ string) *corepb.Order { + now := ra.clk.Now() + created := now.AddDate(-30, 0, 0) + exp := now.AddDate(30, 0, 0) + orderPB := &corepb.Order{ + Id: id, + RegistrationID: 1, + Created: timestamppb.New(created), + Expires: timestamppb.New(exp), + Identifiers: []*corepb.Identifier{identifier.NewDNS("example.com").ToProto()}, + Status: string(core.StatusValid), + V2Authorizations: []int64{1, 2}, + Error: nil, + CertificateProfileName: "default", + } + switch typ { + case "classic": + orderPB.CertificateSerial = "4" + case "mtc": + orderPB.MtcLogID = "44947.4.1.0.3" + orderPB.MtcSerialNumber = 4 + orderPB.MtcSubtreeID = 5 + default: + orderPB.Status = string(core.StatusInvalid) + } + return orderPB +} + +// The `Id` in any order returned by a call to this mock's GetOrder is always +// the same as the input GetOrderRequest.OrderID, if within range of crafted +// test order fixtures. Otherwise, GetOrder returns a berrors.NotFoundError +func (ra *mockRAWithGetOrder) GetOrder(_ context.Context, req *rapb.GetOrderRequest, _ ...grpc.CallOption) (*corepb.Order, error) { + validClassicOrder := ra.testOrderPB(req.OrderID, "classic") + validMTCOrder := ra.testOrderPB(req.OrderID, "mtc") + + // modify Status and some Mtc details for a not-yet-valid order + notYetValidMTCOrder := ra.testOrderPB(req.OrderID, "mtc") + notYetValidMTCOrder.Status = string(core.StatusProcessing) + notYetValidMTCOrder.MtcSerialNumber = 0 + notYetValidMTCOrder.MtcSubtreeID = 0 + + // modify MtcSerialNumber to trigger the mock MTCB to return an error + validMTCOrderMissingStandalone := ra.testOrderPB(req.OrderID, "mtc") + validMTCOrderMissingStandalone.MtcSerialNumber = 666 + + var orders = []*corepb.Order{ + {}, // index zero is empty + validClassicOrder, + validMTCOrder, + notYetValidMTCOrder, + validMTCOrderMissingStandalone, + } + if 0 < req.OrderID && req.OrderID <= 4 { + return orders[req.OrderID], nil + } + return nil, berrors.NotFoundError("No order found for ID %d", req.OrderID) +} + +func TestMTCGetOrder(t *testing.T) { + t.Cleanup(features.Reset) + + fc := clock.NewFake() + fc.Set(time.Now()) + // This fake clock is assigned as the RA mock clock, and this expiry + // timestamp calculation must match that mock. + orderExpiry := fc.Now().AddDate(30, 0, 0).Format(time.RFC3339Nano) + + testCases := []struct { + name string + path string + expect string + }{ + { + // testCase 0 is a special case tested before the wfe is wired-up with an MTCB + name: "MTC Order without MTCBService", + path: "1/2", + expect: `{ "status": "valid", "expires": "` + orderExpiry + `", "identifiers": [ { "type": "dns", "value": "example.com" } ], "authorizations": [ "http://localhost/acme/authz/1/1", "http://localhost/acme/authz/1/2" ], "finalize": "http://localhost/acme/finalize/1/2", "profile": "default" }`, + }, + { + name: "Classic Order", + path: "1/1", + expect: `{ "status": "valid", "expires": "` + orderExpiry + `", "identifiers": [ { "type": "dns", "value": "example.com" } ], "authorizations": [ "http://localhost/acme/authz/1/1", "http://localhost/acme/authz/1/2" ], "finalize": "http://localhost/acme/finalize/1/1", "profile": "default", "certificate": "http://localhost/acme/cert/4" }`, + }, + { + name: "MTC Order", + path: "1/2", + expect: `{ "status": "valid", "expires": "` + orderExpiry + `", "identifiers": [ { "type": "dns", "value": "example.com" } ], "authorizations": [ "http://localhost/acme/authz/1/1", "http://localhost/acme/authz/1/2" ], "finalize": "http://localhost/acme/finalize/1/2", "profile": "default", "certificate": "http://localhost/acme/mtc/1/2/44947.4.1.0.3/4" }`, + }, + { + name: "Not Ready MTC Order", + path: "1/3", + expect: `{ "status": "processing", "expires": "` + orderExpiry + `", "identifiers": [ { "type": "dns", "value": "example.com" } ], "authorizations": [ "http://localhost/acme/authz/1/1", "http://localhost/acme/authz/1/2" ], "finalize": "http://localhost/acme/finalize/1/3", "profile": "default" }`, + }, + { + name: "Not Found Order", + path: "1/9", + expect: `{"type": "urn:ietf:params:acme:error:malformed","detail": "No order for ID 9","status": 404}`, + }, + } + + wfe, _, signer := setupWFE(t) + + makePost := func(keyID int64, path, body string) *http.Request { + _, _, jwsBody := signer.byKeyID(keyID, nil, fmt.Sprintf("http://localhost/%s", path), body) + return makePostRequestWithPath(path, jwsBody) + } + + features.Set(features.Config{WFECallsRAForGetOrder: true}) + + mockRA := &mockRAWithGetOrder{} + mockRA.clk = fc + wfe.ra = mockRA + + // Test order retrieval with no MTCBService configured + responseWriter := httptest.NewRecorder() + wfe.GetOrder(ctx, newRequestEvent(), responseWriter, makePost(1, testCases[0].path, "")) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), testCases[0].expect) + + // Now wire-up the wfe with an MTCB mock + mockMTCB := &mockMTCBWithGetStandalone{} + wfe.mtcb = mockMTCB + + for tcIdx, tc := range testCases { + // skip special test-case 0 + if tcIdx == 0 { + continue + } + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + responseWriter := httptest.NewRecorder() + wfe.GetOrder(ctx, newRequestEvent(), responseWriter, makePost(1, tc.path, "")) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.expect) + }) + } +} + +// mockMTCBWithGetStandalone returns a fixed value in response to +// GetStandalone() +type mockMTCBWithGetStandalone struct { + mtcbpb.MTCBClient +} + +// GetStandalone in this mock always returns the same palindromic nonsense bytes +// of primes, unless the request is for MtcSerialNumber 666, in which case an +// empty StandaloneResponse and a mock error are returned. +func (mtcb *mockMTCBWithGetStandalone) GetStandalone(_ context.Context, req *mtcbpb.StandaloneRequest, _ ...grpc.CallOption) (*mtcbpb.StandaloneResponse, error) { + if req.MtcSerialNumber == 666 { + return &mtcbpb.StandaloneResponse{}, errors.New("mock error") + } + return &mtcbpb.StandaloneResponse{CertDER: []byte{2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 29, 23, 19, 17, 13, 11, 7, 5, 3, 2}}, nil +} + +func TestMerkleTreeCertificate(t *testing.T) { + t.Parallel() + + type expectResponse struct { + isJSON bool + expect string + } + + testCases := []struct { + name string + pathAdd string + expResp expectResponse + }{ + { + name: "Request using non-existent Order ID", + pathAdd: "1/9/44947.4.1.0.3/4", + expResp: expectResponse{ + isJSON: true, + expect: `{"type": "urn:ietf:params:acme:error:malformed", "detail": "No order for ID 9", "status": 404}`, + }, + }, + { + name: "Request using wrong Account ID for extant MTC Order", + pathAdd: "9/2/44947.4.1.0.3/4", + expResp: expectResponse{ + isJSON: true, + expect: `{"type": "urn:ietf:params:acme:error:unauthorized", "detail": "Not allowed for ID 1", "status": 403}`, + }, + }, + { + name: "Request using wrong Account ID for extant Classic Order", + pathAdd: "9/1/44947.4.1.0.3/4", + expResp: expectResponse{ + isJSON: true, + expect: `{"type": "urn:ietf:params:acme:error:unauthorized", "detail": "Not allowed for ID 1", "status": 403}`, + }, + }, + { + name: "Request using wrong Log ID for extant MTC Order", + pathAdd: "1/2/44947.4.1.0.9/4", + expResp: expectResponse{ + isJSON: true, + expect: `{"type": "urn:ietf:params:acme:error:malformed", "detail": "MTC request URL details do not match order details", "status": 400}`, + }, + }, + { + name: "Request using wrong Log ID for extant Classic Order", + pathAdd: "1/1/44947.4.1.0.9/4", + expResp: expectResponse{ + isJSON: true, + expect: `{"type": "urn:ietf:params:acme:error:malformed", "detail": "No merkle tree certificate for order ID 1", "status": 404}`, + }, + }, + { + name: "Request using wrong Serial Number for extant MTC Order", + pathAdd: "1/2/44947.4.1.0.3/9", + expResp: expectResponse{ + isJSON: true, + expect: `{"type": "urn:ietf:params:acme:error:malformed", "detail": "MTC request URL details do not match order details", "status": 400}`, + }, + }, + { + name: "Request using wrong Serial Number for extant Classic Order", + pathAdd: "1/1/44947.4.1.0.3/9", + expResp: expectResponse{ + isJSON: true, + expect: `{"type": "urn:ietf:params:acme:error:malformed", "detail": "No merkle tree certificate for order ID 1", "status": 404}`, + }, + }, + { + name: "Request using trigger-mock Serial Number for error GetStandalone response", + pathAdd: "1/4/44947.4.1.0.3/666", + expResp: expectResponse{ + isJSON: true, + expect: `{"type": "urn:ietf:params:acme:error:serverInternal", "detail": "Failed to retrieve merkle tree certificate", "status": 500}`, + }, + }, + { + name: "Request for order in processing", + pathAdd: "1/3/44947.4.1.0.3/4", + expResp: expectResponse{ + isJSON: true, + expect: `{"type": "urn:ietf:params:acme:error:malformed", "detail": "No order for ID 3", "status": 404}`, + }, + }, + { + name: "Request is Correct", + pathAdd: "1/2/44947.4.1.0.3/4", + expResp: expectResponse{ + isJSON: false, + expect: "-----BEGIN CERTIFICATE-----\nAgMFBwsNERMXHR8dFxMRDQsHBQMC\n-----END CERTIFICATE-----\n", + }, + }, + } + + fc := clock.NewFake() + fc.Set(time.Now()) + + wfe, _, signer := setupWFE(t) + mux := wfe.Handler(metrics.NoopRegisterer) + + makePost := func(keyID int64, path, body string) *http.Request { + _, _, jwsBody := signer.byKeyID(keyID, nil, fmt.Sprintf("http://localhost%s", path), body) + return makePostRequestWithPath(path, jwsBody) + } + + mockRA := &mockRAWithGetOrder{} + mockRA.clk = fc + wfe.ra = mockRA + + // Test that an MTC path request to a WFE without an MTCB Client correctly + // returns "Not Supported" + responseWriter := httptest.NewRecorder() + requestPath := mtcPath + "1/2/44947.4.1.0.3/4" + mux.ServeHTTP(responseWriter, makePost(1, requestPath, "")) + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{ "type": "urn:ietf:params:acme:error:malformed", "detail": "Not Supported", "status": 400 }`) + + // Now wire-up a mock MTCB + mockMTCB := &mockMTCBWithGetStandalone{} + wfe.mtcb = mockMTCB + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + responseWriter := httptest.NewRecorder() + requestPath := mtcPath + tc.pathAdd + mux.ServeHTTP(responseWriter, makePost(1, requestPath, "")) + + if tc.expResp.isJSON { + test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.expResp.expect) + } else { + test.AssertEquals(t, responseWriter.Body.String(), tc.expResp.expect) + } + }) + } +}