382 lines
11 KiB
Go
382 lines
11 KiB
Go
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
|
|
}
|