2719 lines
99 KiB
Go
2719 lines
99 KiB
Go
package wfe2
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"math/rand/v2"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jmhodges/clock"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
"go.opentelemetry.io/otel/trace"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
"google.golang.org/protobuf/types/known/emptypb"
|
|
|
|
"github.com/letsencrypt/boulder/core"
|
|
corepb "github.com/letsencrypt/boulder/core/proto"
|
|
emailpb "github.com/letsencrypt/boulder/email/proto"
|
|
berrors "github.com/letsencrypt/boulder/errors"
|
|
"github.com/letsencrypt/boulder/features"
|
|
"github.com/letsencrypt/boulder/goodkey"
|
|
bgrpc "github.com/letsencrypt/boulder/grpc"
|
|
_ "github.com/letsencrypt/boulder/grpc/noncebalancer" // imported for its init function.
|
|
"github.com/letsencrypt/boulder/identifier"
|
|
"github.com/letsencrypt/boulder/issuance"
|
|
blog "github.com/letsencrypt/boulder/log"
|
|
"github.com/letsencrypt/boulder/metrics/measured_http"
|
|
"github.com/letsencrypt/boulder/nonce"
|
|
"github.com/letsencrypt/boulder/policy"
|
|
"github.com/letsencrypt/boulder/probs"
|
|
rapb "github.com/letsencrypt/boulder/ra/proto"
|
|
"github.com/letsencrypt/boulder/ratelimits"
|
|
"github.com/letsencrypt/boulder/revocation"
|
|
sapb "github.com/letsencrypt/boulder/sa/proto"
|
|
"github.com/letsencrypt/boulder/unpause"
|
|
"github.com/letsencrypt/boulder/web"
|
|
)
|
|
|
|
// Paths are the ACME-spec identified URL path-segments for various methods.
|
|
// NOTE: In metrics/measured_http we make the assumption that these are all
|
|
// lowercase plus hyphens. If you violate that assumption you should update
|
|
// measured_http.
|
|
const (
|
|
directoryPath = "/directory"
|
|
newNoncePath = "/acme/new-nonce"
|
|
newAcctPath = "/acme/new-acct"
|
|
newOrderPath = "/acme/new-order"
|
|
rolloverPath = "/acme/key-change"
|
|
revokeCertPath = "/acme/revoke-cert"
|
|
acctPath = "/acme/acct/"
|
|
orderPath = "/acme/order/"
|
|
authzPath = "/acme/authz/"
|
|
challengePath = "/acme/chall/"
|
|
finalizeOrderPath = "/acme/finalize/"
|
|
certPath = "/acme/cert/"
|
|
|
|
// Non-ACME paths.
|
|
getCertPath = "/get/cert/"
|
|
buildIDPath = "/build"
|
|
|
|
// Draft or likely-to-change paths
|
|
renewalInfoPath = "/draft-ietf-acme-ari-03/renewalInfo/"
|
|
)
|
|
|
|
const (
|
|
headerRetryAfter = "Retry-After"
|
|
// Our 99th percentile finalize latency is 2.3s. Asking clients to wait 3s
|
|
// before polling the order to get an updated status means that >99% of
|
|
// clients will fetch the updated order object exactly once,.
|
|
orderRetryAfter = 3
|
|
)
|
|
|
|
var errIncompleteGRPCResponse = errors.New("incomplete gRPC response message")
|
|
|
|
// WebFrontEndImpl provides all the logic for Boulder's web-facing interface,
|
|
// i.e., ACME. Its members configure the paths for various ACME functions,
|
|
// 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
|
|
// 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.
|
|
gnc nonce.Getter
|
|
// rnc is a nonce-service client used exclusively for the redemption of
|
|
// nonces. It uses a custom RPC load balancer which is configured to route
|
|
// requests to backends based on the prefix and HMAC key passed as in the
|
|
// context of the request. The HMAC and prefix are passed using context keys
|
|
// `nonce.HMACKeyCtxKey` and `nonce.PrefixCtxKey`.
|
|
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
|
|
|
|
// certificateChains maps IssuerNameIDs to slice of []byte containing a leading
|
|
// newline and one or more PEM encoded certificates separated by a newline,
|
|
// sorted from leaf to root. The first []byte is the default certificate chain,
|
|
// and any subsequent []byte is an alternate certificate chain.
|
|
certificateChains map[issuance.NameID][][]byte
|
|
|
|
// issuerCertificates is a map of IssuerNameIDs to issuer certificates built with the
|
|
// first entry from each of the certificateChains. These certificates are used
|
|
// to verify the signature of certificates provided in revocation requests.
|
|
issuerCertificates map[issuance.NameID]*issuance.Certificate
|
|
|
|
// URL to the current subscriber agreement (should contain some version identifier)
|
|
SubscriberAgreementURL string
|
|
|
|
// DirectoryCAAIdentity is used for the /directory response's "meta"
|
|
// element's "caaIdentities" field. It should match the VA's issuerDomain
|
|
// field value.
|
|
DirectoryCAAIdentity string
|
|
|
|
// DirectoryWebsite is used for the /directory response's "meta" element's
|
|
// "website" field.
|
|
DirectoryWebsite string
|
|
|
|
// Allowed prefix for legacy accounts used by verify.go's `lookupJWK`.
|
|
// See `cmd/boulder-wfe2/main.go`'s comment on the configuration field
|
|
// `LegacyKeyIDPrefix` for more information.
|
|
LegacyKeyIDPrefix string
|
|
|
|
// Key policy.
|
|
keyPolicy goodkey.KeyPolicy
|
|
|
|
// CORS settings
|
|
AllowOrigins []string
|
|
|
|
// requestTimeout is the per-request overall timeout.
|
|
requestTimeout time.Duration
|
|
|
|
// StaleTimeout determines the required staleness for certificates to be
|
|
// accessed via the Boulder-specific GET API. Certificates newer than
|
|
// staleTimeout must be accessed via POST-as-GET and the RFC 8555 ACME API. We
|
|
// do this to incentivize client developers to use the standard API.
|
|
staleTimeout time.Duration
|
|
|
|
limiter *ratelimits.Limiter
|
|
txnBuilder *ratelimits.TransactionBuilder
|
|
|
|
unpauseSigner unpause.JWTSigner
|
|
unpauseJWTLifetime time.Duration
|
|
unpauseURL string
|
|
|
|
// certProfiles is a map of acceptable certificate profile names to
|
|
// descriptions (perhaps including URLs) of those profiles. NewOrder
|
|
// Requests with a profile name not present in this map will be rejected.
|
|
certProfiles map[string]string
|
|
}
|
|
|
|
// NewWebFrontEndImpl constructs a web service for Boulder
|
|
func NewWebFrontEndImpl(
|
|
stats prometheus.Registerer,
|
|
clk clock.Clock,
|
|
keyPolicy goodkey.KeyPolicy,
|
|
certificateChains map[issuance.NameID][][]byte,
|
|
issuerCertificates map[issuance.NameID]*issuance.Certificate,
|
|
logger blog.Logger,
|
|
requestTimeout time.Duration,
|
|
staleTimeout time.Duration,
|
|
rac rapb.RegistrationAuthorityClient,
|
|
sac sapb.StorageAuthorityReadOnlyClient,
|
|
eec emailpb.ExporterClient,
|
|
gnc nonce.Getter,
|
|
rnc nonce.Redeemer,
|
|
rncKey []byte,
|
|
accountGetter AccountGetter,
|
|
limiter *ratelimits.Limiter,
|
|
txnBuilder *ratelimits.TransactionBuilder,
|
|
certProfiles map[string]string,
|
|
unpauseSigner unpause.JWTSigner,
|
|
unpauseJWTLifetime time.Duration,
|
|
unpauseURL string,
|
|
) (WebFrontEndImpl, error) {
|
|
if len(issuerCertificates) == 0 {
|
|
return WebFrontEndImpl{}, errors.New("must provide at least one issuer certificate")
|
|
}
|
|
|
|
if len(certificateChains) == 0 {
|
|
return WebFrontEndImpl{}, errors.New("must provide at least one certificate chain")
|
|
}
|
|
|
|
if gnc == nil {
|
|
return WebFrontEndImpl{}, errors.New("must provide a service for nonce issuance")
|
|
}
|
|
|
|
if rnc == nil {
|
|
return WebFrontEndImpl{}, errors.New("must provide a service for nonce redemption")
|
|
}
|
|
|
|
wfe := WebFrontEndImpl{
|
|
log: logger,
|
|
clk: clk,
|
|
keyPolicy: keyPolicy,
|
|
certificateChains: certificateChains,
|
|
issuerCertificates: issuerCertificates,
|
|
stats: initStats(stats),
|
|
requestTimeout: requestTimeout,
|
|
staleTimeout: staleTimeout,
|
|
ra: rac,
|
|
sa: sac,
|
|
ee: eec,
|
|
gnc: gnc,
|
|
rnc: rnc,
|
|
rncKey: rncKey,
|
|
accountGetter: accountGetter,
|
|
limiter: limiter,
|
|
txnBuilder: txnBuilder,
|
|
certProfiles: certProfiles,
|
|
unpauseSigner: unpauseSigner,
|
|
unpauseJWTLifetime: unpauseJWTLifetime,
|
|
unpauseURL: unpauseURL,
|
|
}
|
|
|
|
return wfe, nil
|
|
}
|
|
|
|
// HandleFunc registers a handler at the given path. It's
|
|
// http.HandleFunc(), but with a wrapper around the handler that
|
|
// provides some generic per-request functionality:
|
|
//
|
|
// * Set a Replay-Nonce header.
|
|
//
|
|
// * Respond to OPTIONS requests, including CORS preflight requests.
|
|
//
|
|
// * Set a no cache header
|
|
//
|
|
// * Respond http.StatusMethodNotAllowed for HTTP methods other than
|
|
// those listed.
|
|
//
|
|
// * Set CORS headers when responding to CORS "actual" requests.
|
|
//
|
|
// * Never send a body in response to a HEAD request. Anything
|
|
// written by the handler will be discarded if the method is HEAD.
|
|
// Also, all handlers that accept GET automatically accept HEAD.
|
|
func (wfe *WebFrontEndImpl) HandleFunc(mux *http.ServeMux, pattern string, h web.WFEHandlerFunc, methods ...string) {
|
|
methodsMap := make(map[string]bool)
|
|
for _, m := range methods {
|
|
methodsMap[m] = true
|
|
}
|
|
if methodsMap["GET"] && !methodsMap["HEAD"] {
|
|
// Allow HEAD for any resource that allows GET
|
|
methods = append(methods, "HEAD")
|
|
methodsMap["HEAD"] = true
|
|
}
|
|
methodsStr := strings.Join(methods, ", ")
|
|
handler := http.StripPrefix(pattern, 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)
|
|
|
|
logEvent.Endpoint = pattern
|
|
if request.URL != nil {
|
|
logEvent.Slug = request.URL.Path
|
|
}
|
|
if request.Method != "GET" || pattern == newNoncePath {
|
|
nonceMsg, err := wfe.gnc.Nonce(ctx, &emptypb.Empty{})
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "unable to get nonce"), err)
|
|
return
|
|
}
|
|
response.Header().Set("Replay-Nonce", nonceMsg.Nonce)
|
|
}
|
|
// Per section 7.1 "Resources":
|
|
// The "index" link relation is present on all resources other than the
|
|
// directory and indicates the URL of the directory.
|
|
if pattern != directoryPath {
|
|
directoryURL := web.RelativeEndpoint(request, directoryPath)
|
|
response.Header().Add("Link", link(directoryURL, "index"))
|
|
}
|
|
|
|
switch request.Method {
|
|
case "HEAD":
|
|
// Go's net/http (and httptest) servers will strip out the body
|
|
// of responses for us. This keeps the Content-Length for HEAD
|
|
// requests as the same as GET requests per the spec.
|
|
case "OPTIONS":
|
|
wfe.Options(response, request, methodsStr, methodsMap)
|
|
return
|
|
}
|
|
|
|
// No cache header is set for all requests, succeed or fail.
|
|
addNoCacheHeader(response)
|
|
|
|
if !methodsMap[request.Method] {
|
|
response.Header().Set("Allow", methodsStr)
|
|
wfe.sendError(response, logEvent, probs.MethodNotAllowed(), nil)
|
|
return
|
|
}
|
|
|
|
wfe.setCORSHeaders(response, request, "")
|
|
|
|
timeout := wfe.requestTimeout
|
|
if timeout == 0 {
|
|
timeout = 5 * time.Minute
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
|
|
// Call the wrapped handler.
|
|
h(ctx, logEvent, response, request)
|
|
cancel()
|
|
}),
|
|
))
|
|
mux.Handle(pattern, handler)
|
|
}
|
|
|
|
func marshalIndent(v interface{}) ([]byte, error) {
|
|
return json.MarshalIndent(v, "", " ")
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) writeJsonResponse(response http.ResponseWriter, logEvent *web.RequestEvent, status int, v interface{}) error {
|
|
jsonReply, err := marshalIndent(v)
|
|
if err != nil {
|
|
return err // All callers are responsible for handling this error
|
|
}
|
|
|
|
response.Header().Set("Content-Type", "application/json")
|
|
response.WriteHeader(status)
|
|
_, err = response.Write(jsonReply)
|
|
if err != nil {
|
|
// Don't worry about returning this error because the caller will
|
|
// never handle it.
|
|
wfe.log.Warningf("Could not write response: %s", err)
|
|
logEvent.AddError("failed to write response: %s", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// requestProto returns "http" for HTTP requests and "https" for HTTPS
|
|
// requests. It supports the use of "X-Forwarded-Proto" to override the protocol.
|
|
func requestProto(request *http.Request) string {
|
|
proto := "http"
|
|
|
|
// If the request was received via TLS, use `https://` for the protocol
|
|
if request.TLS != nil {
|
|
proto = "https"
|
|
}
|
|
|
|
// Allow upstream proxies to specify the forwarded protocol. Allow this value
|
|
// to override our own guess.
|
|
if specifiedProto := request.Header.Get("X-Forwarded-Proto"); specifiedProto != "" {
|
|
proto = specifiedProto
|
|
}
|
|
|
|
return proto
|
|
}
|
|
|
|
const randomDirKeyExplanationLink = "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417"
|
|
|
|
func (wfe *WebFrontEndImpl) relativeDirectory(request *http.Request, directory map[string]interface{}) ([]byte, error) {
|
|
// Create an empty map sized equal to the provided directory to store the
|
|
// relative-ized result
|
|
relativeDir := make(map[string]interface{}, len(directory))
|
|
|
|
// Copy each entry of the provided directory into the new relative map,
|
|
// prefixing it with the request protocol and host.
|
|
for k, v := range directory {
|
|
if v == randomDirKeyExplanationLink {
|
|
relativeDir[k] = v
|
|
continue
|
|
}
|
|
switch v := v.(type) {
|
|
case string:
|
|
// Only relative-ize top level string values, e.g. not the "meta" element
|
|
relativeDir[k] = web.RelativeEndpoint(request, v)
|
|
default:
|
|
// If it isn't a string, put it into the results unmodified
|
|
relativeDir[k] = v
|
|
}
|
|
}
|
|
|
|
directoryJSON, err := marshalIndent(relativeDir)
|
|
// This should never happen since we are just marshalling known strings
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return directoryJSON, nil
|
|
}
|
|
|
|
// Handler returns an http.Handler that uses various functions for
|
|
// various ACME-specified paths.
|
|
func (wfe *WebFrontEndImpl) Handler(stats prometheus.Registerer, oTelHTTPOptions ...otelhttp.Option) http.Handler {
|
|
m := http.NewServeMux()
|
|
|
|
// POSTable ACME endpoints
|
|
wfe.HandleFunc(m, newAcctPath, wfe.NewAccount, "POST")
|
|
wfe.HandleFunc(m, acctPath, wfe.Account, "POST")
|
|
wfe.HandleFunc(m, revokeCertPath, wfe.RevokeCertificate, "POST")
|
|
wfe.HandleFunc(m, rolloverPath, wfe.KeyRollover, "POST")
|
|
wfe.HandleFunc(m, newOrderPath, wfe.NewOrder, "POST")
|
|
wfe.HandleFunc(m, finalizeOrderPath, wfe.FinalizeOrder, "POST")
|
|
|
|
// GETable and POST-as-GETable ACME endpoints
|
|
wfe.HandleFunc(m, directoryPath, wfe.Directory, "GET", "POST")
|
|
wfe.HandleFunc(m, newNoncePath, wfe.Nonce, "GET", "POST")
|
|
wfe.HandleFunc(m, orderPath, wfe.GetOrder, "GET", "POST")
|
|
wfe.HandleFunc(m, authzPath, wfe.AuthorizationHandler, "GET", "POST")
|
|
wfe.HandleFunc(m, challengePath, wfe.ChallengeHandler, "GET", "POST")
|
|
wfe.HandleFunc(m, certPath, wfe.Certificate, "GET", "POST")
|
|
|
|
// Boulder specific endpoints
|
|
wfe.HandleFunc(m, getCertPath, wfe.Certificate, "GET")
|
|
wfe.HandleFunc(m, buildIDPath, wfe.BuildID, "GET")
|
|
|
|
// Endpoint for draft-ietf-acme-ari
|
|
if features.Get().ServeRenewalInfo {
|
|
wfe.HandleFunc(m, renewalInfoPath, wfe.RenewalInfo, "GET", "POST")
|
|
}
|
|
|
|
// We don't use our special HandleFunc for "/" because it matches everything,
|
|
// meaning we can wind up returning 405 when we mean to return 404. See
|
|
// https://github.com/letsencrypt/boulder/issues/717
|
|
m.Handle("/", web.NewTopHandler(wfe.log, web.WFEHandlerFunc(wfe.Index)))
|
|
return measured_http.New(m, wfe.clk, stats, oTelHTTPOptions...)
|
|
}
|
|
|
|
// Method implementations
|
|
|
|
// Index serves a simple identification page. It is not part of the ACME spec.
|
|
func (wfe *WebFrontEndImpl) Index(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) {
|
|
// All requests that are not handled by our ACME endpoints ends up
|
|
// here. Set the our logEvent endpoint to "/" and the slug to the path
|
|
// minus "/" to make sure that we properly set log information about
|
|
// the request, even in the case of a 404
|
|
logEvent.Endpoint = "/"
|
|
logEvent.Slug = request.URL.Path[1:]
|
|
|
|
// http://golang.org/pkg/net/http/#example_ServeMux_Handle
|
|
// The "/" pattern matches everything, so we need to check
|
|
// that we're at the root here.
|
|
if request.URL.Path != "/" {
|
|
logEvent.AddError("Resource not found")
|
|
http.NotFound(response, request)
|
|
response.Header().Set("Content-Type", "application/problem+json")
|
|
return
|
|
}
|
|
|
|
if request.Method != "GET" {
|
|
response.Header().Set("Allow", "GET")
|
|
wfe.sendError(response, logEvent, probs.MethodNotAllowed(), errors.New("Bad method"))
|
|
return
|
|
}
|
|
|
|
addNoCacheHeader(response)
|
|
response.Header().Set("Content-Type", "text/html")
|
|
fmt.Fprintf(response, `<html>
|
|
<body>
|
|
This is an <a href="https://tools.ietf.org/html/rfc8555">ACME</a>
|
|
Certificate Authority running <a href="https://github.com/letsencrypt/boulder">Boulder</a>.
|
|
JSON directory is available at <a href="%s">%s</a>.
|
|
</body>
|
|
</html>
|
|
`, directoryPath, directoryPath)
|
|
}
|
|
|
|
func addNoCacheHeader(w http.ResponseWriter) {
|
|
w.Header().Add("Cache-Control", "public, max-age=0, no-cache")
|
|
}
|
|
|
|
func addRequesterHeader(w http.ResponseWriter, requester int64) {
|
|
if requester > 0 {
|
|
w.Header().Set("Boulder-Requester", strconv.FormatInt(requester, 10))
|
|
}
|
|
}
|
|
|
|
// Directory is an HTTP request handler that provides the directory
|
|
// object stored in the WFE's DirectoryEndpoints member with paths prefixed
|
|
// using the `request.Host` of the HTTP request.
|
|
func (wfe *WebFrontEndImpl) Directory(
|
|
ctx context.Context,
|
|
logEvent *web.RequestEvent,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
directoryEndpoints := map[string]interface{}{
|
|
"newAccount": newAcctPath,
|
|
"newNonce": newNoncePath,
|
|
"revokeCert": revokeCertPath,
|
|
"newOrder": newOrderPath,
|
|
"keyChange": rolloverPath,
|
|
}
|
|
|
|
if features.Get().ServeRenewalInfo {
|
|
// ARI-capable clients are expected to add the trailing slash per the
|
|
// draft. We explicitly strip the trailing slash here so that clients
|
|
// don't need to add trailing slash handling in their own code, saving
|
|
// them minimal amounts of complexity.
|
|
directoryEndpoints["renewalInfo"] = strings.TrimRight(renewalInfoPath, "/")
|
|
}
|
|
|
|
if request.Method == http.MethodPost {
|
|
acct, err := wfe.validPOSTAsGETForAccount(request, ctx, logEvent)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
logEvent.Requester = acct.ID
|
|
}
|
|
|
|
// Add a random key to the directory in order to make sure that clients don't hardcode an
|
|
// expected set of keys. This ensures that we can properly extend the directory when we
|
|
// need to add a new endpoint or meta element.
|
|
directoryEndpoints[core.RandomString(8)] = randomDirKeyExplanationLink
|
|
|
|
// ACME since draft-02 describes an optional "meta" directory entry. The
|
|
// meta entry may optionally contain a "termsOfService" URI for the
|
|
// current ToS.
|
|
metaMap := map[string]interface{}{
|
|
"termsOfService": wfe.SubscriberAgreementURL,
|
|
}
|
|
// The "meta" directory entry may also include a []string of CAA identities
|
|
if wfe.DirectoryCAAIdentity != "" {
|
|
// The specification says caaIdentities is an array of strings. In
|
|
// practice Boulder's VA only allows configuring ONE CAA identity. Given
|
|
// that constraint it doesn't make sense to allow multiple directory CAA
|
|
// identities so we use just the `wfe.DirectoryCAAIdentity` alone.
|
|
metaMap["caaIdentities"] = []string{
|
|
wfe.DirectoryCAAIdentity,
|
|
}
|
|
}
|
|
if len(wfe.certProfiles) != 0 {
|
|
metaMap["profiles"] = wfe.certProfiles
|
|
}
|
|
// The "meta" directory entry may also include a string with a website URL
|
|
if wfe.DirectoryWebsite != "" {
|
|
metaMap["website"] = wfe.DirectoryWebsite
|
|
}
|
|
directoryEndpoints["meta"] = metaMap
|
|
|
|
response.Header().Set("Content-Type", "application/json")
|
|
|
|
relDir, err := wfe.relativeDirectory(request, directoryEndpoints)
|
|
if err != nil {
|
|
marshalProb := probs.ServerInternal("unable to marshal JSON directory")
|
|
wfe.sendError(response, logEvent, marshalProb, nil)
|
|
return
|
|
}
|
|
|
|
logEvent.Suppress()
|
|
response.Write(relDir)
|
|
}
|
|
|
|
// Nonce is an endpoint for getting a fresh nonce with an HTTP GET or HEAD
|
|
// request. This endpoint only returns a status code header - the `HandleFunc`
|
|
// wrapper ensures that a nonce is written in the correct response header.
|
|
func (wfe *WebFrontEndImpl) Nonce(
|
|
ctx context.Context,
|
|
logEvent *web.RequestEvent,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
if request.Method == http.MethodPost {
|
|
acct, err := wfe.validPOSTAsGETForAccount(request, ctx, logEvent)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
logEvent.Requester = acct.ID
|
|
}
|
|
|
|
statusCode := http.StatusNoContent
|
|
// The ACME specification says GET requests should receive http.StatusNoContent
|
|
// and HEAD/POST-as-GET requests should receive http.StatusOK.
|
|
if request.Method != "GET" {
|
|
statusCode = http.StatusOK
|
|
}
|
|
response.WriteHeader(statusCode)
|
|
|
|
// The ACME specification says the server MUST include a Cache-Control header
|
|
// field with the "no-store" directive in responses for the newNonce resource,
|
|
// in order to prevent caching of this resource.
|
|
response.Header().Set("Cache-Control", "no-store")
|
|
|
|
// No need to log successful nonce requests, they're boring.
|
|
logEvent.Suppress()
|
|
}
|
|
|
|
// sendError wraps web.SendError
|
|
func (wfe *WebFrontEndImpl) sendError(response http.ResponseWriter, logEvent *web.RequestEvent, eerr any, ierr error) {
|
|
// TODO(#4980): Simplify this function to only take a single error argument,
|
|
// and use web.ProblemDetailsForError to extract the corresponding prob from
|
|
// that. For now, though, the third argument has to be `any` so that it can
|
|
// be either an error or a problem, and this function can handle either one.
|
|
var prob *probs.ProblemDetails
|
|
switch v := eerr.(type) {
|
|
case *probs.ProblemDetails:
|
|
prob = v
|
|
case error:
|
|
prob = web.ProblemDetailsForError(v, "")
|
|
default:
|
|
panic(fmt.Sprintf("wfe.sendError got %#v (type %T), but expected ProblemDetails or error", eerr, eerr))
|
|
}
|
|
|
|
var bErr *berrors.BoulderError
|
|
if errors.As(ierr, &bErr) {
|
|
retryAfterSeconds := int(bErr.RetryAfter.Round(time.Second).Seconds())
|
|
if retryAfterSeconds > 0 {
|
|
response.Header().Add(headerRetryAfter, strconv.Itoa(retryAfterSeconds))
|
|
if bErr.Type == berrors.RateLimit {
|
|
response.Header().Add("Link", link("https://letsencrypt.org/docs/rate-limits", "help"))
|
|
}
|
|
}
|
|
}
|
|
if prob.HTTPStatus == http.StatusInternalServerError {
|
|
response.Header().Add(headerRetryAfter, "60")
|
|
}
|
|
wfe.stats.httpErrorCount.With(prometheus.Labels{"type": string(prob.Type)}).Inc()
|
|
web.SendError(wfe.log, response, logEvent, prob, ierr)
|
|
}
|
|
|
|
func link(url, relation string) string {
|
|
return fmt.Sprintf("<%s>;rel=\"%s\"", url, relation)
|
|
}
|
|
|
|
// contactsToEmails converts a *[]string of contacts (e.g. mailto:
|
|
// person@example.com) to a []string of valid email addresses. Non-email
|
|
// contacts or contacts with invalid email addresses are ignored.
|
|
func contactsToEmails(contacts *[]string) []string {
|
|
if contacts == nil {
|
|
return nil
|
|
}
|
|
var emails []string
|
|
for _, c := range *contacts {
|
|
if !strings.HasPrefix(c, "mailto:") {
|
|
continue
|
|
}
|
|
address := strings.TrimPrefix(c, "mailto:")
|
|
err := policy.ValidEmail(address)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
emails = append(emails, address)
|
|
}
|
|
return emails
|
|
}
|
|
|
|
// checkNewAccountLimits checks whether sufficient limit quota exists for the
|
|
// creation of a new account. If so, that quota is spent. If an error is
|
|
// encountered during the check, it is logged but not returned. A refund
|
|
// function is returned that can be called to refund the quota if the account
|
|
// creation fails, the func will be nil if any error was encountered during the
|
|
// check.
|
|
func (wfe *WebFrontEndImpl) checkNewAccountLimits(ctx context.Context, ip net.IP) (func(), error) {
|
|
txns, err := wfe.txnBuilder.NewAccountLimitTransactions(ip)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building new account limit transactions: %w", err)
|
|
}
|
|
|
|
d, err := wfe.limiter.BatchSpend(ctx, txns)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("spending new account limits: %w", err)
|
|
}
|
|
|
|
err = d.Result(wfe.clk.Now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return func() {
|
|
_, err := wfe.limiter.BatchRefund(ctx, txns)
|
|
if err != nil {
|
|
wfe.log.Warningf("refunding new account limits: %s", err)
|
|
}
|
|
}, nil
|
|
}
|
|
|
|
// NewAccount is used by clients to submit a new account
|
|
func (wfe *WebFrontEndImpl) NewAccount(
|
|
ctx context.Context,
|
|
logEvent *web.RequestEvent,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
|
|
// NewAccount uses `validSelfAuthenticatedPOST` instead of
|
|
// `validPOSTforAccount` because there is no account to authenticate against
|
|
// until after it is created!
|
|
body, key, err := wfe.validSelfAuthenticatedPOST(ctx, request)
|
|
if err != nil {
|
|
// validSelfAuthenticatedPOST handles its own setting of logEvent.Errors
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
|
|
var accountCreateRequest struct {
|
|
Contact *[]string `json:"contact"`
|
|
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"`
|
|
OnlyReturnExisting bool `json:"onlyReturnExisting"`
|
|
}
|
|
|
|
err = json.Unmarshal(body, &accountCreateRequest)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Error unmarshaling JSON"), err)
|
|
return
|
|
}
|
|
|
|
returnExistingAcct := func(acctPB *corepb.Registration) {
|
|
if core.AcmeStatus(acctPB.Status) == core.StatusDeactivated {
|
|
// If there is an existing, but deactivated account, then return an unauthorized
|
|
// problem informing the user that this account was deactivated
|
|
wfe.sendError(response, logEvent, probs.Unauthorized(
|
|
"An account with the provided public key exists but is deactivated"), nil)
|
|
return
|
|
}
|
|
|
|
response.Header().Set("Location",
|
|
web.RelativeEndpoint(request, fmt.Sprintf("%s%d", acctPath, acctPB.Id)))
|
|
logEvent.Requester = acctPB.Id
|
|
addRequesterHeader(response, acctPB.Id)
|
|
|
|
acct, err := bgrpc.PbToRegistration(acctPB)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling account"), err)
|
|
return
|
|
}
|
|
prepAccountForDisplay(&acct)
|
|
|
|
err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, acct)
|
|
if err != nil {
|
|
// ServerInternal because we just created this account, and it
|
|
// should be OK.
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling account"), err)
|
|
return
|
|
}
|
|
}
|
|
|
|
keyBytes, err := key.MarshalJSON()
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent,
|
|
web.ProblemDetailsForError(err, "Error creating new account"), err)
|
|
return
|
|
}
|
|
existingAcct, err := wfe.sa.GetRegistrationByKey(ctx, &sapb.JSONWebKey{Jwk: keyBytes})
|
|
if err == nil {
|
|
returnExistingAcct(existingAcct)
|
|
return
|
|
} else if !errors.Is(err, berrors.NotFound) {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "failed check for existing account"), err)
|
|
return
|
|
}
|
|
|
|
// If the request included a true "OnlyReturnExisting" field and we did not
|
|
// find an existing registration with the key specified then we must return an
|
|
// error and not create a new account.
|
|
if accountCreateRequest.OnlyReturnExisting {
|
|
wfe.sendError(response, logEvent, probs.AccountDoesNotExist(
|
|
"No account exists with the provided key"), nil)
|
|
return
|
|
}
|
|
|
|
if !accountCreateRequest.TermsOfServiceAgreed {
|
|
wfe.sendError(response, logEvent, probs.Malformed("must agree to terms of service"), nil)
|
|
return
|
|
}
|
|
|
|
ip, err := extractRequesterIP(request)
|
|
if err != nil {
|
|
wfe.sendError(
|
|
response,
|
|
logEvent,
|
|
probs.ServerInternal("couldn't parse the remote (that is, the client's) address"),
|
|
fmt.Errorf("Couldn't parse RemoteAddr: %s", request.RemoteAddr),
|
|
)
|
|
return
|
|
}
|
|
|
|
var contacts []string
|
|
if accountCreateRequest.Contact != nil {
|
|
contacts = *accountCreateRequest.Contact
|
|
}
|
|
|
|
// Create corepb.Registration from provided account information
|
|
reg := corepb.Registration{
|
|
Contact: contacts,
|
|
Agreement: wfe.SubscriberAgreementURL,
|
|
Key: keyBytes,
|
|
}
|
|
|
|
refundLimits, err := wfe.checkNewAccountLimits(ctx, ip)
|
|
if err != nil {
|
|
if errors.Is(err, berrors.RateLimit) {
|
|
wfe.sendError(response, logEvent, probs.RateLimited(err.Error()), err)
|
|
return
|
|
} else {
|
|
// Proceed, since we don't want internal rate limit system failures to
|
|
// block all account creation.
|
|
logEvent.IgnoredRateLimitError = err.Error()
|
|
}
|
|
}
|
|
|
|
var newRegistrationSuccessful bool
|
|
defer func() {
|
|
if !newRegistrationSuccessful && refundLimits != nil {
|
|
go refundLimits()
|
|
}
|
|
}()
|
|
|
|
// Send the registration to the RA via grpc
|
|
acctPB, err := wfe.ra.NewRegistration(ctx, ®)
|
|
if err != nil {
|
|
if errors.Is(err, berrors.Duplicate) {
|
|
existingAcct, err := wfe.sa.GetRegistrationByKey(ctx, &sapb.JSONWebKey{Jwk: keyBytes})
|
|
if err == nil {
|
|
returnExistingAcct(existingAcct)
|
|
return
|
|
}
|
|
// return error even if berrors.NotFound, as the duplicate key error we got from
|
|
// ra.NewRegistration indicates it _does_ already exist.
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "checking for existing account"), err)
|
|
return
|
|
}
|
|
wfe.sendError(response, logEvent,
|
|
web.ProblemDetailsForError(err, "Error creating new account"), err)
|
|
return
|
|
}
|
|
|
|
registrationValid := func(reg *corepb.Registration) bool {
|
|
return !(len(reg.Key) == 0) && reg.Id != 0
|
|
}
|
|
|
|
if acctPB == nil || !registrationValid(acctPB) {
|
|
wfe.sendError(response, logEvent,
|
|
web.ProblemDetailsForError(err, "Error creating new account"), err)
|
|
return
|
|
}
|
|
acct, err := bgrpc.PbToRegistration(acctPB)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent,
|
|
web.ProblemDetailsForError(err, "Error creating new account"), err)
|
|
return
|
|
}
|
|
logEvent.Requester = acct.ID
|
|
addRequesterHeader(response, acct.ID)
|
|
|
|
acctURL := web.RelativeEndpoint(request, fmt.Sprintf("%s%d", acctPath, acct.ID))
|
|
|
|
response.Header().Add("Location", acctURL)
|
|
if len(wfe.SubscriberAgreementURL) > 0 {
|
|
response.Header().Add("Link", link(wfe.SubscriberAgreementURL, "terms-of-service"))
|
|
}
|
|
|
|
prepAccountForDisplay(&acct)
|
|
|
|
err = wfe.writeJsonResponse(response, logEvent, http.StatusCreated, acct)
|
|
if err != nil {
|
|
// ServerInternal because we just created this account, and it
|
|
// should be OK.
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling account"), err)
|
|
return
|
|
}
|
|
newRegistrationSuccessful = true
|
|
|
|
emails := contactsToEmails(accountCreateRequest.Contact)
|
|
if wfe.ee != nil && len(emails) > 0 {
|
|
_, err := wfe.ee.SendContacts(ctx, &emailpb.SendContactsRequest{
|
|
// Note: We are explicitly using the contacts provided by the
|
|
// subscriber here. The RA will eventually stop accepting contacts.
|
|
Emails: emails,
|
|
})
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Error sending contacts"), err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseRevocation accepts the payload for a revocation request and parses it
|
|
// into both the certificate to be revoked and the requested revocation reason
|
|
// (if any). Returns an error if any of the parsing fails, or if the given cert
|
|
// or revocation reason don't pass simple static checks. Also populates some
|
|
// metadata fields on the given logEvent.
|
|
func (wfe *WebFrontEndImpl) parseRevocation(
|
|
jwsBody []byte, logEvent *web.RequestEvent) (*x509.Certificate, revocation.Reason, error) {
|
|
// Read the revoke request from the JWS payload
|
|
var revokeRequest struct {
|
|
CertificateDER core.JSONBuffer `json:"certificate"`
|
|
Reason *revocation.Reason `json:"reason"`
|
|
}
|
|
err := json.Unmarshal(jwsBody, &revokeRequest)
|
|
if err != nil {
|
|
return nil, 0, berrors.MalformedError("Unable to JSON parse revoke request")
|
|
}
|
|
|
|
// Parse the provided certificate
|
|
parsedCertificate, err := x509.ParseCertificate(revokeRequest.CertificateDER)
|
|
if err != nil {
|
|
return nil, 0, berrors.MalformedError("Unable to parse certificate DER")
|
|
}
|
|
|
|
// Compute and record the serial number of the provided certificate
|
|
serial := core.SerialToString(parsedCertificate.SerialNumber)
|
|
logEvent.Extra["CertificateSerial"] = serial
|
|
if revokeRequest.Reason != nil {
|
|
logEvent.Extra["RevocationReason"] = *revokeRequest.Reason
|
|
}
|
|
|
|
// Try to validate the signature on the provided cert using its corresponding
|
|
// issuer certificate.
|
|
issuerCert, ok := wfe.issuerCertificates[issuance.IssuerNameID(parsedCertificate)]
|
|
if !ok || issuerCert == nil {
|
|
return nil, 0, berrors.NotFoundError("Certificate from unrecognized issuer")
|
|
}
|
|
err = parsedCertificate.CheckSignatureFrom(issuerCert.Certificate)
|
|
if err != nil {
|
|
return nil, 0, berrors.NotFoundError("No such certificate")
|
|
}
|
|
logEvent.Identifiers = identifier.FromCert(parsedCertificate)
|
|
|
|
if parsedCertificate.NotAfter.Before(wfe.clk.Now()) {
|
|
return nil, 0, berrors.UnauthorizedError("Certificate is expired")
|
|
}
|
|
|
|
// Verify the revocation reason supplied is allowed
|
|
reason := revocation.Reason(0)
|
|
if revokeRequest.Reason != nil {
|
|
if _, present := revocation.UserAllowedReasons[*revokeRequest.Reason]; !present {
|
|
return nil, 0, berrors.BadRevocationReasonError(int64(*revokeRequest.Reason))
|
|
}
|
|
reason = *revokeRequest.Reason
|
|
}
|
|
|
|
return parsedCertificate, reason, nil
|
|
}
|
|
|
|
type revocationEvidence struct {
|
|
Serial string
|
|
Reason revocation.Reason
|
|
RegID int64
|
|
Method string
|
|
}
|
|
|
|
// revokeCertBySubscriberKey processes an outer JWS as a revocation request that
|
|
// is authenticated by a KeyID and the associated account.
|
|
func (wfe *WebFrontEndImpl) revokeCertBySubscriberKey(
|
|
ctx context.Context,
|
|
outerJWS *bJSONWebSignature,
|
|
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)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cert, reason, err := wfe.parseRevocation(jwsBody, logEvent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
wfe.log.AuditObject("Authenticated revocation", revocationEvidence{
|
|
Serial: core.SerialToString(cert.SerialNumber),
|
|
Reason: reason,
|
|
RegID: acct.ID,
|
|
Method: "applicant",
|
|
})
|
|
|
|
// The RA will confirm that the authenticated account either originally
|
|
// issued the certificate, or has demonstrated control over all identifiers
|
|
// in the certificate.
|
|
_, err = wfe.ra.RevokeCertByApplicant(ctx, &rapb.RevokeCertByApplicantRequest{
|
|
Cert: cert.Raw,
|
|
Code: int64(reason),
|
|
RegID: acct.ID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// revokeCertByCertKey processes an outer JWS as a revocation request that is
|
|
// authenticated by an embedded JWK. E.g. in the case where someone is
|
|
// requesting a revocation by using the keypair associated with the certificate
|
|
// to be revoked
|
|
func (wfe *WebFrontEndImpl) revokeCertByCertKey(
|
|
ctx context.Context,
|
|
outerJWS *bJSONWebSignature,
|
|
request *http.Request,
|
|
logEvent *web.RequestEvent) error {
|
|
// For embedded JWK revocations we authenticate the outer JWS by using
|
|
// `validSelfAuthenticatedJWS` similar to new-reg and key rollover.
|
|
// We do *not* use `validSelfAuthenticatedPOST` here because we've already
|
|
// read the HTTP request body in `parseJWSRequest` and it is now empty.
|
|
jwsBody, jwk, prob := wfe.validSelfAuthenticatedJWS(ctx, outerJWS, request)
|
|
if prob != nil {
|
|
return prob
|
|
}
|
|
|
|
cert, reason, err := wfe.parseRevocation(jwsBody, logEvent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// For embedded JWK revocations we decide if a requester is able to revoke a specific
|
|
// certificate by checking that to-be-revoked certificate has the same public
|
|
// key as the JWK that was used to authenticate the request
|
|
if !core.KeyDigestEquals(jwk, cert.PublicKey) {
|
|
return berrors.UnauthorizedError(
|
|
"JWK embedded in revocation request must be the same public key as the cert to be revoked")
|
|
}
|
|
|
|
wfe.log.AuditObject("Authenticated revocation", revocationEvidence{
|
|
Serial: core.SerialToString(cert.SerialNumber),
|
|
Reason: reason,
|
|
RegID: 0,
|
|
Method: "privkey",
|
|
})
|
|
|
|
// The RA assumes here that the WFE2 has validated the JWS as proving
|
|
// control of the private key corresponding to this certificate.
|
|
_, err = wfe.ra.RevokeCertByKey(ctx, &rapb.RevokeCertByKeyRequest{
|
|
Cert: cert.Raw,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RevokeCertificate is used by clients to request the revocation of a cert. The
|
|
// revocation request is handled uniquely based on the method of authentication
|
|
// used.
|
|
func (wfe *WebFrontEndImpl) RevokeCertificate(
|
|
ctx context.Context,
|
|
logEvent *web.RequestEvent,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
|
|
// The ACME specification handles the verification of revocation requests
|
|
// differently from other endpoints. For this reason we do *not* immediately
|
|
// call `wfe.validPOSTForAccount` like all of the other endpoints.
|
|
// For this endpoint we need to accept a JWS with an embedded JWK, or a JWS
|
|
// with an embedded key ID, handling each case differently in terms of which
|
|
// certificates are authorized to be revoked by the requester
|
|
|
|
// Parse the JWS from the HTTP Request
|
|
jws, err := wfe.parseJWSRequest(request)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
|
|
// Figure out which type of authentication this JWS uses
|
|
authType, err := checkJWSAuthType(jws.Signatures[0].Header)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
|
|
// Handle the revocation request according to how it is authenticated, or if
|
|
// the authentication type is unknown, error immediately
|
|
switch authType {
|
|
case embeddedKeyID:
|
|
err = wfe.revokeCertBySubscriberKey(ctx, jws, request, logEvent)
|
|
case embeddedJWK:
|
|
err = wfe.revokeCertByCertKey(ctx, jws, request, logEvent)
|
|
default:
|
|
err = berrors.MalformedError("Malformed JWS, no KeyID or embedded JWK")
|
|
}
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to revoke"), err)
|
|
return
|
|
}
|
|
|
|
response.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// ChallengeHandler handles POST requests to challenge URLs of the form /acme/chall/{regID}/{authzID}/{challID}.
|
|
func (wfe *WebFrontEndImpl) ChallengeHandler(
|
|
ctx context.Context,
|
|
logEvent *web.RequestEvent,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
slug := strings.Split(request.URL.Path, "/")
|
|
if len(slug) != 3 {
|
|
wfe.sendError(response, logEvent, probs.NotFound("No such challenge"), nil)
|
|
return
|
|
}
|
|
// TODO(#7683): the regID is currently ignored.
|
|
wfe.Challenge(ctx, logEvent, response, request, slug[1], slug[2])
|
|
}
|
|
|
|
// Challenge handles POSTS to both formats of challenge URLs.
|
|
func (wfe *WebFrontEndImpl) Challenge(
|
|
ctx context.Context,
|
|
logEvent *web.RequestEvent,
|
|
response http.ResponseWriter,
|
|
request *http.Request,
|
|
authorizationIDStr string,
|
|
challengeID string) {
|
|
authorizationID, err := strconv.ParseInt(authorizationIDStr, 10, 64)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Invalid authorization ID"), nil)
|
|
return
|
|
}
|
|
authzPB, err := wfe.ra.GetAuthorization(ctx, &rapb.GetAuthorizationRequest{Id: authorizationID})
|
|
if err != nil {
|
|
if errors.Is(err, berrors.NotFound) {
|
|
wfe.sendError(response, logEvent, probs.NotFound("No such challenge"), nil)
|
|
} else {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Problem getting authorization"), err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Ensure gRPC response is complete.
|
|
if core.IsAnyNilOrZero(authzPB.Id, identifier.FromProtoWithDefault(authzPB), authzPB.Status, authzPB.Expires) {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Problem getting authorization"), errIncompleteGRPCResponse)
|
|
return
|
|
}
|
|
|
|
authz, err := bgrpc.PBToAuthz(authzPB)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Problem getting authorization"), err)
|
|
return
|
|
}
|
|
challengeIndex := authz.FindChallengeByStringID(challengeID)
|
|
if challengeIndex == -1 {
|
|
wfe.sendError(response, logEvent, probs.NotFound("No such challenge"), nil)
|
|
return
|
|
}
|
|
|
|
if authz.Expires == nil || authz.Expires.Before(wfe.clk.Now()) {
|
|
wfe.sendError(response, logEvent, probs.NotFound("Expired authorization"), nil)
|
|
return
|
|
}
|
|
|
|
logEvent.Identifiers = identifier.ACMEIdentifiers{authz.Identifier}
|
|
logEvent.Status = string(authz.Status)
|
|
|
|
challenge := authz.Challenges[challengeIndex]
|
|
switch request.Method {
|
|
case "GET", "HEAD":
|
|
wfe.getChallenge(response, request, authz, &challenge, logEvent)
|
|
|
|
case "POST":
|
|
logEvent.ChallengeType = string(challenge.Type)
|
|
wfe.postChallenge(ctx, response, request, authz, challengeIndex, logEvent)
|
|
}
|
|
}
|
|
|
|
// prepAccountForDisplay takes a core.Registration and mutates it to be ready
|
|
// for display in a JSON response. Primarily it papers over legacy ACME v1
|
|
// features or non-standard details internal to Boulder we don't want clients to
|
|
// rely on.
|
|
func prepAccountForDisplay(acct *core.Registration) {
|
|
// Zero out the account ID so that it isn't marshalled. RFC 8555 specifies
|
|
// using the Location header for learning the account ID.
|
|
acct.ID = 0
|
|
|
|
// We populate the account Agreement field when creating a new response to
|
|
// track which terms-of-service URL was in effect when an account with
|
|
// "termsOfServiceAgreed":"true" is created. That said, we don't want to send
|
|
// this value back to a V2 client. The "Agreement" field of an
|
|
// account/registration is a V1 notion so we strip it here in the WFE2 before
|
|
// returning the account.
|
|
acct.Agreement = ""
|
|
}
|
|
|
|
// prepChallengeForDisplay takes a core.Challenge and prepares it for display to
|
|
// the client by filling in its URL field and clearing several unnecessary
|
|
// fields.
|
|
func (wfe *WebFrontEndImpl) prepChallengeForDisplay(
|
|
request *http.Request,
|
|
authz core.Authorization,
|
|
challenge *core.Challenge,
|
|
) {
|
|
// Update the challenge URL to be relative to the HTTP request Host
|
|
challenge.URL = web.RelativeEndpoint(request, fmt.Sprintf("%s%d/%s/%s", challengePath, authz.RegistrationID, authz.ID, challenge.StringID()))
|
|
|
|
// Internally, we store challenge error problems with just the short form
|
|
// (e.g. "CAA") of the problem type. But for external display, we need to
|
|
// prefix the error type with the RFC8555 ACME Error namespace.
|
|
if challenge.Error != nil {
|
|
challenge.Error.Type = probs.ErrorNS + challenge.Error.Type
|
|
}
|
|
|
|
// If the authz has been marked invalid, consider all challenges on that authz
|
|
// to be invalid as well.
|
|
if authz.Status == core.StatusInvalid {
|
|
challenge.Status = authz.Status
|
|
}
|
|
|
|
// This field is not useful for the client, only internal debugging,
|
|
for idx := range challenge.ValidationRecord {
|
|
challenge.ValidationRecord[idx].ResolverAddrs = nil
|
|
}
|
|
}
|
|
|
|
// prepAuthorizationForDisplay takes a core.Authorization and prepares it for
|
|
// display to the client by preparing all its challenges.
|
|
func (wfe *WebFrontEndImpl) prepAuthorizationForDisplay(request *http.Request, authz *core.Authorization) {
|
|
for i := range authz.Challenges {
|
|
wfe.prepChallengeForDisplay(request, *authz, &authz.Challenges[i])
|
|
}
|
|
|
|
// Shuffle the challenges so no one relies on their order.
|
|
rand.Shuffle(len(authz.Challenges), func(i, j int) {
|
|
authz.Challenges[i], authz.Challenges[j] = authz.Challenges[j], authz.Challenges[i]
|
|
})
|
|
|
|
// The ACME spec forbids allowing "*" in authorization identifiers. Boulder
|
|
// allows this internally as a means of tracking when an authorization
|
|
// corresponds to a wildcard request (e.g. to handle CAA properly). We strip
|
|
// the "*." prefix from the Authz's Identifier's Value here to respect the law
|
|
// of the protocol.
|
|
if strings.HasPrefix(authz.Identifier.Value, "*.") {
|
|
authz.Identifier.Value = strings.TrimPrefix(authz.Identifier.Value, "*.")
|
|
// Mark that the authorization corresponds to a wildcard request since we've
|
|
// now removed the wildcard prefix from the identifier.
|
|
authz.Wildcard = true
|
|
}
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) getChallenge(
|
|
response http.ResponseWriter,
|
|
request *http.Request,
|
|
authz core.Authorization,
|
|
challenge *core.Challenge,
|
|
logEvent *web.RequestEvent) {
|
|
wfe.prepChallengeForDisplay(request, authz, challenge)
|
|
|
|
authzURL := urlForAuthz(authz, request)
|
|
response.Header().Add("Location", challenge.URL)
|
|
response.Header().Add("Link", link(authzURL, "up"))
|
|
|
|
err := wfe.writeJsonResponse(response, logEvent, http.StatusOK, challenge)
|
|
if err != nil {
|
|
// InternalServerError because this is a failure to decode data passed in
|
|
// by the caller, which got it from the DB.
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Failed to marshal challenge"), err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) postChallenge(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request,
|
|
authz core.Authorization,
|
|
challengeIndex int,
|
|
logEvent *web.RequestEvent) {
|
|
body, _, currAcct, err := wfe.validPOSTForAccount(request, ctx, logEvent)
|
|
addRequesterHeader(response, logEvent.Requester)
|
|
if err != nil {
|
|
// validPOSTForAccount handles its own setting of logEvent.Errors
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
|
|
// Check that the account ID matching the key used matches
|
|
// the account ID on the authz object
|
|
if currAcct.ID != authz.RegistrationID {
|
|
wfe.sendError(response,
|
|
logEvent,
|
|
probs.Unauthorized("User account ID doesn't match account ID in authorization"),
|
|
nil,
|
|
)
|
|
return
|
|
}
|
|
|
|
// If the JWS body is empty then this POST is a POST-as-GET to retrieve
|
|
// challenge details, not a POST to initiate a challenge
|
|
if string(body) == "" {
|
|
challenge := authz.Challenges[challengeIndex]
|
|
wfe.getChallenge(response, request, authz, &challenge, logEvent)
|
|
return
|
|
}
|
|
|
|
// We can expect some clients to try and update a challenge for an authorization
|
|
// that is already valid. In this case we don't need to process the challenge
|
|
// update. It wouldn't be helpful, the overall authorization is already good!
|
|
var returnAuthz core.Authorization
|
|
if authz.Status == core.StatusValid {
|
|
returnAuthz = authz
|
|
} else {
|
|
|
|
// NOTE(@cpu): Historically a challenge update needed to include
|
|
// a KeyAuthorization field. This is no longer the case, since both sides can
|
|
// calculate the key authorization as needed. We unmarshal here only to check
|
|
// that the POST body is valid JSON. Any data/fields included are ignored to
|
|
// be kind to ACMEv2 implementations that still send a key authorization.
|
|
var challengeUpdate struct{}
|
|
err := json.Unmarshal(body, &challengeUpdate)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Error unmarshaling challenge response"), err)
|
|
return
|
|
}
|
|
|
|
authzPB, err := bgrpc.AuthzToPB(authz)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to serialize authz"), err)
|
|
return
|
|
}
|
|
|
|
authzPB, err = wfe.ra.PerformValidation(ctx, &rapb.PerformValidationRequest{
|
|
Authz: authzPB,
|
|
ChallengeIndex: int64(challengeIndex),
|
|
})
|
|
if err != nil || core.IsAnyNilOrZero(authzPB, authzPB.Id, identifier.FromProtoWithDefault(authzPB), authzPB.Status, authzPB.Expires) {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to update challenge"), err)
|
|
return
|
|
}
|
|
|
|
updatedAuthz, err := bgrpc.PBToAuthz(authzPB)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to deserialize authz"), err)
|
|
return
|
|
}
|
|
returnAuthz = updatedAuthz
|
|
}
|
|
|
|
// assumption: PerformValidation does not modify order of challenges
|
|
challenge := returnAuthz.Challenges[challengeIndex]
|
|
wfe.prepChallengeForDisplay(request, authz, &challenge)
|
|
|
|
authzURL := urlForAuthz(authz, request)
|
|
response.Header().Add("Location", challenge.URL)
|
|
response.Header().Add("Link", link(authzURL, "up"))
|
|
|
|
err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, challenge)
|
|
if err != nil {
|
|
// ServerInternal because we made the challenges, they should be OK
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Failed to marshal challenge"), err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Account is used by a client to submit an update to their account.
|
|
func (wfe *WebFrontEndImpl) Account(
|
|
ctx context.Context,
|
|
logEvent *web.RequestEvent,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
body, _, currAcct, err := wfe.validPOSTForAccount(request, ctx, logEvent)
|
|
addRequesterHeader(response, logEvent.Requester)
|
|
if err != nil {
|
|
// validPOSTForAccount handles its own setting of logEvent.Errors
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
|
|
// Requests to this handler should have a path that leads to a known
|
|
// account
|
|
idStr := request.URL.Path
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.Malformed(fmt.Sprintf("Account ID must be an integer, was %q", idStr)), err)
|
|
return
|
|
} else if id <= 0 {
|
|
wfe.sendError(response, logEvent, probs.Malformed(fmt.Sprintf("Account ID must be a positive non-zero integer, was %d", id)), nil)
|
|
return
|
|
} else if id != currAcct.ID {
|
|
wfe.sendError(response, logEvent, probs.Unauthorized("Request signing key did not match account key"), nil)
|
|
return
|
|
}
|
|
|
|
var acct *core.Registration
|
|
if string(body) == "" || string(body) == "{}" {
|
|
// An empty string means POST-as-GET (i.e. no update). A body of "{}" means
|
|
// an update of zero fields, returning the unchanged object. This was the
|
|
// recommended way to fetch the account object in ACMEv1.
|
|
acct = currAcct
|
|
} else {
|
|
acct, err = wfe.updateAccount(ctx, body, currAcct)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to update account"), nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
if len(wfe.SubscriberAgreementURL) > 0 {
|
|
response.Header().Add("Link", link(wfe.SubscriberAgreementURL, "terms-of-service"))
|
|
}
|
|
|
|
prepAccountForDisplay(acct)
|
|
|
|
err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, acct)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Failed to marshal account"), err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// updateAccount unmarshals an account update request from the provided
|
|
// requestBody to update the given registration. Important: It is assumed the
|
|
// request has already been authenticated by the caller. If the request is a
|
|
// valid update the resulting updated account is returned, otherwise a problem
|
|
// is returned.
|
|
func (wfe *WebFrontEndImpl) updateAccount(ctx context.Context, requestBody []byte, currAcct *core.Registration) (*core.Registration, error) {
|
|
// Only the Contact and Status fields of an account may be updated this way.
|
|
// For key updates clients should be using the key change endpoint.
|
|
var accountUpdateRequest struct {
|
|
Contact *[]string `json:"contact"`
|
|
Status core.AcmeStatus `json:"status"`
|
|
}
|
|
|
|
err := json.Unmarshal(requestBody, &accountUpdateRequest)
|
|
if err != nil {
|
|
return nil, berrors.MalformedError("parsing account update request: %s", err)
|
|
}
|
|
|
|
// If a user tries to send both a deactivation request and an update to
|
|
// their contacts, the deactivation will take place and return before an
|
|
// update would be performed. Deactivation deletes the contacts field.
|
|
if accountUpdateRequest.Status == core.StatusDeactivated {
|
|
updatedAcct, err := wfe.ra.DeactivateRegistration(
|
|
ctx, &rapb.DeactivateRegistrationRequest{RegistrationID: currAcct.ID})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("deactivating account: %w", err)
|
|
}
|
|
|
|
if updatedAcct.Status == string(core.StatusDeactivated) {
|
|
// The request was handled by an updated RA/SA, which returned the updated
|
|
// account object.
|
|
updatedReg, err := bgrpc.PbToRegistration(updatedAcct)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing deactivated account: %w", err)
|
|
}
|
|
return &updatedReg, nil
|
|
} else {
|
|
// The request was handled by an old RA/SA, which returned nothing.
|
|
// Instead, modify the existing account object in place and return it.
|
|
// TODO(#5554): Remove this after all RAs and SAs are updated.
|
|
currAcct.Status = core.StatusDeactivated
|
|
currAcct.Contact = nil
|
|
return currAcct, nil
|
|
}
|
|
}
|
|
|
|
if accountUpdateRequest.Status != core.StatusValid && accountUpdateRequest.Status != "" {
|
|
return nil, berrors.MalformedError("invalid status %q for account update request, must be %q or %q", accountUpdateRequest.Status, core.StatusValid, core.StatusDeactivated)
|
|
}
|
|
|
|
if accountUpdateRequest.Contact == nil {
|
|
// We use a pointer-to-slice for the contacts field so that we can tell the
|
|
// difference between the request not including the contact field, and the
|
|
// request including an empty contact list. If the field was omitted
|
|
// entirely, they don't want us to update it, so there's no work to do here.
|
|
return currAcct, nil
|
|
}
|
|
|
|
updatedAcct, err := wfe.ra.UpdateRegistrationContact(ctx, &rapb.UpdateRegistrationContactRequest{
|
|
RegistrationID: currAcct.ID, Contacts: *accountUpdateRequest.Contact})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("updating account: %w", err)
|
|
}
|
|
|
|
// Convert proto to core.Registration for return
|
|
updatedReg, err := bgrpc.PbToRegistration(updatedAcct)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing updated account: %w", err)
|
|
}
|
|
|
|
return &updatedReg, nil
|
|
}
|
|
|
|
// deactivateAuthorization processes the given JWS POST body as a request to
|
|
// deactivate the provided authorization. If an error occurs it is written to
|
|
// the response writer. Important: `deactivateAuthorization` does not check that
|
|
// the requester is authorized to deactivate the given authorization. It is
|
|
// assumed that this check is performed prior to calling deactivateAuthorzation.
|
|
func (wfe *WebFrontEndImpl) deactivateAuthorization(
|
|
ctx context.Context,
|
|
authzPB *corepb.Authorization,
|
|
logEvent *web.RequestEvent,
|
|
response http.ResponseWriter,
|
|
body []byte) bool {
|
|
var req struct {
|
|
Status core.AcmeStatus
|
|
}
|
|
err := json.Unmarshal(body, &req)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Error unmarshaling JSON"), err)
|
|
return false
|
|
}
|
|
if req.Status != core.StatusDeactivated {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Invalid status value"), err)
|
|
return false
|
|
}
|
|
_, err = wfe.ra.DeactivateAuthorization(ctx, authzPB)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Error deactivating authorization"), err)
|
|
return false
|
|
}
|
|
// Since the authorization passed to DeactivateAuthorization isn't
|
|
// mutated locally by the function we must manually set the status
|
|
// here before displaying the authorization to the user
|
|
authzPB.Status = string(core.StatusDeactivated)
|
|
return true
|
|
}
|
|
|
|
// AuthorizationHandler handles requests to authorization URLs of the form /acme/authz/{regID}/{authzID}.
|
|
func (wfe *WebFrontEndImpl) AuthorizationHandler(
|
|
ctx context.Context,
|
|
logEvent *web.RequestEvent,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
slug := strings.Split(request.URL.Path, "/")
|
|
if len(slug) != 2 {
|
|
wfe.sendError(response, logEvent, probs.NotFound("No such authorization"), nil)
|
|
return
|
|
}
|
|
// TODO(#7683): The regID is currently ignored.
|
|
wfe.Authorization(ctx, logEvent, response, request, slug[1])
|
|
}
|
|
|
|
// Authorization handles both `/acme/authz/{authzID}` and `/acme/authz/{regID}/{authzID}` requests,
|
|
// after the calling function has parsed out the authzID.
|
|
func (wfe *WebFrontEndImpl) Authorization(
|
|
ctx context.Context,
|
|
logEvent *web.RequestEvent,
|
|
response http.ResponseWriter,
|
|
request *http.Request,
|
|
authzIDStr string) {
|
|
var requestAccount *core.Registration
|
|
var requestBody []byte
|
|
// If the request is a POST it is either:
|
|
// A) an update to an authorization to deactivate it
|
|
// 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)
|
|
addRequesterHeader(response, logEvent.Requester)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
requestAccount = acct
|
|
requestBody = body
|
|
}
|
|
|
|
authzID, err := strconv.ParseInt(authzIDStr, 10, 64)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Invalid authorization ID"), nil)
|
|
return
|
|
}
|
|
|
|
authzPB, err := wfe.ra.GetAuthorization(ctx, &rapb.GetAuthorizationRequest{Id: authzID})
|
|
if errors.Is(err, berrors.NotFound) {
|
|
wfe.sendError(response, logEvent, probs.NotFound("No such authorization"), nil)
|
|
return
|
|
} else if errors.Is(err, berrors.Malformed) {
|
|
wfe.sendError(response, logEvent, probs.Malformed(err.Error()), nil)
|
|
return
|
|
} else if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Problem getting authorization"), err)
|
|
return
|
|
}
|
|
|
|
ident := identifier.FromProtoWithDefault(authzPB)
|
|
|
|
// Ensure gRPC response is complete.
|
|
if core.IsAnyNilOrZero(authzPB.Id, ident, authzPB.Status, authzPB.Expires) {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Problem getting authorization"), errIncompleteGRPCResponse)
|
|
return
|
|
}
|
|
|
|
logEvent.Identifiers = identifier.ACMEIdentifiers{ident}
|
|
logEvent.Status = authzPB.Status
|
|
|
|
// After expiring, authorizations are inaccessible
|
|
if authzPB.Expires.AsTime().Before(wfe.clk.Now()) {
|
|
wfe.sendError(response, logEvent, probs.NotFound("Expired authorization"), nil)
|
|
return
|
|
}
|
|
|
|
// If this was a POST that has an associated requestAccount and that account
|
|
// doesn't own the authorization, abort before trying to deactivate the authz
|
|
// or return its details
|
|
if requestAccount != nil && requestAccount.ID != authzPB.RegistrationID {
|
|
wfe.sendError(response, logEvent,
|
|
probs.Unauthorized("Account ID doesn't match ID for authorization"), nil)
|
|
return
|
|
}
|
|
|
|
// If the body isn't empty we know it isn't a POST-as-GET and must be an
|
|
// attempt to deactivate an authorization.
|
|
if string(requestBody) != "" {
|
|
// If the deactivation fails return early as errors and return codes
|
|
// have already been set. Otherwise continue so that the user gets
|
|
// sent the deactivated authorization.
|
|
if !wfe.deactivateAuthorization(ctx, authzPB, logEvent, response, requestBody) {
|
|
return
|
|
}
|
|
}
|
|
|
|
authz, err := bgrpc.PBToAuthz(authzPB)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Problem getting authorization"), err)
|
|
return
|
|
}
|
|
|
|
wfe.prepAuthorizationForDisplay(request, &authz)
|
|
|
|
err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, authz)
|
|
if err != nil {
|
|
// InternalServerError because this is a failure to decode from our DB.
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Failed to JSON marshal authz"), err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Certificate is used by clients to request a copy of their current certificate, or to
|
|
// request a reissuance of the certificate.
|
|
func (wfe *WebFrontEndImpl) Certificate(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) {
|
|
var requesterAccount *core.Registration
|
|
// 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)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
requesterAccount = acct
|
|
}
|
|
|
|
requestedChain := 0
|
|
serial := request.URL.Path
|
|
|
|
// An alternate chain may be requested with the request path {serial}/{chain}, where chain
|
|
// is a number - an index into the slice of chains for the issuer. If a specific chain is
|
|
// not requested, then it defaults to zero - the default certificate chain for the issuer.
|
|
serialAndChain := strings.SplitN(serial, "/", 2)
|
|
if len(serialAndChain) == 2 {
|
|
idx, err := strconv.Atoi(serialAndChain[1])
|
|
if err != nil || idx < 0 {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Chain ID must be a non-negative integer"),
|
|
fmt.Errorf("certificate chain id provided was not valid: %s", serialAndChain[1]))
|
|
return
|
|
}
|
|
serial = serialAndChain[0]
|
|
requestedChain = idx
|
|
}
|
|
|
|
// Certificate paths consist of the CertBase path, plus exactly sixteen hex
|
|
// digits.
|
|
if !core.ValidSerial(serial) {
|
|
wfe.sendError(
|
|
response,
|
|
logEvent,
|
|
probs.NotFound("Certificate not found"),
|
|
fmt.Errorf("certificate serial provided was not valid: %s", serial),
|
|
)
|
|
return
|
|
}
|
|
logEvent.Extra["RequestedSerial"] = serial
|
|
|
|
cert, err := wfe.sa.GetCertificate(ctx, &sapb.Serial{Serial: serial})
|
|
if err != nil {
|
|
if errors.Is(err, berrors.NotFound) {
|
|
wfe.sendError(response, logEvent, probs.NotFound("Certificate not found"), nil)
|
|
} else {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Failed to retrieve certificate"), err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Don't serve certificates from the /get/ path until they're a little stale,
|
|
// to prevent ACME clients from using that path.
|
|
if strings.HasPrefix(logEvent.Endpoint, getCertPath) && wfe.clk.Since(cert.Issued.AsTime()) < wfe.staleTimeout {
|
|
wfe.sendError(response, logEvent, probs.Unauthorized(fmt.Sprintf(
|
|
"Certificate is too new for GET API. You should only use this non-standard API to access resources created more than %s ago",
|
|
wfe.staleTimeout)), nil)
|
|
return
|
|
}
|
|
|
|
// If there was a requesterAccount (e.g. because it was a POST-as-GET request)
|
|
// then the requesting account must be the owner of the certificate, otherwise
|
|
// return an unauthorized error.
|
|
if requesterAccount != nil && requesterAccount.ID != cert.RegistrationID {
|
|
wfe.sendError(response, logEvent, probs.Unauthorized("Account in use did not issue specified certificate"), nil)
|
|
return
|
|
}
|
|
|
|
responsePEM, prob := func() ([]byte, *probs.ProblemDetails) {
|
|
leafPEM := pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: cert.Der,
|
|
})
|
|
|
|
parsedCert, err := x509.ParseCertificate(cert.Der)
|
|
if err != nil {
|
|
// If we can't parse one of our own certs there's a serious problem
|
|
return nil, probs.ServerInternal(
|
|
fmt.Sprintf(
|
|
"unable to parse Boulder issued certificate with serial %#v: %s",
|
|
serial,
|
|
err),
|
|
)
|
|
}
|
|
|
|
issuerNameID := issuance.IssuerNameID(parsedCert)
|
|
availableChains, ok := wfe.certificateChains[issuerNameID]
|
|
if !ok || len(availableChains) == 0 {
|
|
// If there is no wfe.certificateChains entry for the IssuerNameID then
|
|
// we can't provide a chain for this cert. If the certificate is expired,
|
|
// just return the bare cert. If the cert is still valid, then there is
|
|
// a misconfiguration and we should treat it as an internal server error.
|
|
if parsedCert.NotAfter.Before(wfe.clk.Now()) {
|
|
return leafPEM, nil
|
|
}
|
|
return nil, probs.ServerInternal(
|
|
fmt.Sprintf(
|
|
"Certificate serial %#v has an unknown IssuerNameID %d - no PEM certificate chain associated.",
|
|
serial,
|
|
issuerNameID),
|
|
)
|
|
}
|
|
|
|
// If the requested chain is outside the bounds of the available chains,
|
|
// then it is an error by the client - not found.
|
|
if requestedChain < 0 || requestedChain >= len(availableChains) {
|
|
return nil, probs.NotFound("Unknown issuance chain")
|
|
}
|
|
|
|
// Double check that the signature validates.
|
|
err = parsedCert.CheckSignatureFrom(wfe.issuerCertificates[issuerNameID].Certificate)
|
|
if err != nil {
|
|
return nil, probs.ServerInternal(
|
|
fmt.Sprintf(
|
|
"Certificate serial %#v has a signature which cannot be verified from issuer %d.",
|
|
serial,
|
|
issuerNameID),
|
|
)
|
|
}
|
|
|
|
// Add rel="alternate" links for every chain available for this issuer,
|
|
// excluding the currently requested chain.
|
|
for chainID := range availableChains {
|
|
if chainID == requestedChain {
|
|
continue
|
|
}
|
|
chainURL := web.RelativeEndpoint(request,
|
|
fmt.Sprintf("%s%s/%d", certPath, serial, chainID))
|
|
response.Header().Add("Link", link(chainURL, "alternate"))
|
|
}
|
|
|
|
// Prepend the chain with the leaf certificate
|
|
return append(leafPEM, availableChains[requestedChain]...), nil
|
|
}()
|
|
if prob != nil {
|
|
wfe.sendError(response, logEvent, prob, nil)
|
|
return
|
|
}
|
|
|
|
// NOTE(@cpu): 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 with the addition of a PEM encoded certificate chain the body size of
|
|
// this endpoint will 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(responsePEM)))
|
|
response.Header().Set("Content-Type", "application/pem-certificate-chain")
|
|
response.WriteHeader(http.StatusOK)
|
|
if _, err = response.Write(responsePEM); 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")
|
|
response.WriteHeader(http.StatusOK)
|
|
detailsString := fmt.Sprintf("Boulder=(%s %s)", core.GetBuildID(), core.GetBuildTime())
|
|
if _, err := fmt.Fprintln(response, detailsString); err != nil {
|
|
wfe.log.Warningf("Could not write response: %s", err)
|
|
}
|
|
}
|
|
|
|
// Options responds to an HTTP OPTIONS request.
|
|
func (wfe *WebFrontEndImpl) Options(response http.ResponseWriter, request *http.Request, methodsStr string, methodsMap map[string]bool) {
|
|
// Every OPTIONS request gets an Allow header with a list of supported methods.
|
|
response.Header().Set("Allow", methodsStr)
|
|
|
|
// CORS preflight requests get additional headers. See
|
|
// http://www.w3.org/TR/cors/#resource-preflight-requests
|
|
reqMethod := request.Header.Get("Access-Control-Request-Method")
|
|
if reqMethod == "" {
|
|
reqMethod = "GET"
|
|
}
|
|
if methodsMap[reqMethod] {
|
|
wfe.setCORSHeaders(response, request, methodsStr)
|
|
}
|
|
}
|
|
|
|
// setCORSHeaders() tells the client that CORS is acceptable for this
|
|
// request. If allowMethods == "" the request is assumed to be a CORS
|
|
// actual request and no Access-Control-Allow-Methods header will be
|
|
// sent.
|
|
func (wfe *WebFrontEndImpl) setCORSHeaders(response http.ResponseWriter, request *http.Request, allowMethods string) {
|
|
reqOrigin := request.Header.Get("Origin")
|
|
if reqOrigin == "" {
|
|
// This is not a CORS request.
|
|
return
|
|
}
|
|
|
|
// Allow CORS if the current origin (or "*") is listed as an
|
|
// allowed origin in config. Otherwise, disallow by returning
|
|
// without setting any CORS headers.
|
|
allow := false
|
|
for _, ao := range wfe.AllowOrigins {
|
|
if ao == "*" {
|
|
response.Header().Set("Access-Control-Allow-Origin", "*")
|
|
allow = true
|
|
break
|
|
} else if ao == reqOrigin {
|
|
response.Header().Set("Vary", "Origin")
|
|
response.Header().Set("Access-Control-Allow-Origin", ao)
|
|
allow = true
|
|
break
|
|
}
|
|
}
|
|
if !allow {
|
|
return
|
|
}
|
|
|
|
if allowMethods != "" {
|
|
// For an OPTIONS request: allow all methods handled at this URL.
|
|
response.Header().Set("Access-Control-Allow-Methods", allowMethods)
|
|
}
|
|
// NOTE(@cpu): "Content-Type" is considered a 'simple header' that doesn't
|
|
// need to be explicitly allowed in 'access-control-allow-headers', but only
|
|
// when the value is one of: `application/x-www-form-urlencoded`,
|
|
// `multipart/form-data`, or `text/plain`. Since `application/jose+json` is
|
|
// not one of these values we must be explicit in saying that `Content-Type`
|
|
// is an allowed header. See MDN for more details:
|
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
|
|
response.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
response.Header().Set("Access-Control-Expose-Headers", "Link, Replay-Nonce, Location")
|
|
response.Header().Set("Access-Control-Max-Age", "86400")
|
|
}
|
|
|
|
// KeyRollover allows a user to change their signing key
|
|
func (wfe *WebFrontEndImpl) KeyRollover(
|
|
ctx context.Context,
|
|
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)
|
|
addRequesterHeader(response, logEvent.Requester)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
oldKey := acct.Key
|
|
|
|
// Parse the inner JWS from the validated outer JWS body
|
|
innerJWS, err := wfe.parseJWS(outerBody)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
|
|
// Validate the inner JWS as a key rollover request for the outer JWS
|
|
rolloverOperation, err := wfe.validKeyRollover(ctx, outerJWS, innerJWS, oldKey)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
newKey := rolloverOperation.NewKey
|
|
|
|
// Check that the rollover request's account URL matches the account URL used
|
|
// to validate the outer JWS
|
|
header := outerJWS.Signatures[0].Header
|
|
if rolloverOperation.Account != header.KeyID {
|
|
wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "KeyRolloverMismatchedAccount"}).Inc()
|
|
wfe.sendError(response, logEvent, probs.Malformed(
|
|
fmt.Sprintf("Inner key rollover request specified Account %q, but outer JWS has Key ID %q",
|
|
rolloverOperation.Account, header.KeyID)), nil)
|
|
return
|
|
}
|
|
|
|
// Check that the new key isn't the same as the old key. This would fail as
|
|
// part of the subsequent `wfe.SA.GetRegistrationByKey` check since the new key
|
|
// will find the old account if its equal to the old account key. We
|
|
// check new key against old key explicitly to save an RPC round trip and a DB
|
|
// query for this easy rejection case
|
|
keysEqual, err := core.PublicKeysEqual(newKey.Key, oldKey.Key)
|
|
if err != nil {
|
|
// This should not happen - both the old and new key have been validated by now
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Unable to compare new and old keys"), err)
|
|
return
|
|
}
|
|
if keysEqual {
|
|
wfe.stats.joseErrorCount.With(prometheus.Labels{"type": "KeyRolloverUnchangedKey"}).Inc()
|
|
wfe.sendError(response, logEvent, probs.Malformed(
|
|
"New key specified by rollover request is the same as the old key"), nil)
|
|
return
|
|
}
|
|
|
|
// Marshal key to bytes
|
|
newKeyBytes, err := newKey.MarshalJSON()
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling new key"), err)
|
|
}
|
|
// Check that the new key isn't already being used for an existing account
|
|
existingAcct, err := wfe.sa.GetRegistrationByKey(ctx, &sapb.JSONWebKey{Jwk: newKeyBytes})
|
|
if err == nil {
|
|
response.Header().Set("Location",
|
|
web.RelativeEndpoint(request, fmt.Sprintf("%s%d", acctPath, existingAcct.Id)))
|
|
wfe.sendError(response, logEvent,
|
|
probs.Conflict("New key is already in use for a different account"), err)
|
|
return
|
|
} else if !errors.Is(err, berrors.NotFound) {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Failed to lookup existing keys"), err)
|
|
return
|
|
}
|
|
|
|
// Update the account key to the new key
|
|
updatedAcctPb, err := wfe.ra.UpdateRegistrationKey(ctx, &rapb.UpdateRegistrationKeyRequest{RegistrationID: acct.ID, Jwk: newKeyBytes})
|
|
if err != nil {
|
|
if errors.Is(err, berrors.Duplicate) {
|
|
// It is possible that between checking for the existing key, and performing the update
|
|
// a parallel update or new account request happened and claimed the key. In this case
|
|
// just retrieve the account again, and return an error as we would above with a Location
|
|
// header
|
|
existingAcct, err := wfe.sa.GetRegistrationByKey(ctx, &sapb.JSONWebKey{Jwk: newKeyBytes})
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "looking up account by key"), err)
|
|
return
|
|
}
|
|
response.Header().Set("Location",
|
|
web.RelativeEndpoint(request, fmt.Sprintf("%s%d", acctPath, existingAcct.Id)))
|
|
wfe.sendError(response, logEvent,
|
|
probs.Conflict("New key is already in use for a different account"), err)
|
|
return
|
|
}
|
|
wfe.sendError(response, logEvent,
|
|
web.ProblemDetailsForError(err, "Unable to update account with new key"), err)
|
|
return
|
|
}
|
|
// Convert proto to registration for display
|
|
updatedAcct, err := bgrpc.PbToRegistration(updatedAcctPb)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling proto to registration"), err)
|
|
return
|
|
}
|
|
prepAccountForDisplay(&updatedAcct)
|
|
|
|
err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, updatedAcct)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Failed to marshal updated account"), err)
|
|
}
|
|
}
|
|
|
|
type orderJSON struct {
|
|
Status core.AcmeStatus `json:"status"`
|
|
Expires time.Time `json:"expires"`
|
|
Identifiers identifier.ACMEIdentifiers `json:"identifiers"`
|
|
Authorizations []string `json:"authorizations"`
|
|
Finalize string `json:"finalize"`
|
|
Profile string `json:"profile,omitempty"`
|
|
Certificate string `json:"certificate,omitempty"`
|
|
Error *probs.ProblemDetails `json:"error,omitempty"`
|
|
Replaces string `json:"replaces,omitempty"`
|
|
}
|
|
|
|
// orderToOrderJSON converts a *corepb.Order instance into an orderJSON struct
|
|
// that is returned in HTTP API responses. It will convert the order names to
|
|
// DNS type identifiers and additionally create absolute URLs for the finalize
|
|
// URL and the certificate URL as appropriate.
|
|
func (wfe *WebFrontEndImpl) orderToOrderJSON(request *http.Request, order *corepb.Order) orderJSON {
|
|
finalizeURL := web.RelativeEndpoint(request,
|
|
fmt.Sprintf("%s%d/%d", finalizeOrderPath, order.RegistrationID, order.Id))
|
|
respObj := orderJSON{
|
|
Status: core.AcmeStatus(order.Status),
|
|
Expires: order.Expires.AsTime(),
|
|
Identifiers: identifier.FromProtoSliceWithDefault(order),
|
|
Finalize: finalizeURL,
|
|
Profile: order.CertificateProfileName,
|
|
Replaces: order.Replaces,
|
|
}
|
|
// If there is an order error, prefix its type with the V2 namespace
|
|
if order.Error != nil {
|
|
prob, err := bgrpc.PBToProblemDetails(order.Error)
|
|
if err != nil {
|
|
wfe.log.AuditErrf("Internal error converting order ID %d "+
|
|
"proto buf prob to problem details: %q", order.Id, err)
|
|
}
|
|
respObj.Error = prob
|
|
respObj.Error.Type = probs.ErrorNS + respObj.Error.Type
|
|
}
|
|
for _, v2ID := range order.V2Authorizations {
|
|
respObj.Authorizations = append(respObj.Authorizations, web.RelativeEndpoint(request, fmt.Sprintf("%s%d/%d", authzPath, order.RegistrationID, v2ID)))
|
|
}
|
|
if respObj.Status == core.StatusValid {
|
|
certURL := web.RelativeEndpoint(request,
|
|
fmt.Sprintf("%s%s", certPath, order.CertificateSerial))
|
|
respObj.Certificate = certURL
|
|
}
|
|
return respObj
|
|
}
|
|
|
|
// checkNewOrderLimits checks whether sufficient limit quota exists for the
|
|
// creation of a new order. If so, that quota is spent. If an error is
|
|
// encountered during the check, it is logged but not returned. A refund
|
|
// function is returned that can be used to refund the quota if the order is not
|
|
// created, the func will be nil if any error was encountered during the check.
|
|
//
|
|
// TODO(#7311): Handle IP address identifiers.
|
|
func (wfe *WebFrontEndImpl) checkNewOrderLimits(ctx context.Context, regId int64, idents identifier.ACMEIdentifiers, isRenewal bool) (func(), error) {
|
|
names, err := idents.ToDNSSlice()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
txns, err := wfe.txnBuilder.NewOrderLimitTransactions(regId, names, isRenewal)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("building new order limit transactions: %w", err)
|
|
}
|
|
|
|
d, err := wfe.limiter.BatchSpend(ctx, txns)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("spending new order limits: %w", err)
|
|
}
|
|
|
|
err = d.Result(wfe.clk.Now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return func() {
|
|
_, err := wfe.limiter.BatchRefund(ctx, txns)
|
|
if err != nil {
|
|
wfe.log.Warningf("refunding new order limits: %s", err)
|
|
}
|
|
}, nil
|
|
}
|
|
|
|
// orderMatchesReplacement checks if the order matches the provided certificate
|
|
// as identified by the provided ARI CertID. This function ensures that:
|
|
// - the certificate being replaced exists,
|
|
// - the requesting account owns that certificate, and
|
|
// - a name in this new order matches a name in the certificate being
|
|
// replaced.
|
|
func (wfe *WebFrontEndImpl) orderMatchesReplacement(ctx context.Context, acct *core.Registration, idents identifier.ACMEIdentifiers, serial string) error {
|
|
// It's okay to use GetCertificate (vs trying to get a precertificate),
|
|
// because we don't intend to serve ARI for certs that never made it past
|
|
// the precert stage.
|
|
oldCert, err := wfe.sa.GetCertificate(ctx, &sapb.Serial{Serial: serial})
|
|
if err != nil {
|
|
if errors.Is(err, berrors.NotFound) {
|
|
return berrors.NotFoundError("request included `replaces` field, but no current certificate with serial %q exists", serial)
|
|
}
|
|
return errors.New("failed to retrieve existing certificate")
|
|
}
|
|
|
|
if oldCert.RegistrationID != acct.ID {
|
|
return berrors.UnauthorizedError("requester account did not request the certificate being replaced by this order")
|
|
}
|
|
parsedCert, err := x509.ParseCertificate(oldCert.Der)
|
|
if err != nil {
|
|
return fmt.Errorf("error parsing certificate replaced by this order: %w", err)
|
|
}
|
|
|
|
var nameMatch bool
|
|
for _, ident := range idents {
|
|
// TODO(#7311): Handle IP address identifiers.
|
|
if parsedCert.VerifyHostname(ident.Value) == nil {
|
|
// At least one name in the new order matches a name in the
|
|
// predecessor certificate.
|
|
nameMatch = true
|
|
break
|
|
}
|
|
}
|
|
if !nameMatch {
|
|
return berrors.MalformedError("identifiers in this order do not match any names in the certificate being replaced")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) determineARIWindow(ctx context.Context, serial string) (core.RenewalInfo, error) {
|
|
// Check if the serial is impacted by an incident.
|
|
result, err := wfe.sa.IncidentsForSerial(ctx, &sapb.Serial{Serial: serial})
|
|
if err != nil {
|
|
return core.RenewalInfo{}, fmt.Errorf("checking if existing certificate is impacted by an incident: %w", err)
|
|
}
|
|
|
|
if len(result.Incidents) > 0 {
|
|
// Find the earliest incident.
|
|
var earliest *sapb.Incident
|
|
for _, incident := range result.Incidents {
|
|
if earliest == nil || incident.RenewBy.AsTime().Before(earliest.RenewBy.AsTime()) {
|
|
earliest = incident
|
|
}
|
|
}
|
|
// The existing cert is impacted by an incident, renew immediately.
|
|
return core.RenewalInfoImmediate(wfe.clk.Now(), earliest.Url), nil
|
|
}
|
|
|
|
// Check if the serial is revoked.
|
|
status, err := wfe.sa.GetCertificateStatus(ctx, &sapb.Serial{Serial: serial})
|
|
if err != nil {
|
|
return core.RenewalInfo{}, fmt.Errorf("checking if existing certificate has been revoked: %w", err)
|
|
}
|
|
|
|
if status.Status == string(core.OCSPStatusRevoked) {
|
|
// The existing certificate is revoked, renew immediately.
|
|
return core.RenewalInfoImmediate(wfe.clk.Now(), ""), nil
|
|
}
|
|
|
|
// It's okay to use GetCertificate (vs trying to get a precertificate),
|
|
// because we don't intend to serve ARI for certs that never made it past
|
|
// the precert stage.
|
|
cert, err := wfe.sa.GetCertificate(ctx, &sapb.Serial{Serial: serial})
|
|
if err != nil {
|
|
if errors.Is(err, berrors.NotFound) {
|
|
return core.RenewalInfo{}, err
|
|
}
|
|
return core.RenewalInfo{}, fmt.Errorf("failed to retrieve existing certificate: %w", err)
|
|
}
|
|
|
|
return core.RenewalInfoSimple(cert.Issued.AsTime(), cert.Expires.AsTime()), nil
|
|
}
|
|
|
|
// validateReplacementOrder implements draft-ietf-acme-ari-03. For a new order
|
|
// to be considered a replacement for an existing certificate, the existing
|
|
// certificate:
|
|
// 1. MUST NOT have been replaced by another finalized order,
|
|
// 2. MUST be associated with the same ACME account as this request, and
|
|
// 3. MUST have at least one identifier in common with this request.
|
|
//
|
|
// There are three values returned by this function:
|
|
// - The first return value is the serial number of the certificate being
|
|
// replaced. If the order is not a replacement, this value is an empty
|
|
// string.
|
|
// - The second return value is a boolean indicating whether the order is
|
|
// exempt from rate limits. If the order is a replacement and the request
|
|
// is made within the suggested renewal window, this value is true.
|
|
// Otherwise, this value is false.
|
|
// - The last value is an error, this is non-nil unless the order is not a
|
|
// replacement or there was an error while validating the replacement.
|
|
func (wfe *WebFrontEndImpl) validateReplacementOrder(ctx context.Context, acct *core.Registration, idents identifier.ACMEIdentifiers, replaces string) (string, bool, error) {
|
|
if replaces == "" {
|
|
// No replacement indicated.
|
|
return "", false, nil
|
|
}
|
|
|
|
decodedSerial, err := parseARICertID(replaces, wfe.issuerCertificates)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("while parsing ARI CertID an error occurred: %w", err)
|
|
}
|
|
|
|
exists, err := wfe.sa.ReplacementOrderExists(ctx, &sapb.Serial{Serial: decodedSerial})
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("checking replacement status of existing certificate: %w", err)
|
|
}
|
|
if exists.Exists {
|
|
return "", false, berrors.AlreadyReplacedError(
|
|
"cannot indicate an order replaces certificate with serial %q, which already has a replacement order",
|
|
decodedSerial,
|
|
)
|
|
}
|
|
|
|
err = wfe.orderMatchesReplacement(ctx, acct, idents, decodedSerial)
|
|
if err != nil {
|
|
// The provided replacement field value failed to meet the required
|
|
// criteria. We're going to return the error to the caller instead
|
|
// of trying to create a regular (non-replacement) order.
|
|
return "", false, fmt.Errorf("while checking that this order is a replacement: %w", err)
|
|
}
|
|
// This order is a replacement for an existing certificate.
|
|
replaces = decodedSerial
|
|
|
|
// For an order to be exempt from rate limits, it must be a replacement
|
|
// and the request must be made within the suggested renewal window.
|
|
renewalInfo, err := wfe.determineARIWindow(ctx, replaces)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("while determining the current ARI renewal window: %w", err)
|
|
}
|
|
|
|
return replaces, renewalInfo.SuggestedWindow.IsWithin(wfe.clk.Now()), nil
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) validateCertificateProfileName(profile string) error {
|
|
if profile == "" {
|
|
// No profile name is specified.
|
|
return nil
|
|
}
|
|
if _, ok := wfe.certProfiles[profile]; !ok {
|
|
// The profile name is not in the list of configured profiles.
|
|
return fmt.Errorf("profile name %q not recognized", profile)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) checkIdentifiersPaused(ctx context.Context, orderIdents identifier.ACMEIdentifiers, regID int64) ([]string, error) {
|
|
uniqueOrderIdents := identifier.Normalize(orderIdents)
|
|
var idents []*corepb.Identifier
|
|
for _, ident := range uniqueOrderIdents {
|
|
idents = append(idents, &corepb.Identifier{
|
|
Type: string(ident.Type),
|
|
Value: ident.Value,
|
|
})
|
|
}
|
|
|
|
paused, err := wfe.sa.CheckIdentifiersPaused(ctx, &sapb.PauseRequest{
|
|
RegistrationID: regID,
|
|
Identifiers: idents,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(paused.Identifiers) <= 0 {
|
|
// No identifiers are paused.
|
|
return nil, nil
|
|
}
|
|
|
|
// At least one of the requested identifiers is paused.
|
|
pausedValues := make([]string, 0, len(paused.Identifiers))
|
|
for _, ident := range paused.Identifiers {
|
|
pausedValues = append(pausedValues, ident.Value)
|
|
}
|
|
|
|
return pausedValues, nil
|
|
}
|
|
|
|
// NewOrder is used by clients to create a new order object and a set of
|
|
// authorizations to fulfill for issuance.
|
|
func (wfe *WebFrontEndImpl) NewOrder(
|
|
ctx context.Context,
|
|
logEvent *web.RequestEvent,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
body, _, acct, err := wfe.validPOSTForAccount(request, ctx, logEvent)
|
|
addRequesterHeader(response, logEvent.Requester)
|
|
if err != nil {
|
|
// validPOSTForAccount handles its own setting of logEvent.Errors
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
|
|
// newOrderRequest is the JSON structure of the request body. We only
|
|
// support the identifiers and replaces fields. If notBefore or notAfter are
|
|
// sent we return a probs.Malformed as we do not support them.
|
|
var newOrderRequest struct {
|
|
Identifiers identifier.ACMEIdentifiers `json:"identifiers"`
|
|
NotBefore string
|
|
NotAfter string
|
|
Replaces string
|
|
Profile string
|
|
}
|
|
err = json.Unmarshal(body, &newOrderRequest)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent,
|
|
probs.Malformed("Unable to unmarshal NewOrder request body"), err)
|
|
return
|
|
}
|
|
|
|
if len(newOrderRequest.Identifiers) == 0 {
|
|
wfe.sendError(response, logEvent,
|
|
probs.Malformed("NewOrder request did not specify any identifiers"), nil)
|
|
return
|
|
}
|
|
if newOrderRequest.NotBefore != "" || newOrderRequest.NotAfter != "" {
|
|
wfe.sendError(response, logEvent, probs.Malformed("NotBefore and NotAfter are not supported"), nil)
|
|
return
|
|
}
|
|
|
|
// TODO(#7311): Handle non-DNS identifiers.
|
|
idents := newOrderRequest.Identifiers
|
|
for _, ident := range idents {
|
|
if ident.Type != identifier.TypeDNS {
|
|
wfe.sendError(response, logEvent,
|
|
probs.UnsupportedIdentifier("NewOrder request included invalid non-DNS type identifier: type %q, value %q",
|
|
ident.Type, ident.Value),
|
|
nil)
|
|
return
|
|
}
|
|
if ident.Value == "" {
|
|
wfe.sendError(response, logEvent, probs.Malformed("NewOrder request included empty identifier"), nil)
|
|
return
|
|
}
|
|
}
|
|
idents = identifier.Normalize(idents)
|
|
logEvent.Identifiers = idents
|
|
|
|
err = policy.WellFormedIdentifiers(idents)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Invalid identifiers requested"), nil)
|
|
return
|
|
}
|
|
|
|
if features.Get().CheckIdentifiersPaused {
|
|
pausedValues, err := wfe.checkIdentifiersPaused(ctx, idents, acct.ID)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Failure while checking pause status of identifiers"), err)
|
|
return
|
|
}
|
|
if len(pausedValues) > 0 {
|
|
jwt, err := unpause.GenerateJWT(wfe.unpauseSigner, acct.ID, pausedValues, wfe.unpauseJWTLifetime, wfe.clk)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Error generating JWT for unpause portal"), err)
|
|
}
|
|
msg := fmt.Sprintf(
|
|
"Your account is temporarily prevented from requesting certificates for %s and possibly others. Please visit: %s",
|
|
strings.Join(pausedValues, ", "),
|
|
fmt.Sprintf("%s%s?jwt=%s", wfe.unpauseURL, unpause.GetForm, jwt),
|
|
)
|
|
wfe.sendError(response, logEvent, probs.Paused(msg), nil)
|
|
return
|
|
}
|
|
}
|
|
|
|
var replacesSerial string
|
|
var isARIRenewal bool
|
|
replacesSerial, isARIRenewal, err = wfe.validateReplacementOrder(ctx, acct, idents, newOrderRequest.Replaces)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "While validating order as a replacement an error occurred"), err)
|
|
return
|
|
}
|
|
|
|
names, err := idents.ToDNSSlice()
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.UnsupportedIdentifier("NewOrder request included invalid non-DNS type identifier"), nil)
|
|
}
|
|
|
|
var isRenewal bool
|
|
if !isARIRenewal {
|
|
// The Subscriber does not have an ARI exemption. However, we can check
|
|
// if the order is a renewal, and thus exempt from the NewOrdersPerAccount
|
|
// and CertificatesPerDomain limits.
|
|
timestamps, err := wfe.sa.FQDNSetTimestampsForWindow(ctx, &sapb.CountFQDNSetsRequest{
|
|
DnsNames: names,
|
|
Identifiers: idents.ToProtoSlice(),
|
|
Window: durationpb.New(120 * 24 * time.Hour),
|
|
Limit: 1,
|
|
})
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "While checking renewal exemption status"), err)
|
|
return
|
|
}
|
|
isRenewal = len(timestamps.Timestamps) > 0
|
|
}
|
|
|
|
err = wfe.validateCertificateProfileName(newOrderRequest.Profile)
|
|
if err != nil {
|
|
// TODO(#7392) Provide link to profile documentation.
|
|
wfe.sendError(response, logEvent, probs.InvalidProfile(err.Error()), err)
|
|
return
|
|
}
|
|
|
|
var refundLimits func()
|
|
if !isARIRenewal {
|
|
refundLimits, err = wfe.checkNewOrderLimits(ctx, acct.ID, idents, isRenewal)
|
|
if err != nil {
|
|
if errors.Is(err, berrors.RateLimit) {
|
|
wfe.sendError(response, logEvent, probs.RateLimited(err.Error()), err)
|
|
return
|
|
} else {
|
|
// Proceed, since we don't want internal rate limit system failures to
|
|
// block all issuance.
|
|
logEvent.IgnoredRateLimitError = err.Error()
|
|
}
|
|
}
|
|
}
|
|
|
|
var newOrderSuccessful bool
|
|
defer func() {
|
|
wfe.stats.ariReplacementOrders.With(prometheus.Labels{
|
|
"isReplacement": fmt.Sprintf("%t", replacesSerial != ""),
|
|
"limitsExempt": fmt.Sprintf("%t", isARIRenewal),
|
|
}).Inc()
|
|
|
|
if !newOrderSuccessful && refundLimits != nil {
|
|
go refundLimits()
|
|
}
|
|
}()
|
|
|
|
order, err := wfe.ra.NewOrder(ctx, &rapb.NewOrderRequest{
|
|
RegistrationID: acct.ID,
|
|
DnsNames: names,
|
|
Identifiers: idents.ToProtoSlice(),
|
|
CertificateProfileName: newOrderRequest.Profile,
|
|
Replaces: newOrderRequest.Replaces,
|
|
ReplacesSerial: replacesSerial,
|
|
})
|
|
|
|
if err != nil || core.IsAnyNilOrZero(order, order.Id, order.RegistrationID, identifier.FromProtoSliceWithDefault(order), order.Created, order.Expires) {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Error creating new order"), err)
|
|
return
|
|
}
|
|
logEvent.Created = fmt.Sprintf("%d", order.Id)
|
|
|
|
orderURL := web.RelativeEndpoint(request,
|
|
fmt.Sprintf("%s%d/%d", orderPath, acct.ID, order.Id))
|
|
response.Header().Set("Location", orderURL)
|
|
|
|
respObj := wfe.orderToOrderJSON(request, order)
|
|
err = wfe.writeJsonResponse(response, logEvent, http.StatusCreated, respObj)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling order"), err)
|
|
return
|
|
}
|
|
newOrderSuccessful = true
|
|
}
|
|
|
|
// GetOrder is used to retrieve a existing order object
|
|
func (wfe *WebFrontEndImpl) GetOrder(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) {
|
|
var requesterAccount *core.Registration
|
|
// 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)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
requesterAccount = acct
|
|
}
|
|
|
|
// Path prefix is stripped, so this should be like "<account ID>/<order ID>"
|
|
fields := strings.SplitN(request.URL.Path, "/", 2)
|
|
if len(fields) != 2 {
|
|
wfe.sendError(response, logEvent, probs.NotFound("Invalid request path"), nil)
|
|
return
|
|
}
|
|
acctID, err := strconv.ParseInt(fields[0], 10, 64)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Invalid account ID"), err)
|
|
return
|
|
}
|
|
orderID, err := strconv.ParseInt(fields[1], 10, 64)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Invalid order ID"), err)
|
|
return
|
|
}
|
|
|
|
order, err := wfe.sa.GetOrder(ctx, &sapb.OrderRequest{Id: orderID})
|
|
if err != nil {
|
|
if errors.Is(err, berrors.NotFound) {
|
|
wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order for ID %d", orderID)), nil)
|
|
return
|
|
}
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err,
|
|
fmt.Sprintf("Failed to retrieve order for ID %d", orderID)), err)
|
|
return
|
|
}
|
|
|
|
if core.IsAnyNilOrZero(order.Id, order.Status, order.RegistrationID, identifier.FromProtoSliceWithDefault(order), order.Created, order.Expires) {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal(fmt.Sprintf("Failed to retrieve order for ID %d", orderID)), errIncompleteGRPCResponse)
|
|
return
|
|
}
|
|
|
|
if order.RegistrationID != acctID {
|
|
wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order found for account ID %d", acctID)), nil)
|
|
return
|
|
}
|
|
|
|
// If the requesterAccount is not nil then this was an authenticated
|
|
// POST-as-GET request and we need to verify the requesterAccount is the
|
|
// order's owner.
|
|
if requesterAccount != nil && order.RegistrationID != requesterAccount.ID {
|
|
wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order found for account ID %d", acctID)), nil)
|
|
return
|
|
}
|
|
|
|
respObj := wfe.orderToOrderJSON(request, order)
|
|
|
|
if respObj.Status == core.StatusProcessing {
|
|
response.Header().Set(headerRetryAfter, strconv.Itoa(orderRetryAfter))
|
|
}
|
|
|
|
err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, respObj)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling order"), err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// FinalizeOrder is used to request issuance for a existing order object.
|
|
// Most processing of the order details is handled by the RA but
|
|
// we do attempt to throw away requests with invalid CSRs here.
|
|
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)
|
|
addRequesterHeader(response, logEvent.Requester)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Unable to validate JWS"), err)
|
|
return
|
|
}
|
|
|
|
// Order URLs are like: /acme/finalize/<account>/<order>/. The prefix is
|
|
// stripped by the time we get here.
|
|
fields := strings.SplitN(request.URL.Path, "/", 2)
|
|
if len(fields) != 2 {
|
|
wfe.sendError(response, logEvent, probs.NotFound("Invalid request path"), nil)
|
|
return
|
|
}
|
|
acctID, err := strconv.ParseInt(fields[0], 10, 64)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Invalid account ID"), nil)
|
|
return
|
|
}
|
|
orderID, err := strconv.ParseInt(fields[1], 10, 64)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Invalid order ID"), nil)
|
|
return
|
|
}
|
|
|
|
if acct.ID != acctID {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Mismatched account ID"), nil)
|
|
return
|
|
}
|
|
|
|
order, err := wfe.sa.GetOrder(ctx, &sapb.OrderRequest{Id: orderID})
|
|
if err != nil {
|
|
if errors.Is(err, berrors.NotFound) {
|
|
wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order for ID %d", orderID)), nil)
|
|
return
|
|
}
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err,
|
|
fmt.Sprintf("Failed to retrieve order for ID %d", orderID)), err)
|
|
return
|
|
}
|
|
|
|
orderIdents := identifier.FromProtoSliceWithDefault(order)
|
|
if core.IsAnyNilOrZero(order.Id, order.Status, order.RegistrationID, orderIdents, order.Created, order.Expires) {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal(fmt.Sprintf("Failed to retrieve order for ID %d", orderID)), errIncompleteGRPCResponse)
|
|
return
|
|
}
|
|
|
|
// If the authenticated account ID doesn't match the order's registration ID
|
|
// pretend it doesn't exist and abort.
|
|
if acct.ID != order.RegistrationID {
|
|
wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("No order found for account ID %d", acct.ID)), nil)
|
|
return
|
|
}
|
|
|
|
// Only ready orders can be finalized.
|
|
if order.Status != string(core.StatusReady) {
|
|
wfe.sendError(response, logEvent, probs.OrderNotReady(fmt.Sprintf("Order's status (%q) is not acceptable for finalization", order.Status)), nil)
|
|
return
|
|
}
|
|
|
|
// If the order is expired we can not finalize it and must return an error
|
|
orderExpiry := order.Expires.AsTime()
|
|
if orderExpiry.Before(wfe.clk.Now()) {
|
|
wfe.sendError(response, logEvent, probs.NotFound(fmt.Sprintf("Order %d is expired", order.Id)), nil)
|
|
return
|
|
}
|
|
|
|
// Don't finalize orders with profiles we no longer recognize.
|
|
if order.CertificateProfileName != "" {
|
|
err = wfe.validateCertificateProfileName(order.CertificateProfileName)
|
|
if err != nil {
|
|
// TODO(#7392) Provide link to profile documentation.
|
|
wfe.sendError(response, logEvent, probs.InvalidProfile(err.Error()), err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// The authenticated finalize message body should be an encoded CSR
|
|
var rawCSR core.RawCertificateRequest
|
|
err = json.Unmarshal(body, &rawCSR)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent,
|
|
probs.Malformed("Error unmarshaling finalize order request"), err)
|
|
return
|
|
}
|
|
|
|
// Check for a malformed CSR early to avoid unnecessary RPCs
|
|
csr, err := x509.ParseCertificateRequest(rawCSR.CSR)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.Malformed("Error parsing certificate request: %s", err), err)
|
|
return
|
|
}
|
|
|
|
logEvent.Identifiers = orderIdents
|
|
logEvent.Extra["KeyType"] = web.KeyTypeToString(csr.PublicKey)
|
|
|
|
updatedOrder, err := wfe.ra.FinalizeOrder(ctx, &rapb.FinalizeOrderRequest{
|
|
Csr: rawCSR.CSR,
|
|
Order: order,
|
|
})
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Error finalizing order"), err)
|
|
return
|
|
}
|
|
if core.IsAnyNilOrZero(updatedOrder.Id, updatedOrder.RegistrationID, identifier.FromProtoSliceWithDefault(updatedOrder), updatedOrder.Created, updatedOrder.Expires) {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Error validating order"), errIncompleteGRPCResponse)
|
|
return
|
|
}
|
|
|
|
// Inc CSR signature algorithm counter
|
|
wfe.stats.csrSignatureAlgs.With(prometheus.Labels{"type": csr.SignatureAlgorithm.String()}).Inc()
|
|
|
|
orderURL := web.RelativeEndpoint(request,
|
|
fmt.Sprintf("%s%d/%d", orderPath, acct.ID, updatedOrder.Id))
|
|
response.Header().Set("Location", orderURL)
|
|
|
|
respObj := wfe.orderToOrderJSON(request, updatedOrder)
|
|
|
|
if respObj.Status == core.StatusProcessing {
|
|
response.Header().Set(headerRetryAfter, strconv.Itoa(orderRetryAfter))
|
|
}
|
|
|
|
err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, respObj)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Unable to write finalize order response"), err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// parseARICertID parses the "certID", a unique identifier specified in
|
|
// draft-ietf-acme-ari-03. It takes the composite string as input returns a
|
|
// extracted and decoded certificate serial. If the decoded AKID does not match
|
|
// any known issuer or the serial number is not valid, an error is returned. For
|
|
// more details see:
|
|
// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-03#section-4.1.
|
|
func parseARICertID(path string, issuerCertificates map[issuance.NameID]*issuance.Certificate) (string, error) {
|
|
parts := strings.Split(path, ".")
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return "", berrors.MalformedError("Invalid path")
|
|
}
|
|
|
|
akid, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
if err != nil {
|
|
return "", berrors.MalformedError("Authority Key Identifier was not base64url-encoded or contained padding: %s", err)
|
|
}
|
|
|
|
var found bool
|
|
for _, issuer := range issuerCertificates {
|
|
if bytes.Equal(issuer.SubjectKeyId, akid) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return "", berrors.NotFoundError("path contained an Authority Key Identifier that did not match a known issuer")
|
|
}
|
|
|
|
serialNumber, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return "", berrors.NotFoundError("serial number was not base64url-encoded or contained padding: %s", err)
|
|
}
|
|
|
|
return core.SerialToString(new(big.Int).SetBytes(serialNumber)), nil
|
|
}
|
|
|
|
// RenewalInfo is used to get information about the suggested renewal window
|
|
// for the given certificate. It only accepts unauthenticated GET requests.
|
|
func (wfe *WebFrontEndImpl) RenewalInfo(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) {
|
|
if !features.Get().ServeRenewalInfo {
|
|
wfe.sendError(response, logEvent, probs.NotFound("Feature not enabled"), nil)
|
|
return
|
|
}
|
|
|
|
if len(request.URL.Path) == 0 {
|
|
wfe.sendError(response, logEvent, probs.NotFound("Must specify a request path"), nil)
|
|
return
|
|
}
|
|
|
|
decodedSerial, err := parseARICertID(request.URL.Path, wfe.issuerCertificates)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "While parsing ARI CertID an error occurred"), err)
|
|
return
|
|
}
|
|
|
|
// We can do all of our processing based just on the serial, because Boulder
|
|
// does not re-use the same serial across multiple issuers.
|
|
logEvent.Extra["RequestedSerial"] = decodedSerial
|
|
|
|
renewalInfo, err := wfe.determineARIWindow(ctx, decodedSerial)
|
|
if err != nil {
|
|
if errors.Is(err, berrors.NotFound) {
|
|
wfe.sendError(response, logEvent, probs.NotFound("Requested certificate was not found"), nil)
|
|
return
|
|
}
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Error determining renewal window"), err)
|
|
return
|
|
}
|
|
|
|
response.Header().Set(headerRetryAfter, fmt.Sprintf("%d", int(6*time.Hour/time.Second)))
|
|
err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, renewalInfo)
|
|
if err != nil {
|
|
wfe.sendError(response, logEvent, probs.ServerInternal("Error marshalling renewalInfo"), err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func extractRequesterIP(req *http.Request) (net.IP, error) {
|
|
ip := net.ParseIP(req.Header.Get("X-Real-IP"))
|
|
if ip != nil {
|
|
return ip, nil
|
|
}
|
|
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return net.ParseIP(host), nil
|
|
}
|
|
|
|
func urlForAuthz(authz core.Authorization, request *http.Request) string {
|
|
return web.RelativeEndpoint(request, fmt.Sprintf("%s%d/%s", authzPath, authz.RegistrationID, authz.ID))
|
|
}
|