package api import ( "context" "encoding/json" "net/http" "github.com/go-chi/chi" "github.com/smallstep/certificates/acme" "github.com/smallstep/certificates/api" "github.com/smallstep/certificates/logging" "go.step.sm/crypto/jose" ) // ExternalAccountBinding represents the ACME externalAccountBinding JWS type ExternalAccountBinding struct { Protected string `json:"protected"` Payload string `json:"payload"` Sig string `json:"signature"` } // NewAccountRequest represents the payload for a new account request. type NewAccountRequest struct { Contact []string `json:"contact"` OnlyReturnExisting bool `json:"onlyReturnExisting"` TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"` ExternalAccountBinding *ExternalAccountBinding `json:"externalAccountBinding,omitempty"` } func validateContacts(cs []string) error { for _, c := range cs { if c == "" { return acme.NewError(acme.ErrorMalformedType, "contact cannot be empty string") } } return nil } // Validate validates a new-account request body. func (n *NewAccountRequest) Validate() error { if n.OnlyReturnExisting && len(n.Contact) > 0 { return acme.NewError(acme.ErrorMalformedType, "incompatible input; onlyReturnExisting must be alone") } return validateContacts(n.Contact) } // UpdateAccountRequest represents an update-account request. type UpdateAccountRequest struct { Contact []string `json:"contact"` Status acme.Status `json:"status"` } // Validate validates a update-account request body. func (u *UpdateAccountRequest) Validate() error { switch { case len(u.Status) > 0 && len(u.Contact) > 0: return acme.NewError(acme.ErrorMalformedType, "incompatible input; contact and "+ "status updates are mutually exclusive") case len(u.Contact) > 0: if err := validateContacts(u.Contact); err != nil { return err } return nil case len(u.Status) > 0: if u.Status != acme.StatusDeactivated { return acme.NewError(acme.ErrorMalformedType, "cannot update account "+ "status to %s, only deactivated", u.Status) } return nil default: // According to the ACME spec (https://tools.ietf.org/html/rfc8555#section-7.3.2) // accountUpdate should ignore any fields not recognized by the server. return nil } } // NewAccount is the handler resource for creating new ACME accounts. func (h *Handler) NewAccount(w http.ResponseWriter, r *http.Request) { ctx := r.Context() payload, err := payloadFromContext(ctx) if err != nil { api.WriteError(w, err) return } var nar NewAccountRequest if err := json.Unmarshal(payload.value, &nar); err != nil { api.WriteError(w, acme.WrapError(acme.ErrorMalformedType, err, "failed to unmarshal new-account request payload")) return } if err := nar.Validate(); err != nil { api.WriteError(w, err) return } prov, err := acmeProvisionerFromContext(ctx) if err != nil { api.WriteError(w, err) return } httpStatus := http.StatusCreated acc, err := accountFromContext(ctx) if err != nil { acmeErr, ok := err.(*acme.Error) if !ok || acmeErr.Status != http.StatusBadRequest { // Something went wrong ... api.WriteError(w, err) return } // Account does not exist // if nar.OnlyReturnExisting { api.WriteError(w, acme.NewError(acme.ErrorAccountDoesNotExistType, "account does not exist")) return } jwk, err := jwkFromContext(ctx) if err != nil { api.WriteError(w, err) return } eak, err := h.validateExternalAccountBinding(ctx, &nar) if err != nil { api.WriteError(w, err) return } acc = &acme.Account{ Key: jwk, Contact: nar.Contact, Status: acme.StatusValid, } if err := h.db.CreateAccount(ctx, acc); err != nil { api.WriteError(w, acme.WrapErrorISE(err, "error creating account")) return } if eak != nil { // means that we have a (valid) External Account Binding key that should be bound, updated and sent in the response err := eak.BindTo(acc) if err != nil { api.WriteError(w, err) return } if err := h.db.UpdateExternalAccountKey(ctx, prov.Name, eak); err != nil { api.WriteError(w, acme.WrapErrorISE(err, "error updating external account binding key")) return } acc.ExternalAccountBinding = nar.ExternalAccountBinding } } else { // Account exists httpStatus = http.StatusOK } h.linker.LinkAccount(ctx, acc) w.Header().Set("Location", h.linker.GetLink(r.Context(), AccountLinkType, acc.ID)) api.JSONStatus(w, acc, httpStatus) } // GetOrUpdateAccount is the api for updating an ACME account. func (h *Handler) GetOrUpdateAccount(w http.ResponseWriter, r *http.Request) { ctx := r.Context() acc, err := accountFromContext(ctx) if err != nil { api.WriteError(w, err) return } payload, err := payloadFromContext(ctx) if err != nil { api.WriteError(w, err) return } // If PostAsGet just respond with the account, otherwise process like a // normal Post request. if !payload.isPostAsGet { var uar UpdateAccountRequest if err := json.Unmarshal(payload.value, &uar); err != nil { api.WriteError(w, acme.WrapError(acme.ErrorMalformedType, err, "failed to unmarshal new-account request payload")) return } if err := uar.Validate(); err != nil { api.WriteError(w, err) return } if len(uar.Status) > 0 || len(uar.Contact) > 0 { if len(uar.Status) > 0 { acc.Status = uar.Status } else if len(uar.Contact) > 0 { acc.Contact = uar.Contact } if err := h.db.UpdateAccount(ctx, acc); err != nil { api.WriteError(w, acme.WrapErrorISE(err, "error updating account")) return } } } h.linker.LinkAccount(ctx, acc) w.Header().Set("Location", h.linker.GetLink(ctx, AccountLinkType, acc.ID)) api.JSON(w, acc) } func logOrdersByAccount(w http.ResponseWriter, oids []string) { if rl, ok := w.(logging.ResponseLogger); ok { m := map[string]interface{}{ "orders": oids, } rl.WithFields(m) } } // GetOrdersByAccountID ACME api for retrieving the list of order urls belonging to an account. func (h *Handler) GetOrdersByAccountID(w http.ResponseWriter, r *http.Request) { ctx := r.Context() acc, err := accountFromContext(ctx) if err != nil { api.WriteError(w, err) return } accID := chi.URLParam(r, "accID") if acc.ID != accID { api.WriteError(w, acme.NewError(acme.ErrorUnauthorizedType, "account ID '%s' does not match url param '%s'", acc.ID, accID)) return } orders, err := h.db.GetOrdersByAccountID(ctx, acc.ID) if err != nil { api.WriteError(w, err) return } h.linker.LinkOrdersByAccountID(ctx, orders) api.JSON(w, orders) logOrdersByAccount(w, orders) } // validateExternalAccountBinding validates the externalAccountBinding property in a call to new-account. func (h *Handler) validateExternalAccountBinding(ctx context.Context, nar *NewAccountRequest) (*acme.ExternalAccountKey, error) { acmeProv, err := acmeProvisionerFromContext(ctx) if err != nil { return nil, acme.WrapErrorISE(err, "could not load ACME provisioner from context") } if !acmeProv.RequireEAB { return nil, nil } if nar.ExternalAccountBinding == nil { return nil, acme.NewError(acme.ErrorExternalAccountRequiredType, "no external account binding provided") } eabJSONBytes, err := json.Marshal(nar.ExternalAccountBinding) if err != nil { return nil, acme.WrapErrorISE(err, "error marshaling externalAccountBinding into bytes") } eabJWS, err := jose.ParseJWS(string(eabJSONBytes)) if err != nil { return nil, acme.WrapErrorISE(err, "error parsing externalAccountBinding jws") } // TODO(hs): implement strategy pattern to allow for different ways of verification (i.e. webhook call) based on configuration? keyID, acmeErr := validateEABJWS(ctx, eabJWS) if acmeErr != nil { return nil, acmeErr } externalAccountKey, err := h.db.GetExternalAccountKey(ctx, acmeProv.Name, keyID) if err != nil { if _, ok := err.(*acme.Error); ok { return nil, acme.WrapError(acme.ErrorUnauthorizedType, err, "the field 'kid' references an unknown key") } return nil, acme.WrapErrorISE(err, "error retrieving external account key") } if externalAccountKey.AlreadyBound() { return nil, acme.NewError(acme.ErrorUnauthorizedType, "external account binding key with id '%s' was already bound to account '%s' on %s", keyID, externalAccountKey.AccountID, externalAccountKey.BoundAt) } payload, err := eabJWS.Verify(externalAccountKey.KeyBytes) if err != nil { return nil, acme.WrapErrorISE(err, "error verifying externalAccountBinding signature") } jwk, err := jwkFromContext(ctx) if err != nil { return nil, err } var payloadJWK *jose.JSONWebKey if err = json.Unmarshal(payload, &payloadJWK); err != nil { return nil, acme.WrapError(acme.ErrorMalformedType, err, "error unmarshaling payload into jwk") } if !keysAreEqual(jwk, payloadJWK) { return nil, acme.NewError(acme.ErrorUnauthorizedType, "keys in jws and eab payload do not match") } return externalAccountKey, nil } // keysAreEqual performs an equality check on two JWKs by comparing // the (base64 encoding) of the Key IDs. func keysAreEqual(x, y *jose.JSONWebKey) bool { if x == nil || y == nil { return false } digestX, errX := acme.KeyToID(x) digestY, errY := acme.KeyToID(y) if errX != nil || errY != nil { return false } return digestX == digestY } // validateEABJWS verifies the contents of the External Account Binding JWS. // The protected header of the JWS MUST meet the following criteria: // o The "alg" field MUST indicate a MAC-based algorithm // o The "kid" field MUST contain the key identifier provided by the CA // o The "nonce" field MUST NOT be present // o The "url" field MUST be set to the same value as the outer JWS func validateEABJWS(ctx context.Context, jws *jose.JSONWebSignature) (string, *acme.Error) { if jws == nil { return "", acme.NewErrorISE("no JWS provided") } if len(jws.Signatures) != 1 { return "", acme.NewError(acme.ErrorMalformedType, "JWS must have one signature") } header := jws.Signatures[0].Protected algorithm := header.Algorithm keyID := header.KeyID nonce := header.Nonce if !(algorithm == jose.HS256 || algorithm == jose.HS384 || algorithm == jose.HS512) { return "", acme.NewError(acme.ErrorMalformedType, "'alg' field set to invalid algorithm '%s'", algorithm) } if keyID == "" { return "", acme.NewError(acme.ErrorMalformedType, "'kid' field is required") } if nonce != "" { return "", acme.NewError(acme.ErrorMalformedType, "'nonce' must not be present") } jwsURL, ok := header.ExtraHeaders["url"] if !ok { return "", acme.NewError(acme.ErrorMalformedType, "'url' field is required") } outerJWS, err := jwsFromContext(ctx) if err != nil { return "", acme.WrapErrorISE(err, "could not retrieve outer JWS from context") } if len(outerJWS.Signatures) != 1 { return "", acme.NewError(acme.ErrorMalformedType, "outer JWS must have one signature") } outerJWSURL, ok := outerJWS.Signatures[0].Protected.ExtraHeaders["url"] if !ok { return "", acme.NewError(acme.ErrorMalformedType, "'url' field must be set in outer JWS") } if jwsURL != outerJWSURL { return "", acme.NewError(acme.ErrorMalformedType, "'url' field is not the same value as the outer JWS") } return keyID, nil }