Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cmd/boulder-wfe2/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.")
}
Expand Down Expand Up @@ -465,6 +474,7 @@ func main() {
rac,
sac,
eec,
mtcbc,
gnc,
rnc,
noncePrefixKey,
Expand Down
19 changes: 15 additions & 4 deletions metrics/measured_http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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()

Expand All @@ -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)
}
3 changes: 2 additions & 1 deletion test/config-next/mtcb.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"services": {
"mtcb.MTCB": {
"clientNames": [
"ra.boulder"
"ra.boulder",
"wfe.boulder"
]
},
"grpc.health.v1.Health": {
Expand Down
10 changes: 10 additions & 0 deletions test/config-next/wfe2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
171 changes: 163 additions & 8 deletions wfe2/wfe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -66,6 +67,7 @@ const (
finalizeOrderPath = "/acme/finalize/"
certPath = "/acme/cert/"
renewalInfoPath = "/acme/renewal-info/"
mtcPath = "/acme/mtc/"

// Non-ACME paths.
getCertPath = "/get/cert/"
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -264,6 +268,7 @@ func NewWebFrontEndImpl(
ra: rac,
sa: sac,
ee: eec,
mtcb: mtcbc,
gnc: gnc,
rnc: rnc,
rncKey: rncKey,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PAIR is emphasized here; why?

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

Expand Down Expand Up @@ -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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
wfe.HandleFunc(m, mtcPath+"{acctID}/{orderID}/{logID}/{serial}", wfe.MerkleTreeCertificate, "POST")
wfe.HandleFunc(m, mtcPath+"{acctID}/{orderID}/{mtcLogID}/{serial}", wfe.MerkleTreeCertificate, "POST")

We use the fully-typed-out "mtcLogID" in other places in the codebase and I think it helps (a) to be consistent and (b) to be explicit, for instance this isn't the ID of some other type of log.


// Boulder specific endpoints
wfe.HandleFunc(m, getCertPath, wfe.Certificate, "GET")
wfe.HandleFunc(m, getCertInfoPath, wfe.CertificateInfo, "GET")
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// MerkleTreeCertificate is used by client to request a copy of their merkle tree certificate.
// MerkleTreeCertificate is used by clients 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this is a little better as a 404 for now.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also 404. See #9042 and #9034.

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.
Comment on lines +1927 to +1928

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this and the below cases fall under CONTRIBUTING.md's "No need to gratuitously comment clear code." They're self-explanatory error cases, no comment needed.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core.IsAnyNilOrZero for consistency, even though there's only one parameter.

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{
Comment on lines +1974 to +1975

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is also unneeded: pem.EncodeToMemory tells us that this is PEM-encoding something.

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.
Comment on lines +1981 to +1985

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this comment is a copy of an existing comment with NOTE(@...). IMO, let's remove the handle (we don't generally need to record who wrote a comment), and let's also remove the NOTE; all comments are notes, and NOTE suggests this one is particularly important.

Further, with the benefit of hindsight I think we could shorten or remove both comments. I might say net/http only adds Content-Length automatically "if the total size of all written data is under a few KB".

Really the comment I wish we had is one that explains why it's important that we set Content-Length here. I think we don't actually need it, and #3366 doesn't explain why it's needed. The purpose of Content-Length is so an HTTP client knows when a response is done and reuse the connection for the next request. However, there's an alternative that Go will use automatically in HTTP/1.1: Transfer-Encoding: chunked. That would work just fine here.

Anyhow, it's about six of one half dozen of the other: my inclination is to keep setting Content-Length explicitly in both sites, and shorten both comments.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some additional context in #1320: we set Content-Length because otherwise our responses to HEAD will have the wrong Content-Length, per HTTP spec.

response.Header().Set("Content-Length", strconv.Itoa(len(standalonePEM)))
response.Header().Set("Content-Type", "application/pem-certificate-chain")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://ietf-plants-wg.github.io/merkle-tree-certs/draft-ietf-plants-merkle-tree-certs.html#name-using-acme-with-merkle-tree

When downloading either certificate (Section 7.4.2 of [RFC8555]), ACME clients supporting Merkle Tree certificates SHOULD send "application/pem-certificate-chain-with-properties" in their Accept header (Section 12.5.1 of [RFC9110]). ACME servers issuing Merkle Tree certificates SHOULD then respond with that content type to include a CertificatePropertyList.

We don't have the code for CertificatePropertyList yet but might as well start using the Content-Type.

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")
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Loading
Loading