diff --git a/acme/account.go b/acme/account.go index 197a3400..3fc0d451 100644 --- a/acme/account.go +++ b/acme/account.go @@ -4,6 +4,7 @@ import ( "crypto" "encoding/base64" "encoding/json" + "time" "go.step.sm/crypto/jose" ) @@ -40,3 +41,12 @@ func KeyToID(jwk *jose.JSONWebKey) (string, error) { } return base64.RawURLEncoding.EncodeToString(kid), nil } + +type ExternalAccountKey struct { + ID string `json:"id"` + Name string `json:"name"` + AccountID string `json:"-"` + KeyBytes []byte `json:"-"` + CreatedAt time.Time `json:"createdAt"` + BoundAt time.Time `json:"boundAt,omitempty"` +} diff --git a/acme/api/account.go b/acme/api/account.go index b733c679..17d7bb52 100644 --- a/acme/api/account.go +++ b/acme/api/account.go @@ -1,20 +1,26 @@ package api import ( + "bytes" + "context" "encoding/json" "net/http" "github.com/go-chi/chi" "github.com/smallstep/certificates/acme" "github.com/smallstep/certificates/api" + "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/logging" + + squarejose "gopkg.in/square/go-jose.v2" ) // NewAccountRequest represents the payload for a new account request. type NewAccountRequest struct { - Contact []string `json:"contact"` - OnlyReturnExisting bool `json:"onlyReturnExisting"` - TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"` + Contact []string `json:"contact"` + OnlyReturnExisting bool `json:"onlyReturnExisting"` + TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"` + ExternalAccountBinding interface{} `json:"externalAccountBinding,omitempty"` } func validateContacts(cs []string) error { @@ -83,6 +89,14 @@ func (h *Handler) NewAccount(w http.ResponseWriter, r *http.Request) { return } + if err := h.validateExternalAccountBinding(ctx, &nar); err != nil { + api.WriteError(w, err) + return + } + + // TODO: link account to the key when created; mark boundat timestamp + // TODO: return the externalAccountBinding field (should contain same info) if new account created + httpStatus := http.StatusCreated acc, err := accountFromContext(r.Context()) if err != nil { @@ -205,3 +219,66 @@ func (h *Handler) GetOrdersByAccountID(w http.ResponseWriter, r *http.Request) { 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) error { + + prov, err := provisionerFromContext(ctx) + if err != nil { + return err + } + + acmeProv, ok := prov.(*provisioner.ACME) // TODO: rewrite into providing configuration via function on acme.Provisioner + if !ok || acmeProv == nil { + return acme.NewErrorISE("provisioner in context is not an ACME provisioner") + } + + shouldSkipAccountBindingValidation := !acmeProv.RequireEAB + if shouldSkipAccountBindingValidation { + return nil + } + + if nar.ExternalAccountBinding == nil { + return acme.NewError(acme.ErrorExternalAccountRequiredType, "no external account binding provided") + } + + eabJSONBytes, err := json.Marshal(nar.ExternalAccountBinding) + if err != nil { + return acme.WrapErrorISE(err, "error marshalling externalAccountBinding") + } + + eabJWS, err := squarejose.ParseSigned(string(eabJSONBytes)) + if err != nil { + return acme.WrapErrorISE(err, "error parsing externalAccountBinding jws") + } + + // TODO: verify supported algorithms against the incoming alg (and corresponding settings)? + // TODO: implement strategy pattern to allow for different ways of verification (i.e. webhook call) based on configuration + + keyID := eabJWS.Signatures[0].Protected.KeyID + externalAccountKey, err := h.db.GetExternalAccountKey(ctx, keyID) + if err != nil { + return acme.WrapErrorISE(err, "error retrieving external account key") + } + + payload, err := eabJWS.Verify(externalAccountKey.KeyBytes) + if err != nil { + return acme.WrapErrorISE(err, "error verifying externalAccountBinding signature") + } + + jwk, err := jwkFromContext(ctx) + if err != nil { + return err + } + + jwkJSONBytes, err := jwk.MarshalJSON() + if err != nil { + return acme.WrapErrorISE(err, "error marshaling jwk") + } + + if bytes.Equal(payload, jwkJSONBytes) { + acme.NewError(acme.ErrorMalformedType, "keys in jws and eab payload do not match") // TODO: decide ACME error type to use + } + + return nil +} diff --git a/acme/api/handler.go b/acme/api/handler.go index 2a6d3a02..4519bd38 100644 --- a/acme/api/handler.go +++ b/acme/api/handler.go @@ -123,6 +123,13 @@ func (h *Handler) GetNonce(w http.ResponseWriter, r *http.Request) { } } +type Meta struct { + TermsOfService string `json:"termsOfService,omitempty"` + Website string `json:"website,omitempty"` + CaaIdentities []string `json:"caaIdentities,omitempty"` + ExternalAccountRequired bool `json:"externalAccountRequired,omitempty"` +} + // Directory represents an ACME directory for configuring clients. type Directory struct { NewNonce string `json:"newNonce"` @@ -130,6 +137,7 @@ type Directory struct { NewOrder string `json:"newOrder"` RevokeCert string `json:"revokeCert"` KeyChange string `json:"keyChange"` + Meta Meta `json:"meta"` } // ToLog enables response logging for the Directory type. @@ -145,12 +153,27 @@ func (d *Directory) ToLog() (interface{}, error) { // for client configuration. func (h *Handler) GetDirectory(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + prov, err := provisionerFromContext(ctx) + if err != nil { + api.WriteError(w, err) + return + } + + acmeProv, ok := prov.(*provisioner.ACME) // TODO: rewrite into providing configuration via function on acme.Provisioner + if !ok || acmeProv == nil { + api.WriteError(w, acme.NewErrorISE("provisioner in context is not an ACME provisioner")) + return + } + api.JSON(w, &Directory{ NewNonce: h.linker.GetLink(ctx, NewNonceLinkType), NewAccount: h.linker.GetLink(ctx, NewAccountLinkType), NewOrder: h.linker.GetLink(ctx, NewOrderLinkType), RevokeCert: h.linker.GetLink(ctx, RevokeCertLinkType), KeyChange: h.linker.GetLink(ctx, KeyChangeLinkType), + Meta: Meta{ + ExternalAccountRequired: acmeProv.RequireEAB, + }, }) } diff --git a/acme/db.go b/acme/db.go index d678fef4..5aae131a 100644 --- a/acme/db.go +++ b/acme/db.go @@ -19,6 +19,9 @@ type DB interface { GetAccountByKeyID(ctx context.Context, kid string) (*Account, error) UpdateAccount(ctx context.Context, acc *Account) error + CreateExternalAccountKey(ctx context.Context, name string) (*ExternalAccountKey, error) + GetExternalAccountKey(ctx context.Context, keyID string) (*ExternalAccountKey, error) + CreateNonce(ctx context.Context) (Nonce, error) DeleteNonce(ctx context.Context, nonce Nonce) error @@ -47,6 +50,9 @@ type MockDB struct { MockGetAccountByKeyID func(ctx context.Context, kid string) (*Account, error) MockUpdateAccount func(ctx context.Context, acc *Account) error + MockCreateExternalAccountKey func(ctx context.Context, name string) (*ExternalAccountKey, error) + MockGetExternalAccountKey func(ctx context.Context, keyID string) (*ExternalAccountKey, error) + MockCreateNonce func(ctx context.Context) (Nonce, error) MockDeleteNonce func(ctx context.Context, nonce Nonce) error @@ -110,6 +116,26 @@ func (m *MockDB) UpdateAccount(ctx context.Context, acc *Account) error { return m.MockError } +// CreateExternalAccountKey mock +func (m *MockDB) CreateExternalAccountKey(ctx context.Context, name string) (*ExternalAccountKey, error) { + if m.MockCreateExternalAccountKey != nil { + return m.MockCreateExternalAccountKey(ctx, name) + } else if m.MockError != nil { + return nil, m.MockError + } + return m.MockRet1.(*ExternalAccountKey), m.MockError +} + +// GetExternalAccountKey mock +func (m *MockDB) GetExternalAccountKey(ctx context.Context, keyID string) (*ExternalAccountKey, error) { + if m.MockGetExternalAccountKey != nil { + return m.MockGetExternalAccountKey(ctx, keyID) + } else if m.MockError != nil { + return nil, m.MockError + } + return m.MockRet1.(*ExternalAccountKey), m.MockError +} + // CreateNonce mock func (m *MockDB) CreateNonce(ctx context.Context) (Nonce, error) { if m.MockCreateNonce != nil { diff --git a/acme/db/nosql/account.go b/acme/db/nosql/account.go index 1c3bec5d..466adf43 100644 --- a/acme/db/nosql/account.go +++ b/acme/db/nosql/account.go @@ -2,6 +2,7 @@ package nosql import ( "context" + "crypto/rand" "encoding/json" "time" @@ -26,6 +27,15 @@ func (dba *dbAccount) clone() *dbAccount { return &nu } +type dbExternalAccountKey struct { + ID string `json:"id"` + Name string `json:"name"` + AccountID string `json:"accountID,omitempty"` + KeyBytes []byte `json:"key,omitempty"` + CreatedAt time.Time `json:"createdAt"` + BoundAt time.Time `json:"boundAt"` +} + func (db *DB) getAccountIDByKeyID(ctx context.Context, kid string) (string, error) { id, err := db.db.Get(accountByKeyIDTable, []byte(kid)) if err != nil { @@ -134,3 +144,61 @@ func (db *DB) UpdateAccount(ctx context.Context, acc *acme.Account) error { return db.save(ctx, old.ID, nu, old, "account", accountTable) } + +// CreateExternalAccountKey creates a new External Account Binding key with a name +func (db *DB) CreateExternalAccountKey(ctx context.Context, name string) (*acme.ExternalAccountKey, error) { + keyID, err := randID() + if err != nil { + return nil, err + } + + random := make([]byte, 32) + _, err = rand.Read(random) + if err != nil { + return nil, err + } + + dbeak := &dbExternalAccountKey{ + ID: keyID, + Name: name, + KeyBytes: random, + CreatedAt: clock.Now(), + } + + if err = db.save(ctx, keyID, dbeak, nil, "external_account_key", externalAccountKeyTable); err != nil { + return nil, err + } + return &acme.ExternalAccountKey{ + ID: dbeak.ID, + Name: dbeak.Name, + AccountID: dbeak.AccountID, + KeyBytes: dbeak.KeyBytes, + CreatedAt: dbeak.CreatedAt, + BoundAt: dbeak.BoundAt, + }, nil +} + +// GetExternalAccountKey retrieves an External Account Binding key by KeyID +func (db *DB) GetExternalAccountKey(ctx context.Context, keyID string) (*acme.ExternalAccountKey, error) { + data, err := db.db.Get(externalAccountKeyTable, []byte(keyID)) + if err != nil { + if nosqlDB.IsErrNotFound(err) { + return nil, acme.ErrNotFound + } + return nil, errors.Wrapf(err, "error loading external account key %s", keyID) + } + + dbeak := new(dbExternalAccountKey) + if err = json.Unmarshal(data, dbeak); err != nil { + return nil, errors.Wrapf(err, "error unmarshaling external account key %s into dbExternalAccountKey", keyID) + } + + return &acme.ExternalAccountKey{ + ID: dbeak.ID, + Name: dbeak.Name, + AccountID: dbeak.AccountID, + KeyBytes: dbeak.KeyBytes, + CreatedAt: dbeak.CreatedAt, + BoundAt: dbeak.BoundAt, + }, nil +} diff --git a/acme/db/nosql/nosql.go b/acme/db/nosql/nosql.go index 052f5729..320e7d58 100644 --- a/acme/db/nosql/nosql.go +++ b/acme/db/nosql/nosql.go @@ -11,14 +11,15 @@ import ( ) var ( - accountTable = []byte("acme_accounts") - accountByKeyIDTable = []byte("acme_keyID_accountID_index") - authzTable = []byte("acme_authzs") - challengeTable = []byte("acme_challenges") - nonceTable = []byte("nonces") - orderTable = []byte("acme_orders") - ordersByAccountIDTable = []byte("acme_account_orders_index") - certTable = []byte("acme_certs") + accountTable = []byte("acme_accounts") + accountByKeyIDTable = []byte("acme_keyID_accountID_index") + authzTable = []byte("acme_authzs") + challengeTable = []byte("acme_challenges") + nonceTable = []byte("nonces") + orderTable = []byte("acme_orders") + ordersByAccountIDTable = []byte("acme_account_orders_index") + certTable = []byte("acme_certs") + externalAccountKeyTable = []byte("acme_external_account_keys") ) // DB is a struct that implements the AcmeDB interface. @@ -29,7 +30,7 @@ type DB struct { // New configures and returns a new ACME DB backend implemented using a nosql DB. func New(db nosqlDB.DB) (*DB, error) { tables := [][]byte{accountTable, accountByKeyIDTable, authzTable, - challengeTable, nonceTable, orderTable, ordersByAccountIDTable, certTable} + challengeTable, nonceTable, orderTable, ordersByAccountIDTable, certTable, externalAccountKeyTable} for _, b := range tables { if err := db.CreateTable(b); err != nil { return nil, errors.Wrapf(err, "error creating table %s", diff --git a/authority/admin/api/eak.go b/authority/admin/api/eak.go new file mode 100644 index 00000000..1e46ff31 --- /dev/null +++ b/authority/admin/api/eak.go @@ -0,0 +1,45 @@ +package api + +import ( + "net/http" + + "github.com/smallstep/certificates/api" + "github.com/smallstep/certificates/authority/admin" +) + +// CreateExternalAccountKeyRequest is the type for GET /admin/eak requests +type CreateExternalAccountKeyRequest struct { + Name string `json:"name"` +} + +// CreateExternalAccountKeyResponse is the type for GET /admin/eak responses +type CreateExternalAccountKeyResponse struct { + KeyID string `json:"keyID"` + Name string `json:"name"` + Key []byte `json:"key"` +} + +// CreateExternalAccountKey creates a new External Account Binding key +func (h *Handler) CreateExternalAccountKey(w http.ResponseWriter, r *http.Request) { + var eakRequest = new(CreateExternalAccountKeyRequest) + if err := api.ReadJSON(r.Body, eakRequest); err != nil { // TODO: rewrite into protobuf json (likely) + api.WriteError(w, err) + return + } + + // TODO: Validate input + + eak, err := h.db.CreateExternalAccountKey(r.Context(), eakRequest.Name) + if err != nil { + api.WriteError(w, admin.WrapErrorISE(err, "error creating external account key %s", eakRequest.Name)) + return + } + + eakResponse := CreateExternalAccountKeyResponse{ + KeyID: eak.ID, + Name: eak.Name, + Key: eak.KeyBytes, + } + + api.JSONStatus(w, eakResponse, http.StatusCreated) // TODO: rewrite into protobuf json (likely) +} diff --git a/authority/admin/api/handler.go b/authority/admin/api/handler.go index d88edfa1..561db687 100644 --- a/authority/admin/api/handler.go +++ b/authority/admin/api/handler.go @@ -38,4 +38,7 @@ func (h *Handler) Route(r api.Router) { r.MethodFunc("POST", "/admins", authnz(h.CreateAdmin)) r.MethodFunc("PATCH", "/admins/{id}", authnz(h.UpdateAdmin)) r.MethodFunc("DELETE", "/admins/{id}", authnz(h.DeleteAdmin)) + + // External Account Binding Keys + r.MethodFunc("POST", "/eak", h.CreateExternalAccountKey) // TODO: authnz } diff --git a/authority/admin/db.go b/authority/admin/db.go index 15fe6686..14520207 100644 --- a/authority/admin/db.go +++ b/authority/admin/db.go @@ -7,6 +7,8 @@ import ( "github.com/pkg/errors" "go.step.sm/linkedca" + + "github.com/smallstep/certificates/authority/admin/eak" ) const ( @@ -67,6 +69,8 @@ type DB interface { GetAdmins(ctx context.Context) ([]*linkedca.Admin, error) UpdateAdmin(ctx context.Context, admin *linkedca.Admin) error DeleteAdmin(ctx context.Context, id string) error + + CreateExternalAccountKey(ctx context.Context, name string) (*eak.ExternalAccountKey, error) } // MockDB is an implementation of the DB interface that should only be used as @@ -84,6 +88,8 @@ type MockDB struct { MockUpdateAdmin func(ctx context.Context, adm *linkedca.Admin) error MockDeleteAdmin func(ctx context.Context, id string) error + MockCreateExternalAccountKey func(ctx context.Context, name string) (*eak.ExternalAccountKey, error) + MockError error MockRet1 interface{} } @@ -177,3 +183,10 @@ func (m *MockDB) DeleteAdmin(ctx context.Context, id string) error { } return m.MockError } + +func (m *MockDB) CreateExternalAccountKey(ctx context.Context, name string) (*eak.ExternalAccountKey, error) { + if m.MockCreateExternalAccountKey != nil { + return m.MockCreateExternalAccountKey(ctx, name) + } + return m.MockRet1.(*eak.ExternalAccountKey), m.MockError +} diff --git a/authority/admin/db/nosql/eak.go b/authority/admin/db/nosql/eak.go new file mode 100644 index 00000000..a3a3d96d --- /dev/null +++ b/authority/admin/db/nosql/eak.go @@ -0,0 +1,51 @@ +package nosql + +import ( + "context" + "crypto/rand" + "time" + + "github.com/smallstep/certificates/authority/admin/eak" +) + +type dbExternalAccountKey struct { + ID string `json:"id"` + Name string `json:"name"` + AccountID string `json:"accountID,omitempty"` + KeyBytes []byte `json:"key,omitempty"` + CreatedAt time.Time `json:"createdAt"` + BoundAt time.Time `json:"boundAt"` +} + +// CreateExternalAccountKey creates a new External Account Binding key +func (db *DB) CreateExternalAccountKey(ctx context.Context, name string) (*eak.ExternalAccountKey, error) { + keyID, err := randID() + if err != nil { + return nil, err + } + + random := make([]byte, 32) + _, err = rand.Read(random) + if err != nil { + return nil, err + } + + dbeak := &dbExternalAccountKey{ + ID: keyID, + Name: name, + KeyBytes: random, + CreatedAt: clock.Now(), + } + + if err = db.save(ctx, keyID, dbeak, nil, "external_account_key", externalAccountKeyTable); err != nil { + return nil, err + } + return &eak.ExternalAccountKey{ + ID: dbeak.ID, + Name: dbeak.Name, + AccountID: dbeak.AccountID, + KeyBytes: dbeak.KeyBytes, + CreatedAt: dbeak.CreatedAt, + BoundAt: dbeak.BoundAt, + }, nil +} diff --git a/authority/admin/db/nosql/nosql.go b/authority/admin/db/nosql/nosql.go index 18599b02..8019cbdf 100644 --- a/authority/admin/db/nosql/nosql.go +++ b/authority/admin/db/nosql/nosql.go @@ -11,8 +11,9 @@ import ( ) var ( - adminsTable = []byte("admins") - provisionersTable = []byte("provisioners") + adminsTable = []byte("admins") + provisionersTable = []byte("provisioners") + externalAccountKeyTable = []byte("acme_external_account_keys") ) // DB is a struct that implements the AdminDB interface. @@ -23,7 +24,7 @@ type DB struct { // New configures and returns a new Authority DB backend implemented using a nosql DB. func New(db nosqlDB.DB, authorityID string) (*DB, error) { - tables := [][]byte{adminsTable, provisionersTable} + tables := [][]byte{adminsTable, provisionersTable, externalAccountKeyTable} for _, b := range tables { if err := db.CreateTable(b); err != nil { return nil, errors.Wrapf(err, "error creating table %s", diff --git a/authority/admin/eak/eak.go b/authority/admin/eak/eak.go new file mode 100644 index 00000000..b2eaa157 --- /dev/null +++ b/authority/admin/eak/eak.go @@ -0,0 +1,12 @@ +package eak + +import "time" + +type ExternalAccountKey struct { + ID string `json:"id"` + Name string `json:"name"` + AccountID string `json:"-"` + KeyBytes []byte `json:"-"` + CreatedAt time.Time `json:"createdAt"` + BoundAt time.Time `json:"boundAt,omitempty"` +} diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index d81b0231..9a0f1356 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -13,13 +13,14 @@ import ( // provisioning flow. type ACME struct { *base - ID string `json:"-"` - Type string `json:"type"` - Name string `json:"name"` - ForceCN bool `json:"forceCN,omitempty"` - Claims *Claims `json:"claims,omitempty"` - Options *Options `json:"options,omitempty"` - claimer *Claimer + ID string `json:"-"` + Type string `json:"type"` + Name string `json:"name"` + ForceCN bool `json:"forceCN,omitempty"` + RequireEAB bool `json:"requireEAB,omitempty"` + Claims *Claims `json:"claims,omitempty"` + Options *Options `json:"options,omitempty"` + claimer *Claimer } // GetID returns the provisioner unique identifier.