2103 lines
64 KiB
Go
2103 lines
64 KiB
Go
package wfe
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"math/rand"
|
|
"net"
|
|
"net/http"
|
|
"net/mail"
|
|
"net/url"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"gopkg.in/square/go-jose.v2"
|
|
|
|
"github.com/jmhodges/clock"
|
|
"github.com/letsencrypt/pebble/acme"
|
|
"github.com/letsencrypt/pebble/ca"
|
|
"github.com/letsencrypt/pebble/core"
|
|
"github.com/letsencrypt/pebble/db"
|
|
"github.com/letsencrypt/pebble/va"
|
|
)
|
|
|
|
const (
|
|
// Note: We deliberately pick endpoint paths that differ from Boulder to
|
|
// exercise clients processing of the /directory response
|
|
// We export the DirectoryPath and RootCertPath so that the pebble binary can reference it
|
|
DirectoryPath = "/dir"
|
|
RootCertPath = "/root"
|
|
noncePath = "/nonce-plz"
|
|
newAccountPath = "/sign-me-up"
|
|
acctPath = "/my-account/"
|
|
newOrderPath = "/order-plz"
|
|
orderPath = "/my-order/"
|
|
orderFinalizePath = "/finalize-order/"
|
|
authzPath = "/authZ/"
|
|
challengePath = "/chalZ/"
|
|
certPath = "/certZ/"
|
|
revokeCertPath = "/revoke-cert"
|
|
keyRolloverPath = "/rollover-account-key"
|
|
|
|
// How long do pending authorizations last before expiring?
|
|
pendingAuthzExpire = time.Hour
|
|
|
|
// How many contacts is an account allowed to have?
|
|
maxContactsPerAcct = 2
|
|
|
|
// badNonceEnvVar defines the environment variable name used to provide
|
|
// a percentage value for how often good nonces should be rejected as if they
|
|
// were bad. This can be used to exercise client nonce handling/retries.
|
|
// To have the WFE not reject any good nonces, run Pebble like:
|
|
// PEBBLE_WFE_NONCEREJECT=0 pebble
|
|
// To have the WFE reject 15% of good nonces, run Pebble like:
|
|
// PEBBLE_WFE_NONCEREJECT=15 pebble
|
|
badNonceEnvVar = "PEBBLE_WFE_NONCEREJECT"
|
|
|
|
// By default when no PEBBLE_WFE_NONCEREJECT is set, what percentage of good
|
|
// nonces are rejected?
|
|
defaultNonceReject = 5
|
|
|
|
// POST requests with a JWS body must have the following Content-Type header
|
|
expectedJWSContentType = "application/jose+json"
|
|
|
|
// RFC 1034 says DNS labels have a max of 63 octets, and names have a max of 255
|
|
// octets: https://tools.ietf.org/html/rfc1035#page-10. Since two of those octets
|
|
// are taken up by the leading length byte and the trailing root period the actual
|
|
// max length becomes 253.
|
|
maxDNSIdentifierLength = 253
|
|
|
|
// Invalid revocation reason codes.
|
|
// The full list of codes can be found in Section 8.5.3.1 of ITU-T X.509
|
|
// http://www.itu.int/rec/T-REC-X.509-201210-I/en
|
|
unusedRevocationReason = 7
|
|
aACompromiseRevocationReason = 10
|
|
)
|
|
|
|
type wfeHandlerFunc func(context.Context, http.ResponseWriter, *http.Request)
|
|
|
|
func (f wfeHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
ctx := context.TODO()
|
|
f(ctx, w, r)
|
|
}
|
|
|
|
type wfeHandler interface {
|
|
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
|
}
|
|
|
|
type topHandler struct {
|
|
wfe wfeHandler
|
|
}
|
|
|
|
func (th *topHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
th.wfe.ServeHTTP(w, r)
|
|
}
|
|
|
|
type WebFrontEndImpl struct {
|
|
log *log.Logger
|
|
db *db.MemoryStore
|
|
nonce *nonceMap
|
|
nonceErrPercent int
|
|
clk clock.Clock
|
|
va *va.VAImpl
|
|
ca *ca.CAImpl
|
|
strict bool
|
|
}
|
|
|
|
const ToSURL = "data:text/plain,Do%20what%20thou%20wilt"
|
|
|
|
func New(
|
|
log *log.Logger,
|
|
clk clock.Clock,
|
|
db *db.MemoryStore,
|
|
va *va.VAImpl,
|
|
ca *ca.CAImpl,
|
|
strict bool) WebFrontEndImpl {
|
|
|
|
// Read the % of good nonces that should be rejected as bad nonces from the
|
|
// environment
|
|
nonceErrPercentVal := os.Getenv(badNonceEnvVar)
|
|
var nonceErrPercent int
|
|
|
|
// Parse the env var value as a base 10 int - if there isn't an error, use it
|
|
// as the wfe nonceErrPercent
|
|
if val, err := strconv.ParseInt(nonceErrPercentVal, 10, 0); err == nil {
|
|
nonceErrPercent = int(val)
|
|
} else {
|
|
// Otherwise just use the default
|
|
nonceErrPercent = defaultNonceReject
|
|
}
|
|
|
|
// If the value is out of the range just clip it sensibly
|
|
if nonceErrPercent < 0 {
|
|
nonceErrPercent = 0
|
|
} else if nonceErrPercent > 100 {
|
|
nonceErrPercent = 99
|
|
}
|
|
log.Printf("Configured to reject %d%% of good nonces", nonceErrPercent)
|
|
|
|
return WebFrontEndImpl{
|
|
log: log,
|
|
db: db,
|
|
nonce: newNonceMap(),
|
|
nonceErrPercent: nonceErrPercent,
|
|
clk: clk,
|
|
va: va,
|
|
ca: ca,
|
|
strict: strict,
|
|
}
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) HandleFunc(
|
|
mux *http.ServeMux,
|
|
pattern string,
|
|
handler 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, ", ")
|
|
defaultHandler := http.StripPrefix(pattern,
|
|
&topHandler{
|
|
wfe: wfeHandlerFunc(func(ctx context.Context, response http.ResponseWriter, request *http.Request) {
|
|
// Modern ACME only sends a Replay-Nonce in responses to GET/HEAD
|
|
// requests to the dedicated newNonce endpoint, or in replies to POST
|
|
// requests that consumed a nonce.
|
|
if request.Method == "POST" || pattern == noncePath {
|
|
response.Header().Set("Replay-Nonce", wfe.nonce.createNonce())
|
|
}
|
|
|
|
// 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 := wfe.relativeEndpoint(request, DirectoryPath)
|
|
response.Header().Add("Link", link(directoryURL, "index"))
|
|
}
|
|
|
|
addNoCacheHeader(response)
|
|
|
|
if !methodsMap[request.Method] {
|
|
response.Header().Set("Allow", methodsStr)
|
|
wfe.sendError(acme.MethodNotAllowed(), response)
|
|
return
|
|
}
|
|
|
|
wfe.log.Printf("%s %s -> calling handler()\n", request.Method, pattern)
|
|
|
|
// TODO(@cpu): Configurable request timeout
|
|
timeout := 1 * time.Minute
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
handler(ctx, response, request)
|
|
cancel()
|
|
},
|
|
)})
|
|
mux.Handle(pattern, defaultHandler)
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) sendError(prob *acme.ProblemDetails, response http.ResponseWriter) {
|
|
problemDoc, err := marshalIndent(prob)
|
|
if err != nil {
|
|
problemDoc = []byte("{\"detail\": \"Problem marshalling error message.\"}")
|
|
}
|
|
|
|
response.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
|
|
response.WriteHeader(prob.HTTPStatus)
|
|
_, _ = response.Write(problemDoc)
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) RootCert(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
|
|
root := wfe.ca.GetRootCert()
|
|
if root == nil {
|
|
response.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
response.Header().Set("Content-Type", "application/pem-certificate-chain; charset=utf-8")
|
|
response.WriteHeader(http.StatusOK)
|
|
_, _ = response.Write(root.PEM())
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) Handler() http.Handler {
|
|
m := http.NewServeMux()
|
|
// GET only handlers
|
|
wfe.HandleFunc(m, DirectoryPath, wfe.Directory, "GET")
|
|
// Note for noncePath: "GET" also implies "HEAD"
|
|
wfe.HandleFunc(m, noncePath, wfe.Nonce, "GET")
|
|
wfe.HandleFunc(m, RootCertPath, wfe.RootCert, "GET")
|
|
|
|
// POST only handlers
|
|
wfe.HandleFunc(m, newAccountPath, wfe.NewAccount, "POST")
|
|
wfe.HandleFunc(m, newOrderPath, wfe.NewOrder, "POST")
|
|
wfe.HandleFunc(m, orderFinalizePath, wfe.FinalizeOrder, "POST")
|
|
wfe.HandleFunc(m, acctPath, wfe.UpdateAccount, "POST")
|
|
wfe.HandleFunc(m, keyRolloverPath, wfe.KeyRollover, "POST")
|
|
wfe.HandleFunc(m, revokeCertPath, wfe.RevokeCert, "POST")
|
|
wfe.HandleFunc(m, certPath, wfe.Certificate, "POST")
|
|
wfe.HandleFunc(m, orderPath, wfe.Order, "POST")
|
|
wfe.HandleFunc(m, authzPath, wfe.Authz, "POST")
|
|
wfe.HandleFunc(m, challengePath, wfe.Challenge, "POST")
|
|
|
|
return m
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) Directory(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
|
|
directoryEndpoints := map[string]string{
|
|
"newNonce": noncePath,
|
|
"newAccount": newAccountPath,
|
|
"newOrder": newOrderPath,
|
|
"revokeCert": revokeCertPath,
|
|
"keyChange": keyRolloverPath,
|
|
}
|
|
|
|
response.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
relDir, err := wfe.relativeDirectory(request, directoryEndpoints)
|
|
if err != nil {
|
|
wfe.sendError(acme.InternalErrorProblem("unable to create directory"), response)
|
|
return
|
|
}
|
|
|
|
_, _ = response.Write(relDir)
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) relativeDirectory(request *http.Request, directory map[string]string) ([]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))
|
|
|
|
for k, v := range directory {
|
|
relativeDir[k] = wfe.relativeEndpoint(request, v)
|
|
}
|
|
relativeDir["meta"] = map[string]string{
|
|
"termsOfService": ToSURL,
|
|
}
|
|
|
|
directoryJSON, err := marshalIndent(relativeDir)
|
|
// This should never happen since we are just marshaling known strings
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return directoryJSON, nil
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) relativeEndpoint(request *http.Request, endpoint string) string {
|
|
proto := "http"
|
|
host := request.Host
|
|
|
|
// 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
|
|
}
|
|
|
|
// Default to "localhost" when no request.Host is provided. Otherwise requests
|
|
// with an empty `Host` produce results like `http:///acme/new-authz`
|
|
if request.Host == "" {
|
|
host = "localhost"
|
|
}
|
|
|
|
return (&url.URL{Scheme: proto, Host: host, Path: endpoint}).String()
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) Nonce(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
statusCode := http.StatusNoContent
|
|
// The ACME specification says GET requets should receive http.StatusNoContent
|
|
// and HEAD requests should receive http.StatusOK.
|
|
if request.Method == "HEAD" {
|
|
statusCode = http.StatusOK
|
|
}
|
|
response.WriteHeader(statusCode)
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) parseJWS(body string) (*jose.JSONWebSignature, error) {
|
|
// Parse the raw JWS JSON to check that:
|
|
// * the unprotected Header field is not being used.
|
|
// * the "signatures" member isn't present, just "signature".
|
|
//
|
|
// This must be done prior to `jose.parseSigned` since it will strip away
|
|
// these headers.
|
|
var unprotected struct {
|
|
Header map[string]string
|
|
Signatures []interface{}
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &unprotected); err != nil {
|
|
return nil, errors.New("Parse error reading JWS")
|
|
}
|
|
|
|
// ACME v2 never uses values from the unprotected JWS header. Reject JWS that
|
|
// include unprotected headers.
|
|
if unprotected.Header != nil {
|
|
return nil, errors.New(
|
|
"JWS \"header\" field not allowed. All headers must be in \"protected\" field")
|
|
}
|
|
|
|
// ACME v2 never uses the "signatures" array of JSON serialized JWS, just the
|
|
// mandatory "signature" field. Reject JWS that include the "signatures" array.
|
|
if len(unprotected.Signatures) > 0 {
|
|
return nil, errors.New(
|
|
"JWS \"signatures\" field not allowed. Only the \"signature\" field should contain a signature")
|
|
}
|
|
|
|
parsedJWS, err := jose.ParseSigned(body)
|
|
if err != nil {
|
|
return nil, errors.New("Parse error reading JWS")
|
|
}
|
|
|
|
if len(parsedJWS.Signatures) > 1 {
|
|
return nil, errors.New("Too many signatures in POST body")
|
|
}
|
|
|
|
if len(parsedJWS.Signatures) == 0 {
|
|
return nil, errors.New("POST JWS not signed")
|
|
}
|
|
return parsedJWS, nil
|
|
}
|
|
|
|
// jwsAuthType represents whether a given POST request is authenticated using
|
|
// a JWS with an embedded JWK (new-account, possibly revoke-cert) or an
|
|
// embedded Key ID or an unsupported/unknown auth type.
|
|
type jwsAuthType int
|
|
|
|
const (
|
|
embeddedJWK jwsAuthType = iota
|
|
embeddedKeyID
|
|
invalidAuthType
|
|
)
|
|
|
|
// checkJWSAuthType examines a JWS' protected headers to determine if
|
|
// the request being authenticated by the JWS is identified using an embedded
|
|
// JWK or an embedded key ID. If no signatures are present, or mutually
|
|
// exclusive authentication types are specified at the same time, a problem is
|
|
// returned.
|
|
func checkJWSAuthType(jws *jose.JSONWebSignature) (jwsAuthType, *acme.ProblemDetails) {
|
|
// checkJWSAuthType is called after parseJWS() which defends against the
|
|
// incorrect number of signatures.
|
|
header := jws.Signatures[0].Header
|
|
// There must not be a Key ID *and* an embedded JWK
|
|
if header.KeyID != "" && header.JSONWebKey != nil {
|
|
return invalidAuthType, acme.MalformedProblem("jwk and kid header fields are mutually exclusive")
|
|
} else if header.KeyID != "" {
|
|
return embeddedKeyID, nil
|
|
} else if header.JSONWebKey != nil {
|
|
return embeddedJWK, nil
|
|
}
|
|
return invalidAuthType, nil
|
|
}
|
|
|
|
// extractJWK returns a JSONWebKey embedded in a JWS header.
|
|
func (wfe *WebFrontEndImpl) extractJWK(_ *http.Request, jws *jose.JSONWebSignature) (*jose.JSONWebKey, *acme.ProblemDetails) {
|
|
header := jws.Signatures[0].Header
|
|
key := header.JSONWebKey
|
|
if key == nil {
|
|
return nil, acme.MalformedProblem("No JWK in JWS header")
|
|
}
|
|
if !key.Valid() {
|
|
return nil, acme.MalformedProblem("Invalid JWK in JWS header")
|
|
}
|
|
if header.KeyID != "" {
|
|
return nil, acme.MalformedProblem("jwk and kid header fields are mutually exclusive.")
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// lookupJWK returns a JSONWebKey referenced by the "kid" (key id) field in a JWS header.
|
|
func (wfe *WebFrontEndImpl) lookupJWK(request *http.Request, jws *jose.JSONWebSignature) (*jose.JSONWebKey, *acme.ProblemDetails) {
|
|
header := jws.Signatures[0].Header
|
|
accountURL := header.KeyID
|
|
prefix := wfe.relativeEndpoint(request, acctPath)
|
|
if !strings.HasPrefix(accountURL, prefix) {
|
|
return nil, acme.MalformedProblem("Key ID (kid) in JWS header missing expected URL prefix")
|
|
}
|
|
accountID := strings.TrimPrefix(accountURL, prefix)
|
|
if accountID == "" {
|
|
return nil, acme.MalformedProblem("No key ID (kid) in JWS header")
|
|
}
|
|
account := wfe.db.GetAccountByID(accountID)
|
|
if account == nil {
|
|
return nil, acme.AccountDoesNotExistProblem(fmt.Sprintf(
|
|
"Account %s not found.", accountURL))
|
|
}
|
|
if header.JSONWebKey != nil {
|
|
return nil, acme.MalformedProblem("jwk and kid header fields are mutually exclusive.")
|
|
}
|
|
return account.Key, nil
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) validPOST(request *http.Request) *acme.ProblemDetails {
|
|
// Section 6.2 says to reject JWS requests without the expected Content-Type
|
|
// using a status code of http.UnsupportedMediaType
|
|
if _, present := request.Header["Content-Type"]; !present {
|
|
return acme.UnsupportedMediaTypeProblem(
|
|
`missing Content-Type header on POST. ` +
|
|
`Content-Type must be "application/jose+json"`)
|
|
}
|
|
if contentType := request.Header.Get("Content-Type"); contentType != expectedJWSContentType {
|
|
return acme.UnsupportedMediaTypeProblem(
|
|
`Invalid Content-Type header on POST. ` +
|
|
`Content-Type must be "application/jose+json"`)
|
|
}
|
|
|
|
if _, present := request.Header["Content-Length"]; !present {
|
|
return acme.MalformedProblem("missing Content-Length header on POST")
|
|
}
|
|
|
|
// Per 6.4.1 "Replay-Nonce" clients should not send a Replay-Nonce header in
|
|
// the HTTP request, it needs to be part of the signed JWS request body
|
|
if _, present := request.Header["Replay-Nonce"]; present {
|
|
return acme.MalformedProblem("HTTP requests should NOT contain Replay-Nonce header. Use JWS nonce field")
|
|
}
|
|
|
|
// All POSTs must have a body
|
|
if request.Body == nil {
|
|
return acme.MalformedProblem("no body on POST")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) validPOSTAsGET(postData *authenticatedPOST) (*core.Account, *acme.ProblemDetails) {
|
|
if postData == nil {
|
|
return nil, acme.InternalErrorProblem("nil authenticated POST data")
|
|
}
|
|
|
|
if !postData.postAsGet {
|
|
return nil, acme.MalformedProblem("POST-as-GET requests must have a nil body")
|
|
}
|
|
|
|
// All POST-as-GET requests are authenticated by an existing account
|
|
account, prob := wfe.getAcctByKey(postData.jwk)
|
|
if prob != nil {
|
|
return nil, prob
|
|
}
|
|
|
|
return account, nil
|
|
}
|
|
|
|
// keyExtractor is a function that returns a JSONWebKey based on input from a
|
|
// user-provided JSONWebSignature, for instance by extracting it from the input,
|
|
// or by looking it up in a database based on the input.
|
|
type keyExtractor func(*http.Request, *jose.JSONWebSignature) (*jose.JSONWebKey, *acme.ProblemDetails)
|
|
|
|
type authenticatedPOST struct {
|
|
postAsGet bool
|
|
body []byte
|
|
url string
|
|
jwk *jose.JSONWebKey
|
|
}
|
|
|
|
// NOTE: Unlike `verifyPOST` from the Boulder WFE this version does not
|
|
// presently handle the `regCheck` parameter or do any lookups for existing
|
|
// accounts.
|
|
func (wfe *WebFrontEndImpl) verifyPOST(
|
|
request *http.Request,
|
|
kx keyExtractor) (*authenticatedPOST, *acme.ProblemDetails) {
|
|
|
|
if prob := wfe.validPOST(request); prob != nil {
|
|
return nil, prob
|
|
}
|
|
|
|
bodyBytes, err := ioutil.ReadAll(request.Body)
|
|
if err != nil {
|
|
return nil, acme.InternalErrorProblem("unable to read request body")
|
|
}
|
|
|
|
body := string(bodyBytes)
|
|
parsedJWS, err := wfe.parseJWS(body)
|
|
if err != nil {
|
|
return nil, acme.MalformedProblem(err.Error())
|
|
}
|
|
|
|
pubKey, prob := kx(request, parsedJWS)
|
|
if prob != nil {
|
|
return nil, prob
|
|
}
|
|
|
|
result, prob := wfe.verifyJWS(pubKey, parsedJWS, request)
|
|
if prob != nil {
|
|
return nil, prob
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// verifyJWSSignatureAndAlgorithm verifies the pubkey and JWS algorithms are
|
|
// acceptable and that the JWS verifies with the provided pubkey.
|
|
func (wfe *WebFrontEndImpl) verifyJWSSignatureAndAlgorithm(
|
|
pubKey *jose.JSONWebKey,
|
|
parsedJWS *jose.JSONWebSignature) ([]byte, *acme.ProblemDetails) {
|
|
if prob := checkAlgorithm(pubKey, parsedJWS); prob != nil {
|
|
return nil, prob
|
|
}
|
|
|
|
payload, err := parsedJWS.Verify(pubKey)
|
|
if err != nil {
|
|
return nil, acme.MalformedProblem(fmt.Sprintf("JWS verification error: %s", err))
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
// Extracts URL header parameter from parsed JWS.
|
|
// Second return value indicates whether header was found.
|
|
func (wfe *WebFrontEndImpl) extractJWSURL(
|
|
parsedJWS *jose.JSONWebSignature) (string, bool) {
|
|
headerURL, ok := parsedJWS.Signatures[0].Header.ExtraHeaders[jose.HeaderKey("url")].(string)
|
|
if !ok || len(headerURL) == 0 {
|
|
return "", false
|
|
}
|
|
return headerURL, true
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) verifyJWS(
|
|
pubKey *jose.JSONWebKey,
|
|
parsedJWS *jose.JSONWebSignature,
|
|
request *http.Request) (*authenticatedPOST, *acme.ProblemDetails) {
|
|
payload, prob := wfe.verifyJWSSignatureAndAlgorithm(pubKey, parsedJWS)
|
|
if prob != nil {
|
|
return nil, prob
|
|
}
|
|
|
|
headerURL, ok := wfe.extractJWSURL(parsedJWS)
|
|
if !ok {
|
|
return nil, acme.MalformedProblem("JWS header parameter 'url' required.")
|
|
}
|
|
|
|
nonce := parsedJWS.Signatures[0].Header.Nonce
|
|
if len(nonce) == 0 {
|
|
return nil, acme.BadNonceProblem("JWS has no anti-replay nonce")
|
|
}
|
|
|
|
// Roll a random number between 0 and 100.
|
|
nonceRoll := rand.Intn(100)
|
|
// If the nonce is not valid OR if the nonceRoll was less than the
|
|
// nonceErrPercent, fail with an error
|
|
if !wfe.nonce.validNonce(nonce) || nonceRoll < wfe.nonceErrPercent {
|
|
return nil, acme.BadNonceProblem(fmt.Sprintf(
|
|
"JWS has an invalid anti-replay nonce: %s", nonce))
|
|
}
|
|
|
|
expectedURL := url.URL{
|
|
// NOTE(@cpu): ACME **REQUIRES** HTTPS and Pebble is hardcoded to offer the
|
|
// API over HTTPS.
|
|
Scheme: "https",
|
|
Host: request.Host,
|
|
Path: request.RequestURI,
|
|
}
|
|
if expectedURL.String() != headerURL {
|
|
return nil, acme.MalformedProblem(fmt.Sprintf(
|
|
"JWS header parameter 'url' incorrect. Expected %q, got %q",
|
|
expectedURL.String(), headerURL))
|
|
}
|
|
|
|
return &authenticatedPOST{
|
|
postAsGet: string(payload) == "",
|
|
body: payload,
|
|
url: headerURL,
|
|
jwk: pubKey}, nil
|
|
}
|
|
|
|
// isASCII determines if every character in a string is encoded in
|
|
// the ASCII character set.
|
|
func isASCII(str string) bool {
|
|
for _, r := range str {
|
|
if r > unicode.MaxASCII {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) verifyContacts(acct acme.Account) *acme.ProblemDetails {
|
|
contacts := acct.Contact
|
|
|
|
// Providing no Contacts is perfectly acceptable
|
|
if len(contacts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if len(contacts) > maxContactsPerAcct {
|
|
return acme.MalformedProblem(fmt.Sprintf(
|
|
"too many contacts provided: %d > %d", len(contacts), maxContactsPerAcct))
|
|
}
|
|
|
|
for _, c := range contacts {
|
|
parsed, err := url.Parse(c)
|
|
if err != nil {
|
|
return acme.InvalidContactProblem(fmt.Sprintf("contact %q is invalid", c))
|
|
}
|
|
if parsed.Scheme != "mailto" {
|
|
return acme.UnsupportedContactProblem(fmt.Sprintf(
|
|
"contact method %q is not supported", parsed.Scheme))
|
|
}
|
|
email := parsed.Opaque
|
|
// An empty or omitted Contact array should be used instead of an empty contact
|
|
if email == "" {
|
|
return acme.InvalidContactProblem("empty contact email")
|
|
}
|
|
if !isASCII(email) {
|
|
return acme.InvalidContactProblem(fmt.Sprintf(
|
|
"contact email %q contains non-ASCII characters", email))
|
|
}
|
|
// NOTE(@cpu): ParseAddress may allow invalid emails since it supports RFC 5322
|
|
// display names. This is sufficient for Pebble because we don't intend to
|
|
// use the emails for anything and check this as a best effort for client
|
|
// developers to test invalid contact problems.
|
|
_, err = mail.ParseAddress(email)
|
|
if err != nil {
|
|
return acme.InvalidContactProblem(fmt.Sprintf(
|
|
"contact email %q is invalid", email))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) UpdateAccount(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
postData, prob := wfe.verifyPOST(request, wfe.lookupJWK)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
// updateAcctReq is the ACME account information submitted by the client
|
|
var updateAcctReq struct {
|
|
Contact []string `json:"contact"`
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
var existingAcct *core.Account
|
|
if postData.postAsGet {
|
|
existingAcct, prob = wfe.validPOSTAsGET(postData)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
} else {
|
|
err := json.Unmarshal(postData.body, &updateAcctReq)
|
|
if err != nil {
|
|
wfe.sendError(
|
|
acme.MalformedProblem("Error unmarshaling account update JSON body"), response)
|
|
return
|
|
}
|
|
existingAcct, prob = wfe.getAcctByKey(postData.jwk)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
}
|
|
|
|
// if this update contains no contacts or deactivated status,
|
|
// simply return the existing account and return early.
|
|
if updateAcctReq.Contact == nil && updateAcctReq.Status != acme.StatusDeactivated {
|
|
if !postData.postAsGet {
|
|
wfe.sendError(acme.MalformedProblem("Use POST-as-GET to retrieve account data instead of doing an empty update"), response)
|
|
return
|
|
}
|
|
err := wfe.writeJSONResponse(response, http.StatusOK, existingAcct)
|
|
if err != nil {
|
|
wfe.sendError(acme.InternalErrorProblem("Error marshalling account"), response)
|
|
return
|
|
}
|
|
return
|
|
}
|
|
|
|
// Create a new account object with the existing data
|
|
newAcct := &core.Account{
|
|
Account: acme.Account{
|
|
Contact: existingAcct.Contact,
|
|
Status: existingAcct.Status,
|
|
Orders: existingAcct.Orders,
|
|
},
|
|
Key: existingAcct.Key,
|
|
ID: existingAcct.ID,
|
|
}
|
|
|
|
switch {
|
|
case updateAcctReq.Status == acme.StatusDeactivated:
|
|
newAcct.Status = updateAcctReq.Status
|
|
case updateAcctReq.Status != "" && updateAcctReq.Status != newAcct.Status:
|
|
wfe.sendError(
|
|
acme.MalformedProblem(fmt.Sprintf(
|
|
"Invalid account status: %q", updateAcctReq.Status)), response)
|
|
return
|
|
case updateAcctReq.Contact != nil:
|
|
newAcct.Contact = updateAcctReq.Contact
|
|
// Verify that the contact information provided is supported & valid
|
|
prob = wfe.verifyContacts(newAcct.Account)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
}
|
|
|
|
err := wfe.db.UpdateAccountByID(existingAcct.ID, newAcct)
|
|
if err != nil {
|
|
wfe.sendError(
|
|
acme.MalformedProblem("Error storing updated account"), response)
|
|
return
|
|
}
|
|
|
|
err = wfe.writeJSONResponse(response, http.StatusOK, newAcct)
|
|
if err != nil {
|
|
wfe.sendError(acme.InternalErrorProblem("Error marshalling account"), response)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) verifyKeyRollover(
|
|
innerPayload []byte,
|
|
existingAcct *core.Account,
|
|
newKey *jose.JSONWebKey,
|
|
request *http.Request) *acme.ProblemDetails {
|
|
var innerContent struct {
|
|
Account string
|
|
OldKey jose.JSONWebKey
|
|
}
|
|
err := json.Unmarshal(innerPayload, &innerContent)
|
|
if err != nil {
|
|
return acme.MalformedProblem("Error unmarshaling key roll-over inner JWS body")
|
|
}
|
|
|
|
// Check account ID
|
|
prefix := wfe.relativeEndpoint(request, acctPath)
|
|
if !strings.HasPrefix(innerContent.Account, prefix) {
|
|
return acme.MalformedProblem(fmt.Sprintf("Key ID (account) in inner JWS body missing expected URL prefix (provided account value: %q)", innerContent.Account))
|
|
}
|
|
accountID := strings.TrimPrefix(innerContent.Account, prefix)
|
|
if accountID == "" {
|
|
return acme.MalformedProblem(fmt.Sprintf("No key ID (account) in inner JWS body (provided account value: %q)", innerContent.Account))
|
|
}
|
|
if accountID != existingAcct.ID {
|
|
return acme.MalformedProblem(fmt.Sprintf("Key roll-over inner JWS body contains wrong account ID (provided account value: %q)", innerContent.Account))
|
|
}
|
|
|
|
// Verify inner key
|
|
if !keyDigestEquals(innerContent.OldKey, *existingAcct.Key) {
|
|
return acme.MalformedProblem("Key roll-over inner JWS body JSON contains wrong old key")
|
|
}
|
|
|
|
// Check for same key
|
|
if keyDigestEquals(innerContent.OldKey, newKey) {
|
|
return acme.MalformedProblem("New and old key are identical")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) KeyRollover(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
// Extract and parse outer JWS, and retrieve account
|
|
outerPostData, prob := wfe.verifyPOST(request, wfe.lookupJWK)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
existingAcct, prob := wfe.getAcctByKey(outerPostData.jwk)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
// Extract inner JWS
|
|
parsedInnerJWS, err := wfe.parseJWS(string(outerPostData.body))
|
|
if err != nil {
|
|
wfe.sendError(acme.MalformedProblem(err.Error()), response)
|
|
return
|
|
}
|
|
|
|
newPubKey, prob := wfe.extractJWK(request, parsedInnerJWS)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
innerPayload, prob := wfe.verifyJWSSignatureAndAlgorithm(newPubKey, parsedInnerJWS)
|
|
if err != nil {
|
|
prob.Detail = "inner JWS error: " + prob.Detail
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
innerHeaderURL, ok := wfe.extractJWSURL(parsedInnerJWS)
|
|
if !ok {
|
|
wfe.sendError(acme.MalformedProblem("Inner JWS header parameter 'url' required."), response)
|
|
return
|
|
}
|
|
|
|
if innerHeaderURL != outerPostData.url {
|
|
wfe.sendError(acme.MalformedProblem("JWS header parameter 'url' differs for inner and outer JWS."), response)
|
|
return
|
|
}
|
|
|
|
prob = wfe.verifyKeyRollover(innerPayload, existingAcct, newPubKey, request)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
// Ok, now change account key
|
|
err = wfe.db.ChangeAccountKey(existingAcct, newPubKey)
|
|
if err != nil {
|
|
if existingAccountError, ok := err.(*db.ExistingAccountError); ok {
|
|
acctURL := wfe.relativeEndpoint(request, fmt.Sprintf("%s%s", acctPath, existingAccountError.MatchingAccount.ID))
|
|
response.Header().Set("Location", acctURL)
|
|
response.WriteHeader(http.StatusConflict)
|
|
} else {
|
|
wfe.sendError(acme.InternalErrorProblem(fmt.Sprintf("Error rolling over account key (%s)", err.Error())), response)
|
|
}
|
|
return
|
|
}
|
|
|
|
response.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) NewAccount(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
|
|
// We use extractJWK rather than lookupJWK here because the account is not yet
|
|
// created, so the user provides the full key in a JWS header rather than
|
|
// referring to an existing key.
|
|
postData, prob := wfe.verifyPOST(request, wfe.extractJWK)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
// newAcctReq is the ACME account information submitted by the client
|
|
var newAcctReq struct {
|
|
Contact []string `json:"contact"`
|
|
ToSAgreed bool `json:"termsOfServiceAgreed"`
|
|
OnlyReturnExisting bool `json:"onlyReturnExisting"`
|
|
}
|
|
err := json.Unmarshal(postData.body, &newAcctReq)
|
|
if err != nil {
|
|
wfe.sendError(
|
|
acme.MalformedProblem("Error unmarshaling body JSON"), response)
|
|
return
|
|
}
|
|
|
|
// Lookup existing account to exit early if it exists
|
|
existingAcct, _ := wfe.db.GetAccountByKey(postData.jwk)
|
|
if existingAcct != nil {
|
|
if existingAcct.Status == acme.StatusDeactivated {
|
|
// If there is an existing, but deactivated account, then return an unauthorized
|
|
// problem informing the user that this account was deactivated
|
|
wfe.sendError(acme.UnauthorizedProblem(
|
|
"An account with the provided public key exists but is deactivated"), response)
|
|
} else {
|
|
// If there is an existing account then return a Location header pointing to
|
|
// the account and a 200 OK response
|
|
acctURL := wfe.relativeEndpoint(request, fmt.Sprintf("%s%s", acctPath, existingAcct.ID))
|
|
response.Header().Set("Location", acctURL)
|
|
_ = wfe.writeJSONResponse(response, http.StatusOK, existingAcct)
|
|
}
|
|
return
|
|
} else if existingAcct == nil && newAcctReq.OnlyReturnExisting {
|
|
// If there *isn't* an existing account and the created account request
|
|
// contained OnlyReturnExisting then this is an error - return now before
|
|
// creating a new account with the key
|
|
wfe.sendError(acme.AccountDoesNotExistProblem(
|
|
"unable to find existing account for only-return-existing request"), response)
|
|
return
|
|
}
|
|
|
|
if !newAcctReq.ToSAgreed {
|
|
response.Header().Add("Link", link(ToSURL, "terms-of-service"))
|
|
wfe.sendError(
|
|
acme.AgreementRequiredProblem(
|
|
"Provided account did not agree to the terms of service"),
|
|
response)
|
|
return
|
|
}
|
|
|
|
// Create a new account object with the provided contact
|
|
newAcct := core.Account{
|
|
Account: acme.Account{
|
|
Contact: newAcctReq.Contact,
|
|
// New accounts are valid to start.
|
|
Status: acme.StatusValid,
|
|
},
|
|
Key: postData.jwk,
|
|
}
|
|
|
|
// Verify that the contact information provided is supported & valid
|
|
prob = wfe.verifyContacts(newAcct.Account)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
count, err := wfe.db.AddAccount(&newAcct)
|
|
if err != nil {
|
|
wfe.sendError(acme.InternalErrorProblem("Error saving account"), response)
|
|
return
|
|
}
|
|
wfe.log.Printf("There are now %d accounts in memory\n", count)
|
|
|
|
acctURL := wfe.relativeEndpoint(request, fmt.Sprintf("%s%s", acctPath, newAcct.ID))
|
|
|
|
response.Header().Add("Location", acctURL)
|
|
err = wfe.writeJSONResponse(response, http.StatusCreated, newAcct)
|
|
if err != nil {
|
|
wfe.sendError(acme.InternalErrorProblem("Error marshalling account"), response)
|
|
return
|
|
}
|
|
}
|
|
|
|
// isDNSCharacter is ported from Boulder's `policy/pa.go` implementation.
|
|
func isDNSCharacter(ch byte) bool {
|
|
return ('a' <= ch && ch <= 'z') ||
|
|
('A' <= ch && ch <= 'Z') ||
|
|
('0' <= ch && ch <= '9') ||
|
|
ch == '.' || ch == '-' || ch == '*'
|
|
}
|
|
|
|
/* TODO(@cpu): Pebble's validation of domain names is still pretty weak
|
|
* compared to Boulder. We should consider adding:
|
|
* 1) Checks for the # of labels, and the size of each label
|
|
* 2) Checks against the Public Suffix List
|
|
* 3) Checks against a configured domain blocklist
|
|
* 4) Checks for malformed IDN, RLDH, etc
|
|
*/
|
|
// verifyOrder checks that a new order is considered well formed. Light
|
|
// validation is done on the order identifiers.
|
|
func (wfe *WebFrontEndImpl) verifyOrder(order *core.Order) *acme.ProblemDetails {
|
|
// Lock the order for reading
|
|
order.RLock()
|
|
defer order.RUnlock()
|
|
|
|
// Shouldn't happen - defensive check
|
|
if order == nil {
|
|
return acme.InternalErrorProblem("Order is nil")
|
|
}
|
|
idents := order.Identifiers
|
|
if len(idents) == 0 {
|
|
return acme.MalformedProblem("Order did not specify any identifiers")
|
|
}
|
|
// Check that all of the identifiers in the new-order are DNS type
|
|
for _, ident := range idents {
|
|
if ident.Type != acme.IdentifierDNS {
|
|
return acme.MalformedProblem(fmt.Sprintf(
|
|
"Order included non-DNS type identifier: type %q, value %q",
|
|
ident.Type, ident.Value))
|
|
}
|
|
|
|
rawDomain := ident.Value
|
|
if rawDomain == "" {
|
|
return acme.MalformedProblem(fmt.Sprintf(
|
|
"Order included DNS identifier with empty value"))
|
|
}
|
|
|
|
for _, ch := range []byte(rawDomain) {
|
|
if !isDNSCharacter(ch) {
|
|
return acme.MalformedProblem(fmt.Sprintf(
|
|
"Order included DNS identifier with a value containing an illegal character: %q",
|
|
ch))
|
|
}
|
|
}
|
|
|
|
if len(rawDomain) > maxDNSIdentifierLength {
|
|
return acme.MalformedProblem(fmt.Sprintf(
|
|
"Order included DNS identifier that was longer than %d characters",
|
|
maxDNSIdentifierLength))
|
|
}
|
|
|
|
if ip := net.ParseIP(rawDomain); ip != nil {
|
|
return acme.MalformedProblem(fmt.Sprintf(
|
|
"Order included a DNS identifier with an IP address value: %q\n",
|
|
rawDomain))
|
|
}
|
|
|
|
if strings.HasSuffix(rawDomain, ".") {
|
|
return acme.MalformedProblem(fmt.Sprintf(
|
|
"Order included a DNS identifier with a value ending in a period: %q\n",
|
|
rawDomain))
|
|
}
|
|
|
|
// If there is a wildcard character in the ident value there should be only
|
|
// *one* instance
|
|
if strings.Count(rawDomain, "*") > 1 {
|
|
return acme.MalformedProblem(fmt.Sprintf(
|
|
"Order included DNS type identifier with illegal wildcard value: "+
|
|
"too many wildcards %q",
|
|
rawDomain))
|
|
} else if strings.Count(rawDomain, "*") == 1 {
|
|
// If there is one wildcard character it should be the only character in
|
|
// the leftmost label.
|
|
if !strings.HasPrefix(rawDomain, "*.") {
|
|
return acme.MalformedProblem(fmt.Sprintf(
|
|
"Order included DNS type identifier with illegal wildcard value: "+
|
|
"wildcard isn't leftmost prefix %q",
|
|
rawDomain))
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// makeAuthorizations populates an order with new authz's. The request parameter
|
|
// is required to make the authz URL's absolute based on the request host
|
|
func (wfe *WebFrontEndImpl) makeAuthorizations(order *core.Order, request *http.Request) error {
|
|
var auths []string
|
|
var authObs []*core.Authorization
|
|
|
|
// Lock the order for reading
|
|
order.RLock()
|
|
// Create one authz for each name in the order's parsed CSR
|
|
for _, name := range order.Names {
|
|
now := wfe.clk.Now().UTC()
|
|
expires := now.Add(pendingAuthzExpire)
|
|
ident := acme.Identifier{
|
|
Type: acme.IdentifierDNS,
|
|
Value: name,
|
|
}
|
|
authz := &core.Authorization{
|
|
ID: newToken(),
|
|
ExpiresDate: expires,
|
|
Order: order,
|
|
Authorization: acme.Authorization{
|
|
Status: acme.StatusPending,
|
|
Identifier: ident,
|
|
Expires: expires.UTC().Format(time.RFC3339),
|
|
},
|
|
}
|
|
authz.URL = wfe.relativeEndpoint(request, fmt.Sprintf("%s%s", authzPath, authz.ID))
|
|
// Create the challenges for this authz
|
|
err := wfe.makeChallenges(authz, request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Save the authorization in memory
|
|
count, err := wfe.db.AddAuthorization(authz)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wfe.log.Printf("There are now %d authorizations in the db\n", count)
|
|
authzURL := wfe.relativeEndpoint(request, fmt.Sprintf("%s%s", authzPath, authz.ID))
|
|
auths = append(auths, authzURL)
|
|
authObs = append(authObs, authz)
|
|
}
|
|
// Unlock the order from reading
|
|
order.RUnlock()
|
|
|
|
// Lock the order for writing & update the order's authorizations
|
|
order.Lock()
|
|
order.Authorizations = auths
|
|
order.AuthorizationObjects = authObs
|
|
order.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) makeChallenge(
|
|
chalType string,
|
|
authz *core.Authorization,
|
|
request *http.Request) (*core.Challenge, error) {
|
|
// Create a new challenge of the requested type
|
|
id := newToken()
|
|
chal := &core.Challenge{
|
|
ID: id,
|
|
Challenge: acme.Challenge{
|
|
Type: chalType,
|
|
Token: newToken(),
|
|
URL: wfe.relativeEndpoint(request, fmt.Sprintf("%s%s", challengePath, id)),
|
|
Status: acme.StatusPending,
|
|
},
|
|
Authz: authz,
|
|
}
|
|
|
|
// Add it to the in-memory database
|
|
_, err := wfe.db.AddChallenge(chal)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return chal, nil
|
|
}
|
|
|
|
// makeChallenges populates an authz with new challenges. The request parameter
|
|
// is required to make the challenge URL's absolute based on the request host
|
|
func (wfe *WebFrontEndImpl) makeChallenges(authz *core.Authorization, request *http.Request) error {
|
|
var chals []*core.Challenge
|
|
|
|
// Authorizations for a wildcard identifier only get a DNS-01 challenges to
|
|
// match Boulder/Let's Encrypt wildcard issuance policy
|
|
if strings.HasPrefix(authz.Identifier.Value, "*.") {
|
|
chal, err := wfe.makeChallenge(acme.ChallengeDNS01, authz, request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
chals = []*core.Challenge{chal}
|
|
} else {
|
|
// Non-wildcard authorizations get all of the enabled challenge types
|
|
enabledChallenges := []string{acme.ChallengeHTTP01, acme.ChallengeTLSALPN01, acme.ChallengeDNS01}
|
|
for _, chalType := range enabledChallenges {
|
|
chal, err := wfe.makeChallenge(chalType, authz, request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
chals = append(chals, chal)
|
|
}
|
|
}
|
|
|
|
// Lock the authorization for writing to update the challenges
|
|
authz.Lock()
|
|
authz.Challenges = nil
|
|
for _, c := range chals {
|
|
authz.Challenges = append(authz.Challenges, &c.Challenge)
|
|
}
|
|
authz.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// NewOrder creates a new Order request and populates its authorizations
|
|
func (wfe *WebFrontEndImpl) NewOrder(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
|
|
postData, prob := wfe.verifyPOST(request, wfe.lookupJWK)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
existingReg, prob := wfe.getAcctByKey(postData.jwk)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
// Unpack the order request body
|
|
var newOrder acme.Order
|
|
err := json.Unmarshal(postData.body, &newOrder)
|
|
if err != nil {
|
|
wfe.sendError(
|
|
acme.MalformedProblem("Error unmarshaling body JSON: "+err.Error()), response)
|
|
return
|
|
}
|
|
|
|
expires := time.Now().AddDate(0, 0, 1)
|
|
order := &core.Order{
|
|
ID: newToken(),
|
|
AccountID: existingReg.ID,
|
|
Order: acme.Order{
|
|
Status: acme.StatusPending,
|
|
Expires: expires.UTC().Format(time.RFC3339),
|
|
// Only the Identifiers, NotBefore and NotAfter from the submitted order
|
|
// are carried forward
|
|
Identifiers: newOrder.Identifiers,
|
|
NotBefore: newOrder.NotBefore,
|
|
NotAfter: newOrder.NotAfter,
|
|
},
|
|
ExpiresDate: expires,
|
|
}
|
|
|
|
// Verify the details of the order before creating authorizations
|
|
if err := wfe.verifyOrder(order); err != nil {
|
|
wfe.sendError(err, response)
|
|
return
|
|
}
|
|
|
|
// Collect all of the DNS identifier values up into a []string
|
|
var orderNames []string
|
|
for _, ident := range order.Identifiers {
|
|
orderNames = append(orderNames, ident.Value)
|
|
}
|
|
|
|
// Store the unique lower version of the names on the order object
|
|
order.Names = uniqueLowerNames(orderNames)
|
|
|
|
// Create the authorizations for the order
|
|
err = wfe.makeAuthorizations(order, request)
|
|
if err != nil {
|
|
wfe.sendError(
|
|
acme.InternalErrorProblem("Error creating authorizations for order"), response)
|
|
return
|
|
}
|
|
|
|
// Add the order to the in-memory DB
|
|
count, err := wfe.db.AddOrder(order)
|
|
if err != nil {
|
|
wfe.sendError(
|
|
acme.InternalErrorProblem("Error saving order"), response)
|
|
return
|
|
}
|
|
wfe.log.Printf("Added order %q to the db\n", order.ID)
|
|
wfe.log.Printf("There are now %d orders in the db\n", count)
|
|
|
|
// Get the stored order back from the DB. The memorystore will set the order's
|
|
// status for us.
|
|
storedOrder := wfe.db.GetOrderByID(order.ID)
|
|
|
|
orderURL := wfe.relativeEndpoint(request, fmt.Sprintf("%s%s", orderPath, storedOrder.ID))
|
|
response.Header().Add("Location", orderURL)
|
|
|
|
orderResp := wfe.orderForDisplay(storedOrder, request)
|
|
err = wfe.writeJSONResponse(response, http.StatusCreated, orderResp)
|
|
if err != nil {
|
|
wfe.sendError(acme.InternalErrorProblem("Error marshalling order"), response)
|
|
return
|
|
}
|
|
}
|
|
|
|
// orderForDisplay preps a *core.Order for display by populating some fields
|
|
// based on the http.request provided and returning a *acme.Order ready to be
|
|
// rendered to JSON for display to an API client.
|
|
func (wfe *WebFrontEndImpl) orderForDisplay(
|
|
order *core.Order,
|
|
request *http.Request) acme.Order {
|
|
// Lock the order for reading
|
|
order.RLock()
|
|
defer order.RUnlock()
|
|
|
|
// Copy the initial OrderRequest from the internal order object to mutate and
|
|
// use as the result.
|
|
result := order.Order
|
|
|
|
// Randomize the order of the order authorization URLs as well as the order's
|
|
// identifiers. ACME draft Section 7.4 "Applying for Certificate Issuance"
|
|
// says:
|
|
// Clients SHOULD NOT make any assumptions about the sort order of
|
|
// "identifiers" or "authorizations" elements in the returned order
|
|
// object.
|
|
rand.Shuffle(len(result.Authorizations), func(i, j int) {
|
|
result.Authorizations[i], result.Authorizations[j] = result.Authorizations[j], result.Authorizations[i]
|
|
})
|
|
rand.Shuffle(len(result.Identifiers), func(i, j int) {
|
|
result.Identifiers[i], result.Identifiers[j] = result.Identifiers[j], result.Identifiers[i]
|
|
})
|
|
|
|
// Populate a finalization URL for this order
|
|
result.Finalize = wfe.relativeEndpoint(request,
|
|
fmt.Sprintf("%s%s", orderFinalizePath, order.ID))
|
|
|
|
// If the order has a cert ID then set the certificate URL by constructing
|
|
// a relative path based on the HTTP request & the cert ID
|
|
if order.CertificateObject != nil {
|
|
result.Certificate = wfe.relativeEndpoint(
|
|
request,
|
|
certPath+order.CertificateObject.ID)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Order retrieves the details of an existing order
|
|
func (wfe *WebFrontEndImpl) Order(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
postData, prob := wfe.verifyPOST(request, wfe.lookupJWK)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
account, prob := wfe.validPOSTAsGET(postData)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
orderID := strings.TrimPrefix(request.URL.Path, orderPath)
|
|
order := wfe.db.GetOrderByID(orderID)
|
|
if order == nil {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
order.RLock()
|
|
orderAccountID := order.AccountID
|
|
defer order.RUnlock()
|
|
|
|
// If the request was authenticated we need to make sure that the
|
|
// authenticated account owns the order being requested
|
|
if account != nil {
|
|
if orderAccountID != account.ID {
|
|
response.WriteHeader(http.StatusForbidden)
|
|
wfe.sendError(acme.UnauthorizedProblem(
|
|
"Account that authenticated the request does not own the specified order"), response)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Prepare the order for display as JSON
|
|
orderReq := wfe.orderForDisplay(order, request)
|
|
err := wfe.writeJSONResponse(response, http.StatusOK, orderReq)
|
|
if err != nil {
|
|
wfe.sendError(acme.InternalErrorProblem("Error marshalling order"), response)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) FinalizeOrder(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
|
|
// Verify the POST request
|
|
postData, prob := wfe.verifyPOST(request, wfe.lookupJWK)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
// Find the account corresponding to the key that authenticated the POST request
|
|
existingAcct, prob := wfe.getAcctByKey(postData.jwk)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
// Find the order specified by the order ID
|
|
orderID := strings.TrimPrefix(request.URL.Path, orderFinalizePath)
|
|
existingOrder := wfe.db.GetOrderByID(orderID)
|
|
if existingOrder == nil {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
wfe.sendError(acme.NotFoundProblem(fmt.Sprintf(
|
|
"No order %q found for account ID %q", orderID, existingAcct.ID)), response)
|
|
return
|
|
}
|
|
|
|
// Lock the order for reading the properties we need to check
|
|
existingOrder.RLock()
|
|
orderAccountID := existingOrder.AccountID
|
|
orderStatus := existingOrder.Status
|
|
orderExpires := existingOrder.ExpiresDate
|
|
orderNames := existingOrder.Names
|
|
// And then immediately unlock it again - we don't defer() here because
|
|
// `maybeIssue` will also acquire a read lock and we call that before
|
|
// returning
|
|
existingOrder.RUnlock()
|
|
|
|
if orderAccountID != existingAcct.ID {
|
|
response.WriteHeader(http.StatusForbidden)
|
|
wfe.sendError(acme.UnauthorizedProblem(
|
|
"Account that authenticated the request does not own the specified order"), response)
|
|
return
|
|
}
|
|
|
|
// The existing order must be in a ready status to finalize it
|
|
if orderStatus != acme.StatusReady {
|
|
wfe.sendError(acme.OrderNotReadyProblem(fmt.Sprintf(
|
|
"Order's status (%q) was not %s", orderStatus, acme.StatusReady)), response)
|
|
return
|
|
}
|
|
|
|
// The existing order must not be expired
|
|
if orderExpires.Before(wfe.clk.Now()) {
|
|
wfe.sendError(acme.NotFoundProblem(fmt.Sprintf(
|
|
"Order %q expired %s", orderID, orderExpires)), response)
|
|
return
|
|
}
|
|
|
|
// The finalize POST body is expected to be the bytes from a base64 raw url
|
|
// encoded CSR
|
|
var finalizeMessage struct {
|
|
CSR string
|
|
}
|
|
err := json.Unmarshal(postData.body, &finalizeMessage)
|
|
if err != nil {
|
|
wfe.sendError(acme.MalformedProblem(fmt.Sprintf(
|
|
"Error unmarshaling finalize order request body: %s", err.Error())), response)
|
|
return
|
|
}
|
|
|
|
csrBytes, err := base64.RawURLEncoding.DecodeString(finalizeMessage.CSR)
|
|
if err != nil {
|
|
wfe.sendError(
|
|
acme.MalformedProblem("Error decoding Base64url-encoded CSR: "+err.Error()), response)
|
|
return
|
|
}
|
|
|
|
parsedCSR, err := x509.ParseCertificateRequest(csrBytes)
|
|
if err != nil {
|
|
wfe.sendError(
|
|
acme.MalformedProblem("Error parsing Base64url-encoded CSR: "+err.Error()), response)
|
|
return
|
|
}
|
|
|
|
// Check that the CSR has the same number of names as the initial order contained
|
|
csrNames := uniqueLowerNames(parsedCSR.DNSNames)
|
|
if len(csrNames) != len(orderNames) {
|
|
wfe.sendError(acme.UnauthorizedProblem(
|
|
"Order includes different number of names than CSR specifies"), response)
|
|
return
|
|
}
|
|
|
|
// Check that the CSR's names match the order names exactly
|
|
for i, name := range orderNames {
|
|
if name != csrNames[i] {
|
|
wfe.sendError(acme.UnauthorizedProblem(
|
|
fmt.Sprintf("CSR is missing Order domain %q", name)), response)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Lock and update the order with the parsed CSR and the began processing
|
|
// state.
|
|
existingOrder.Lock()
|
|
existingOrder.ParsedCSR = parsedCSR
|
|
existingOrder.BeganProcessing = true
|
|
existingOrder.Unlock()
|
|
|
|
// Ask the CA to complete the order in a separate goroutine.
|
|
wfe.log.Printf("Order %s is fully authorized. Processing finalization", orderID)
|
|
go wfe.ca.CompleteOrder(existingOrder)
|
|
|
|
// Set the existingOrder to processing before displaying to the user
|
|
existingOrder.Status = acme.StatusProcessing
|
|
|
|
// Prepare the order for display as JSON
|
|
orderReq := wfe.orderForDisplay(existingOrder, request)
|
|
orderURL := wfe.relativeEndpoint(request, fmt.Sprintf("%s%s", orderPath, existingOrder.ID))
|
|
response.Header().Add("Location", orderURL)
|
|
err = wfe.writeJSONResponse(response, http.StatusOK, orderReq)
|
|
if err != nil {
|
|
wfe.sendError(acme.InternalErrorProblem("Error marshalling order"), response)
|
|
return
|
|
}
|
|
}
|
|
|
|
// prepAuthorizationForDisplay prepares the provided acme.Authorization for
|
|
// display to an ACME client.
|
|
func prepAuthorizationForDisplay(authz acme.Authorization) acme.Authorization {
|
|
// Copy the authz to mutate and return
|
|
result := authz
|
|
|
|
identVal := result.Identifier.Value
|
|
// If the authorization identifier has a wildcard in the value, remove it and
|
|
// set the Wildcard field to true
|
|
if strings.HasPrefix(identVal, "*.") {
|
|
result.Identifier.Value = strings.TrimPrefix(identVal, "*.")
|
|
result.Wildcard = true
|
|
}
|
|
|
|
// If the authz isn't pending then we need to filter the challenges displayed
|
|
// to only those that were used to make the authz valid || invalid.
|
|
if result.Status != acme.StatusPending {
|
|
var chals []*acme.Challenge
|
|
// Scan each of the authz's challenges
|
|
for _, c := range result.Challenges {
|
|
// Include any that have an associated error, or that are status valid
|
|
if c.Error != nil || c.Status == acme.StatusValid {
|
|
chals = append(chals, c)
|
|
}
|
|
}
|
|
// Replace the authz's challenges with the filtered set
|
|
result.Challenges = chals
|
|
}
|
|
|
|
// Randomize the order of the challenges in the returned authorization.
|
|
// Clients should not make any assumptions about the sort order.
|
|
rand.Shuffle(len(result.Challenges), func(i, j int) {
|
|
result.Challenges[i], result.Challenges[j] = result.Challenges[j], result.Challenges[i]
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) Authz(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
// There are two types of requests we might get:
|
|
// A) a POST to update the authorization
|
|
// B) a POST-as-GET to get the authorization
|
|
postData, prob := wfe.verifyPOST(request, wfe.lookupJWK)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
authzID := strings.TrimPrefix(request.URL.Path, authzPath)
|
|
authz := wfe.db.GetAuthorizationByID(authzID)
|
|
if authz == nil {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// If the postData is not a POST-as-GET, treat this as case A) and update
|
|
// the authorization based on the postData
|
|
if !postData.postAsGet {
|
|
existingAcct, prob := wfe.getAcctByKey(postData.jwk)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
if authz.Order.AccountID != existingAcct.ID {
|
|
wfe.sendError(acme.UnauthorizedProblem(
|
|
"Account does not own authorization"), response)
|
|
return
|
|
}
|
|
|
|
var deactivateRequest struct {
|
|
Status string
|
|
}
|
|
err := json.Unmarshal(postData.body, &deactivateRequest)
|
|
if err != nil {
|
|
wfe.sendError(acme.MalformedProblem(
|
|
fmt.Sprintf("Malformed authorization update: %s",
|
|
err.Error())), response)
|
|
return
|
|
}
|
|
|
|
if deactivateRequest.Status != "deactivated" {
|
|
wfe.sendError(acme.MalformedProblem(
|
|
fmt.Sprintf("Malformed authorization update, status must be \"deactivated\" not %q",
|
|
deactivateRequest.Status)), response)
|
|
return
|
|
}
|
|
authz.Status = acme.StatusDeactivated
|
|
} else {
|
|
// Otherwise this was a POST-as-GET request and we need to verify it
|
|
// accordingly and ensure the authorized account owns the authorization
|
|
// being fetched.
|
|
account, prob := wfe.validPOSTAsGET(postData)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
if authz.Order.AccountID != account.ID {
|
|
response.WriteHeader(http.StatusForbidden)
|
|
wfe.sendError(acme.UnauthorizedProblem(
|
|
"Account authorizing the request is not the owner of the authorization"),
|
|
response)
|
|
return
|
|
}
|
|
}
|
|
|
|
err := wfe.writeJSONResponse(
|
|
response,
|
|
http.StatusOK,
|
|
prepAuthorizationForDisplay(authz.Authorization))
|
|
if err != nil {
|
|
wfe.sendError(acme.InternalErrorProblem("Error marshalling authz"), response)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) Challenge(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
// There are two possibilities:
|
|
// A) request is a POST to begin a challenge
|
|
// B) request is a POST-as-GET to poll a challenge
|
|
postData, prob := wfe.verifyPOST(request, wfe.lookupJWK)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
chalID := strings.TrimPrefix(request.URL.Path, challengePath)
|
|
chal := wfe.db.GetChallengeByID(chalID)
|
|
if chal == nil {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// If the post isn't a POST-as-GET its case A)
|
|
var account *core.Account
|
|
if !postData.postAsGet {
|
|
wfe.updateChallenge(postData, response, request)
|
|
return
|
|
} else {
|
|
// Otherwise it is case B)
|
|
account, prob = wfe.validPOSTAsGET(postData)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Lock the challenge for reading in order to write the response
|
|
chal.RLock()
|
|
defer chal.RUnlock()
|
|
|
|
if chal.Authz.Order.AccountID != account.ID {
|
|
response.WriteHeader(http.StatusUnauthorized)
|
|
wfe.sendError(acme.UnauthorizedProblem(
|
|
"Account authenticating request is not the owner of the challenge"), response)
|
|
return
|
|
}
|
|
|
|
err := wfe.writeJSONResponse(response, http.StatusOK, chal.Challenge)
|
|
if err != nil {
|
|
wfe.sendError(acme.InternalErrorProblem("Error marshalling challenge"), response)
|
|
return
|
|
}
|
|
}
|
|
|
|
// getAcctByKey finds a account by key or returns a problem pointer if an
|
|
// existing account can't be found or the key is invalid.
|
|
func (wfe *WebFrontEndImpl) getAcctByKey(key crypto.PublicKey) (*core.Account, *acme.ProblemDetails) {
|
|
// Find the existing account object for that key
|
|
existingAcct, err := wfe.db.GetAccountByKey(key)
|
|
if err != nil {
|
|
return nil, acme.AccountDoesNotExistProblem("Error while retrieving key ID from public key")
|
|
}
|
|
if existingAcct == nil {
|
|
return nil, acme.AccountDoesNotExistProblem(
|
|
"URL in JWS 'kid' field does not correspond to an account")
|
|
}
|
|
|
|
if existingAcct.Status == acme.StatusDeactivated {
|
|
return nil, acme.UnauthorizedProblem("Account has been deactivated")
|
|
}
|
|
return existingAcct, nil
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) validateChallengeUpdate(
|
|
chal *core.Challenge) (*core.Authorization, *acme.ProblemDetails) {
|
|
// Lock the challenge for reading to do validation
|
|
chal.RLock()
|
|
defer chal.RUnlock()
|
|
|
|
// Check that the existing challenge is Pending
|
|
if chal.Status != acme.StatusPending {
|
|
return nil, acme.MalformedProblem(
|
|
fmt.Sprintf("Cannot update challenge with status %s, only status %s",
|
|
chal.Status, acme.StatusPending))
|
|
}
|
|
|
|
return chal.Authz, nil
|
|
}
|
|
|
|
// validateAuthzForChallenge checks an authz is:
|
|
// 1) for a supported identifier type
|
|
// 2) not expired
|
|
// 3) associated to an order
|
|
// The associated order is returned when no problems are found to avoid needing
|
|
// another RLock() for the caller to get the order pointer later.
|
|
func (wfe *WebFrontEndImpl) validateAuthzForChallenge(authz *core.Authorization) (*core.Order, *acme.ProblemDetails) {
|
|
// Lock the authz for reading
|
|
authz.RLock()
|
|
defer authz.RUnlock()
|
|
|
|
ident := authz.Identifier
|
|
if ident.Type != acme.IdentifierDNS {
|
|
return nil, acme.MalformedProblem(
|
|
fmt.Sprintf("Authorization identifier was type %s, only %s is supported",
|
|
ident.Type, acme.IdentifierDNS))
|
|
}
|
|
|
|
now := wfe.clk.Now()
|
|
if now.After(authz.ExpiresDate) {
|
|
return nil, acme.MalformedProblem(
|
|
fmt.Sprintf("Authorization expired %s",
|
|
authz.ExpiresDate.Format(time.RFC3339)))
|
|
}
|
|
|
|
existingOrder := authz.Order
|
|
if existingOrder == nil {
|
|
return nil, acme.InternalErrorProblem("authz missing associated order")
|
|
}
|
|
|
|
return existingOrder, nil
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) updateChallenge(
|
|
postData *authenticatedPOST,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
|
|
existingAcct, prob := wfe.getAcctByKey(postData.jwk)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
var chalResp struct {
|
|
KeyAuthorization *string
|
|
}
|
|
err := json.Unmarshal(postData.body, &chalResp)
|
|
if err != nil {
|
|
wfe.sendError(
|
|
acme.MalformedProblem("Error unmarshaling body JSON"), response)
|
|
return
|
|
}
|
|
|
|
// Historically challenges were updated by POSTing a KeyAuthorization. This is
|
|
// unnecessary, the server can calculate this itself. We could ignore this if
|
|
// sent (and that's what Boulder will do) but for Pebble we'd like to offer
|
|
// a way to be more aggressive about pushing clients implementations in the
|
|
// right direction, so we treat this as a malformed request.
|
|
if chalResp.KeyAuthorization != nil {
|
|
wfe.sendError(
|
|
acme.MalformedProblem(
|
|
"Challenge response body contained legacy KeyAuthorization field, "+
|
|
"POST body should be `{}`"), response)
|
|
return
|
|
}
|
|
|
|
chalID := strings.TrimPrefix(request.URL.Path, challengePath)
|
|
existingChal := wfe.db.GetChallengeByID(chalID)
|
|
if existingChal == nil {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
authz, prob := wfe.validateChallengeUpdate(existingChal)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
if authz == nil {
|
|
wfe.sendError(
|
|
acme.InternalErrorProblem("challenge missing associated authz"), response)
|
|
return
|
|
}
|
|
|
|
if authz.Order.AccountID != existingAcct.ID {
|
|
response.WriteHeader(http.StatusUnauthorized)
|
|
wfe.sendError(acme.UnauthorizedProblem(
|
|
"Account authenticating request is not the owner of the challenge"), response)
|
|
return
|
|
}
|
|
|
|
existingOrder, prob := wfe.validateAuthzForChallenge(authz)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
// Lock the order for reading to check the expiry date
|
|
existingOrder.RLock()
|
|
now := wfe.clk.Now()
|
|
if now.After(existingOrder.ExpiresDate) {
|
|
wfe.sendError(
|
|
acme.MalformedProblem(fmt.Sprintf("order expired %s",
|
|
existingOrder.ExpiresDate.Format(time.RFC3339))), response)
|
|
return
|
|
}
|
|
existingOrder.RUnlock()
|
|
|
|
// Lock the authorization to get the identifier value
|
|
authz.RLock()
|
|
ident := authz.Identifier.Value
|
|
authz.RUnlock()
|
|
|
|
// If the identifier value is for a wildcard domain then strip the wildcard
|
|
// prefix before dispatching the validation to ensure the base domain is
|
|
// validated.
|
|
if strings.HasPrefix(ident, "*.") {
|
|
ident = strings.TrimPrefix(ident, "*.")
|
|
}
|
|
|
|
// Submit a validation job to the VA, this will be processed asynchronously
|
|
wfe.va.ValidateChallenge(ident, existingChal, existingAcct)
|
|
|
|
// Lock the challenge for reading in order to write the response
|
|
existingChal.RLock()
|
|
defer existingChal.RUnlock()
|
|
response.Header().Add("Link", link(existingChal.Authz.URL, "up"))
|
|
err = wfe.writeJSONResponse(response, http.StatusOK, existingChal.Challenge)
|
|
if err != nil {
|
|
wfe.sendError(acme.InternalErrorProblem("Error marshalling challenge"), response)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) Certificate(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
postData, prob := wfe.verifyPOST(request, wfe.lookupJWK)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
acct, prob := wfe.validPOSTAsGET(postData)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
serial := strings.TrimPrefix(request.URL.Path, certPath)
|
|
cert := wfe.db.GetCertificateByID(serial)
|
|
if cert == nil {
|
|
response.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if cert.AccountID != acct.ID {
|
|
response.WriteHeader(http.StatusUnauthorized)
|
|
wfe.sendError(acme.UnauthorizedProblem(
|
|
"Account authenticating request does not own certificate"), response)
|
|
return
|
|
}
|
|
|
|
response.Header().Set("Content-Type", "application/pem-certificate-chain; charset=utf-8")
|
|
response.WriteHeader(http.StatusOK)
|
|
_, _ = response.Write(cert.Chain())
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) writeJSONResponse(response http.ResponseWriter, 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; charset=utf-8")
|
|
response.WriteHeader(status)
|
|
|
|
// Don't worry about returning an error from Write() because the caller will
|
|
// never handle it.
|
|
_, _ = response.Write(jsonReply)
|
|
return nil
|
|
}
|
|
|
|
func addNoCacheHeader(response http.ResponseWriter) {
|
|
response.Header().Add("Cache-Control", "public, max-age=0, no-cache")
|
|
}
|
|
|
|
func marshalIndent(v interface{}) ([]byte, error) {
|
|
return json.MarshalIndent(v, "", " ")
|
|
}
|
|
|
|
func link(url, relation string) string {
|
|
return fmt.Sprintf("<%s>;rel=\"%s\"", url, relation)
|
|
}
|
|
|
|
// uniqueLowerNames returns the set of all unique names in the input after all
|
|
// of them are lowercased. The returned names will be in their lowercased form
|
|
// and sorted alphabetically. See Boulder `core/util.go UniqueLowerNames`.
|
|
func uniqueLowerNames(names []string) []string {
|
|
nameMap := make(map[string]int, len(names))
|
|
for _, name := range names {
|
|
nameMap[strings.ToLower(name)] = 1
|
|
}
|
|
unique := make([]string, 0, len(nameMap))
|
|
for name := range nameMap {
|
|
unique = append(unique, name)
|
|
}
|
|
sort.Strings(unique)
|
|
return unique
|
|
}
|
|
|
|
// RevokeCert revokes an ACME certificate.
|
|
// It currently only implements one method of ACME revocation:
|
|
// Signing the revocation request by signing it with the certificate
|
|
// to be revoked's private key and embedding the certificate
|
|
// to be revoked's public key as a JWK in the JWS.
|
|
//
|
|
// Pebble's idea of certificate revocation is to forget the certificate exists.
|
|
// This method does not percolate to a CRL or an OCSP response.
|
|
func (wfe *WebFrontEndImpl) RevokeCert(
|
|
ctx context.Context,
|
|
response http.ResponseWriter,
|
|
request *http.Request) {
|
|
|
|
// The ACME specification handles the verification of revocation requests
|
|
// differently from other endpoints that always use one JWS authentication
|
|
// method. 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
|
|
|
|
bodyBytes, err := ioutil.ReadAll(request.Body)
|
|
if err != nil {
|
|
wfe.sendError(
|
|
acme.InternalErrorProblem("unable to read request body"), response)
|
|
return
|
|
}
|
|
body := string(bodyBytes)
|
|
|
|
parsedJWS, err := wfe.parseJWS(body)
|
|
if err != nil {
|
|
wfe.sendError(
|
|
acme.MalformedProblem(err.Error()), response)
|
|
return
|
|
}
|
|
|
|
if prob := wfe.validPOST(request); prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
// Determine the authentication type for this request
|
|
authType, prob := checkJWSAuthType(parsedJWS)
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
// Handle the revocation request according to how it is authenticated, or if
|
|
// the authentication type is unknown, error immediately
|
|
if authType == embeddedKeyID {
|
|
prob = wfe.revokeCertByKeyID(parsedJWS, request)
|
|
} else if authType == embeddedJWK {
|
|
prob = wfe.revokeCertByJWK(parsedJWS, request)
|
|
} else {
|
|
prob = acme.MalformedProblem("Malformed JWS, no KeyID or embedded JWK")
|
|
}
|
|
if prob != nil {
|
|
wfe.sendError(prob, response)
|
|
return
|
|
}
|
|
|
|
response.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) revokeCertByKeyID(
|
|
jws *jose.JSONWebSignature,
|
|
request *http.Request) *acme.ProblemDetails {
|
|
|
|
pubKey, prob := wfe.lookupJWK(request, jws)
|
|
if prob != nil {
|
|
return prob
|
|
}
|
|
|
|
postData, prob := wfe.verifyJWS(pubKey, jws, request)
|
|
if prob != nil {
|
|
return prob
|
|
}
|
|
|
|
existingAcct, err := wfe.db.GetAccountByKey(postData.jwk)
|
|
if err != nil {
|
|
return acme.MalformedProblem(fmt.Sprintf("Cannot obtain key ID from public key (%s)", err.Error()))
|
|
}
|
|
if existingAcct == nil {
|
|
return acme.UnauthorizedProblem("No account found corresponding to public key authenticating this request")
|
|
}
|
|
|
|
// An account is only authorized to revoke its own certificates presently.
|
|
// TODO(@cpu): Allow an account to revoke another account's certificate if
|
|
// the revoker account has valid authorizations for all of the names in the
|
|
// to-be-revoked certificate.
|
|
authorizedToRevoke := func(cert *core.Certificate) *acme.ProblemDetails {
|
|
if cert.AccountID == existingAcct.ID {
|
|
return nil
|
|
}
|
|
return acme.UnauthorizedProblem(
|
|
fmt.Sprintf(
|
|
"The certificate being revoked is not associated with account %q",
|
|
existingAcct.ID))
|
|
}
|
|
return wfe.processRevocation(postData.body, authorizedToRevoke)
|
|
}
|
|
|
|
func (wfe *WebFrontEndImpl) revokeCertByJWK(
|
|
jws *jose.JSONWebSignature,
|
|
request *http.Request) *acme.ProblemDetails {
|
|
|
|
var requestKey *jose.JSONWebKey
|
|
pubKey, prob := wfe.extractJWK(request, jws)
|
|
if prob != nil {
|
|
return prob
|
|
}
|
|
postData, prob := wfe.verifyJWS(pubKey, jws, request)
|
|
if prob != nil {
|
|
return prob
|
|
}
|
|
requestKey = postData.jwk
|
|
|
|
// 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
|
|
authorizedToRevoke := func(cert *core.Certificate) *acme.ProblemDetails {
|
|
if keyDigestEquals(requestKey, cert.Cert.PublicKey) {
|
|
return nil
|
|
}
|
|
return acme.UnauthorizedProblem(
|
|
"JWK embedded in revocation request must be the same public key as the cert to be revoked")
|
|
}
|
|
return wfe.processRevocation(postData.body, authorizedToRevoke)
|
|
}
|
|
|
|
// authorizedToRevokeCert is a callback function that can be used to validate if
|
|
// a given requester is authorized to revoke the certificate parsed out of the
|
|
// revocation request. If the requester is not authorized to revoke the
|
|
// certificate a problem is returned. It is expected to be a closure containing
|
|
// additional state (an account ID or key) that will be used to make the
|
|
// decision.
|
|
type authorizedToRevokeCert func(*core.Certificate) *acme.ProblemDetails
|
|
|
|
func (wfe *WebFrontEndImpl) processRevocation(
|
|
jwsBody []byte,
|
|
authorizedToRevoke authorizedToRevokeCert) *acme.ProblemDetails {
|
|
|
|
// revokeCertReq is the ACME certificate information submitted by the client
|
|
var revokeCertReq struct {
|
|
Certificate string `json:"certificate"`
|
|
Reason *uint `json:"reason,omitempty"`
|
|
}
|
|
err := json.Unmarshal(jwsBody, &revokeCertReq)
|
|
if err != nil {
|
|
return acme.MalformedProblem("Error unmarshaling certificate revocation JSON body")
|
|
}
|
|
|
|
if revokeCertReq.Reason != nil {
|
|
r := *revokeCertReq.Reason
|
|
if r == unusedRevocationReason || r > aACompromiseRevocationReason {
|
|
return acme.BadRevocationReasonProblem(fmt.Sprintf("Invalid revocation reason: %d", r))
|
|
}
|
|
}
|
|
|
|
derBytes, err := base64.RawURLEncoding.DecodeString(revokeCertReq.Certificate)
|
|
if err != nil {
|
|
return acme.MalformedProblem("Error decoding Base64url-encoded DER: " + err.Error())
|
|
}
|
|
|
|
cert := wfe.db.GetCertificateByDER(derBytes)
|
|
if cert == nil {
|
|
cert := wfe.db.GetRevokedCertificateByDER(derBytes)
|
|
if cert != nil {
|
|
return acme.AlreadyRevokedProblem(
|
|
"Certificate has already been revoked.")
|
|
} else {
|
|
return acme.MalformedProblem(
|
|
"Unable to find specified certificate.")
|
|
}
|
|
}
|
|
|
|
if prob := authorizedToRevoke(cert); prob != nil {
|
|
return prob
|
|
}
|
|
|
|
wfe.db.RevokeCertificate(cert)
|
|
return nil
|
|
}
|