Add first working version of External Account Binding

This commit is contained in:
Herman Slatman 2021-07-17 17:35:44 +02:00
parent b9743b36e1
commit f81d49d963
No known key found for this signature in database
GPG key ID: F4D8A44EA0A75A4F
13 changed files with 353 additions and 22 deletions

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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,
},
})
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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",

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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",

View file

@ -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"`
}

View file

@ -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.