From 71441742fa134647ec15ddc6252ee1bd6d4d32b5 Mon Sep 17 00:00:00 2001 From: Alexander Scheel Date: Wed, 29 Mar 2023 15:06:09 -0400 Subject: [PATCH] Add ACME new account creation handlers (#19820) * Identify whether JWKs existed or were created, set KIDs Signed-off-by: Alexander Scheel * Reclassify ErrAccountDoesNotExist as 400 per spec Signed-off-by: Alexander Scheel * Add additional stub methods for ACME accounts Signed-off-by: Alexander Scheel * Start adding ACME newAccount handlers This handler supports two pieces of functionality: 1. Searching for whether an existing account already exists. 2. Creating a new account. One side effect of our JWS parsing logic is that we needed a way to differentiate between whether a JWK existed on disk from an account or if it was specified in the request. This technically means we're potentially responding to certain requests with positive results (e.g., key search based on kid) versus erring earlier like other implementations do. No account storage has been done as part of this commit. Signed-off-by: Alexander Scheel * Unify path fields handling, fix newAccount method Signed-off-by: Alexander Scheel --------- Signed-off-by: Alexander Scheel --- builtin/logical/pki/acme/errors.go | 2 +- builtin/logical/pki/acme/jws.go | 27 ++- builtin/logical/pki/acme/state.go | 14 +- builtin/logical/pki/backend.go | 22 +- builtin/logical/pki/backend_test.go | 1 + builtin/logical/pki/path_acme_directory.go | 31 +-- builtin/logical/pki/path_acme_new_account.go | 207 +++++++++++++++++++ builtin/logical/pki/path_acme_nonce.go | 31 +-- 8 files changed, 271 insertions(+), 64 deletions(-) create mode 100644 builtin/logical/pki/path_acme_new_account.go diff --git a/builtin/logical/pki/acme/errors.go b/builtin/logical/pki/acme/errors.go index 996c0f66a80c..69683837eef5 100644 --- a/builtin/logical/pki/acme/errors.go +++ b/builtin/logical/pki/acme/errors.go @@ -73,7 +73,7 @@ var errIdMappings = map[error]string{ // Mapping of err->status codes; see table in RFC 8555 Section 6.7. Errors. var errCodeMappings = map[error]int{ - ErrAccountDoesNotExist: http.StatusNotFound, + ErrAccountDoesNotExist: http.StatusBadRequest, // See RFC 8555 Section 7.3.1. Finding an Account URL Given a Key. ErrAlreadyRevoked: http.StatusBadRequest, ErrBadCSR: http.StatusBadRequest, ErrBadNonce: http.StatusBadRequest, diff --git a/builtin/logical/pki/acme/jws.go b/builtin/logical/pki/acme/jws.go index e6a5ad8bff5b..0807b7b5d170 100644 --- a/builtin/logical/pki/acme/jws.go +++ b/builtin/logical/pki/acme/jws.go @@ -1,6 +1,8 @@ package acme import ( + "crypto" + "encoding/base64" "encoding/json" "fmt" @@ -22,12 +24,13 @@ var AllowedOuterJWSTypes = map[string]interface{}{ // This wraps a JWS message structure. type JWSCtx struct { - Algo string `json:"alg"` - Kid string `json:"kid"` - jwk json.RawMessage `json:"jwk"` - Nonce string `json:"nonce"` - Url string `json:"url"` - key jose.JSONWebKey `json:"-"` + Algo string `json:"alg"` + Kid string `json:"kid"` + jwk json.RawMessage `json:"jwk"` + Nonce string `json:"nonce"` + Url string `json:"url"` + key jose.JSONWebKey `json:"-"` + Existing bool `json:"-"` } func (c *JWSCtx) UnmarshalJSON(a *ACMEState, jws []byte) error { @@ -71,6 +74,7 @@ func (c *JWSCtx) UnmarshalJSON(a *ACMEState, jws []byte) error { if err != nil { return err } + c.Existing = true } if err = c.key.UnmarshalJSON(c.jwk); err != nil { @@ -81,6 +85,17 @@ func (c *JWSCtx) UnmarshalJSON(a *ACMEState, jws []byte) error { return fmt.Errorf("received invalid jwk") } + if c.Kid != "" { + // Create a key ID + kid, err := c.key.Thumbprint(crypto.SHA256) + if err != nil { + return fmt.Errorf("failed creating thumbprint: %w", err) + } + + c.Kid = base64.URLEncoding.EncodeToString(kid) + c.Existing = false + } + return nil } diff --git a/builtin/logical/pki/acme/state.go b/builtin/logical/pki/acme/state.go index 798bb7acae9e..2e341f1e830b 100644 --- a/builtin/logical/pki/acme/state.go +++ b/builtin/logical/pki/acme/state.go @@ -99,13 +99,23 @@ func (a *ACMEState) TidyNonces() { a.nextExpiry.Store(nextRun.Unix()) } -func (a *ACMEState) LoadKey(keyID string) (map[string]interface{}, error) { +func (a *ACMEState) CreateAccount(c *JWSCtx, contact []string, termsOfServiceAgreed bool) (map[string]interface{}, error) { // TODO return nil, nil } +func (a *ACMEState) LoadAccount(keyID string) (map[string]interface{}, error) { + // TODO + return nil, nil +} + +func (a *ACMEState) DoesAccountExist(keyId string) bool { + account, err := a.LoadAccount(keyId) + return err == nil && len(account) > 0 +} + func (a *ACMEState) LoadJWK(keyID string) ([]byte, error) { - key, err := a.LoadKey(keyID) + key, err := a.LoadAccount(keyID) if err != nil { return nil, err } diff --git a/builtin/logical/pki/backend.go b/builtin/logical/pki/backend.go index ec40948940ec..de937ba73395 100644 --- a/builtin/logical/pki/backend.go +++ b/builtin/logical/pki/backend.go @@ -12,20 +12,17 @@ import ( "sync/atomic" "time" - "github.com/hashicorp/vault/builtin/logical/pki/acme" - atomic2 "go.uber.org/atomic" - "github.com/hashicorp/vault/helper/constants" - - "github.com/hashicorp/go-multierror" - - "github.com/hashicorp/vault/sdk/helper/consts" + "github.com/hashicorp/vault/builtin/logical/pki/acme" "github.com/armon/go-metrics" + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/vault/helper/constants" "github.com/hashicorp/vault/helper/metricsutil" "github.com/hashicorp/vault/helper/namespace" "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/helper/consts" "github.com/hashicorp/vault/sdk/logical" ) @@ -220,11 +217,14 @@ func Backend(conf *logical.BackendConfig) *backend { pathAcmeRoleDirectory(&b), pathAcmeIssuerDirectory(&b), pathAcmeIssuerAndRoleDirectory(&b), - pathAcmeRootNonce(&b), pathAcmeRoleNonce(&b), pathAcmeIssuerNonce(&b), pathAcmeIssuerAndRoleNonce(&b), + pathAcmeRootNewAccount(&b), + pathAcmeRoleNewAccount(&b), + pathAcmeIssuerNewAccount(&b), + pathAcmeIssuerAndRoleNewAccount(&b), }, Secrets: []*framework.Secret{ @@ -241,6 +241,7 @@ func Backend(conf *logical.BackendConfig) *backend { for _, acmePrefix := range []string{"", "issuer/+/", "roles/+/", "issuer/+/roles/+/"} { b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/directory") b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/new-nonce") + b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/new-account") b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/new-order") b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/revoke-cert") b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/key-change") @@ -322,7 +323,9 @@ type backend struct { // Write lock around issuers and keys. issuersLock sync.RWMutex - acmeState *acme.ACMEState + + // Context around ACME operations + acmeState *acme.ACMEState } type roleOperation func(ctx context.Context, req *logical.Request, data *framework.FieldData, role *roleEntry) (*logical.Response, error) @@ -410,6 +413,7 @@ func (b *backend) initialize(ctx context.Context, _ *logical.InitializationReque b.Logger().Error("Could not initialize stored certificate counts", err) b.certCountError = err.Error() } + return nil } diff --git a/builtin/logical/pki/backend_test.go b/builtin/logical/pki/backend_test.go index 50c9b4014f49..93bca248a598 100644 --- a/builtin/logical/pki/backend_test.go +++ b/builtin/logical/pki/backend_test.go @@ -6812,6 +6812,7 @@ func TestProperAuthing(t *testing.T) { for _, acmePrefix := range []string{"", "issuer/default/", "roles/test/", "issuer/default/roles/test/"} { paths[acmePrefix+"acme/directory"] = shouldBeUnauthedReadList paths[acmePrefix+"acme/new-nonce"] = shouldBeUnauthedReadList + paths[acmePrefix+"acme/new-account"] = shouldBeUnauthedWriteOnly } for path, checkerType := range paths { diff --git a/builtin/logical/pki/path_acme_directory.go b/builtin/logical/pki/path_acme_directory.go index dbae89b8be0d..4770ff7abad0 100644 --- a/builtin/logical/pki/path_acme_directory.go +++ b/builtin/logical/pki/path_acme_directory.go @@ -19,42 +19,27 @@ const ( ) func pathAcmeRootDirectory(b *backend) *framework.Path { - return patternAcmeDirectory(b, "acme/directory", false /* requireRole */, false /* requireIssuer */) + return patternAcmeDirectory(b, "acme/directory") } func pathAcmeRoleDirectory(b *backend) *framework.Path { - return patternAcmeDirectory(b, "roles/"+framework.GenericNameRegex("role")+"/acme/directory", - true /* requireRole */, false /* requireIssuer */) + return patternAcmeDirectory(b, "roles/"+framework.GenericNameRegex("role")+"/acme/directory") } func pathAcmeIssuerDirectory(b *backend) *framework.Path { - return patternAcmeDirectory(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/directory", - false /* requireRole */, true /* requireIssuer */) + return patternAcmeDirectory(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/directory") } func pathAcmeIssuerAndRoleDirectory(b *backend) *framework.Path { return patternAcmeDirectory(b, - "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/roles/"+framework.GenericNameRegex( - "role")+"/acme/directory", - true /* requireRole */, true /* requireIssuer */) + "issuer/"+framework.GenericNameRegex(issuerRefParam)+ + "/roles/"+framework.GenericNameRegex("role")+"/acme/directory") } -func patternAcmeDirectory(b *backend, pattern string, requireRole, requireIssuer bool) *framework.Path { +func patternAcmeDirectory(b *backend, pattern string) *framework.Path { fields := map[string]*framework.FieldSchema{} - if requireRole { - fields["role"] = &framework.FieldSchema{ - Type: framework.TypeString, - Description: `The desired role for the acme request`, - Required: true, - } - } - if requireIssuer { - fields[issuerRefParam] = &framework.FieldSchema{ - Type: framework.TypeString, - Description: `Reference to an existing issuer name or issuer id`, - Required: true, - } - } + addFieldsForACMEPath(fields, pattern) + return &framework.Path{ Pattern: pattern, Fields: fields, diff --git a/builtin/logical/pki/path_acme_new_account.go b/builtin/logical/pki/path_acme_new_account.go new file mode 100644 index 000000000000..7095e67a7f16 --- /dev/null +++ b/builtin/logical/pki/path_acme_new_account.go @@ -0,0 +1,207 @@ +package pki + +import ( + "fmt" + "strings" + + "github.com/hashicorp/vault/builtin/logical/pki/acme" + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +func pathAcmeRootNewAccount(b *backend) *framework.Path { + return patternAcmeNewAccount(b, "acme/new-account") +} + +func pathAcmeRoleNewAccount(b *backend) *framework.Path { + return patternAcmeNewAccount(b, "roles/"+framework.GenericNameRegex("role")+"/acme/new-account") +} + +func pathAcmeIssuerNewAccount(b *backend) *framework.Path { + return patternAcmeNewAccount(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/new-account") +} + +func pathAcmeIssuerAndRoleNewAccount(b *backend) *framework.Path { + return patternAcmeNewAccount(b, + "issuer/"+framework.GenericNameRegex(issuerRefParam)+ + "/roles/"+framework.GenericNameRegex("role")+"/acme/new-account") +} + +func addFieldsForACMEPath(fields map[string]*framework.FieldSchema, pattern string) map[string]*framework.FieldSchema { + if strings.Contains(pattern, framework.GenericNameRegex("role")) { + fields["role"] = &framework.FieldSchema{ + Type: framework.TypeString, + Description: `The desired role for the acme request`, + Required: true, + } + } + if strings.Contains(pattern, framework.GenericNameRegex(issuerRefParam)) { + fields[issuerRefParam] = &framework.FieldSchema{ + Type: framework.TypeString, + Description: `Reference to an existing issuer name or issuer id`, + Required: true, + } + } + + return fields +} + +func addFieldsForACMERequest(fields map[string]*framework.FieldSchema) map[string]*framework.FieldSchema { + fields["protected"] = &framework.FieldSchema{ + Type: framework.TypeString, + Description: "ACME request 'protected' value", + Required: true, + } + + fields["payload"] = &framework.FieldSchema{ + Type: framework.TypeString, + Description: "ACME request 'payload' value", + Required: true, + } + + fields["signature"] = &framework.FieldSchema{ + Type: framework.TypeString, + Description: "ACME request 'signature' value", + Required: true, + } + + return fields +} + +func patternAcmeNewAccount(b *backend, pattern string) *framework.Path { + fields := map[string]*framework.FieldSchema{} + addFieldsForACMEPath(fields, pattern) + addFieldsForACMERequest(fields) + + return &framework.Path{ + Pattern: pattern, + Fields: fields, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.acmeParsedWrapper(b.acmeNewAccountHandler), + ForwardPerformanceSecondary: false, + ForwardPerformanceStandby: true, + }, + }, + + HelpSynopsis: pathOcspHelpSyn, + HelpDescription: pathOcspHelpDesc, + } +} + +type acmeParsedOperation func(acmeCtx acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *acme.JWSCtx, data map[string]interface{}) (*logical.Response, error) + +func (b *backend) acmeParsedWrapper(op acmeParsedOperation) framework.OperationFunc { + return b.acmeWrapper(func(acmeCtx acmeContext, r *logical.Request, fields *framework.FieldData) (*logical.Response, error) { + user, data, err := b.acmeState.ParseRequestParams(fields) + if err != nil { + return nil, err + } + + return op(acmeCtx, r, fields, user, data) + }) +} + +func (b *backend) acmeNewAccountHandler(acmeCtx acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *acme.JWSCtx, data map[string]interface{}) (*logical.Response, error) { + // Parameters + var ok bool + var onlyReturnExisting bool + var contact []string + var termsOfServiceAgreed bool + + rawContact, present := data["contact"] + if present { + contact, ok = rawContact.([]string) + if !ok { + return nil, fmt.Errorf("invalid type for field 'contact': %w", acme.ErrMalformed) + } + } + + rawTermsOfServiceAgreed, present := data["termsOfServiceAgreed"] + if present { + termsOfServiceAgreed, ok = rawTermsOfServiceAgreed.(bool) + if !ok { + return nil, fmt.Errorf("invalid type for field 'termsOfServiceAgreed': %w", acme.ErrMalformed) + } + } + + rawOnlyReturnExisting, present := data["onlyReturnExisting"] + if present { + onlyReturnExisting, ok = rawOnlyReturnExisting.(bool) + if !ok { + return nil, fmt.Errorf("invalid type for field 'onlyReturnExisting': %w", acme.ErrMalformed) + } + } + + // We ignore the EAB parameter as it is currently not supported. + + // We have two paths here: search or create. + if onlyReturnExisting { + return b.acmeNewAccountSearchHandler(acmeCtx, r, fields, userCtx, data) + } + + return b.acmeNewAccountCreateHandler(acmeCtx, r, fields, userCtx, data, contact, termsOfServiceAgreed) +} + +func formatAccountResponse(location string, status string, contact []string) *logical.Response { + resp := &logical.Response{ + Data: map[string]interface{}{ + "status": status, + "orders": location + "/orders", + }, + } + + if len(contact) > 0 { + resp.Data["contact"] = contact + } + + resp.Headers["Location"] = []string{location} + + return resp +} + +func (b *backend) acmeNewAccountSearchHandler(acmeCtx acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *acme.JWSCtx, data map[string]interface{}) (*logical.Response, error) { + if userCtx.Existing || b.acmeState.DoesAccountExist(userCtx.Kid) { + // This account exists; return its details. It would be slightly + // weird to specify a kid in the request (and not use an explicit + // jwk here), but we might as well support it too. + account, err := b.acmeState.LoadAccount(userCtx.Kid) + if err != nil { + return nil, fmt.Errorf("error loading account: %w", err) + } + + location := acmeCtx.baseUrl.String() + "/acme/account/" + userCtx.Kid + return formatAccountResponse(location, account["status"].(string), account["contact"].([]string)), nil + } + + // Per RFC 8555 Section 7.3.1. Finding an Account URL Given a Key: + // + // > If a client sends such a request and an account does not exist, + // > then the server MUST return an error response with status code + // > 400 (Bad Request) and type "urn:ietf:params:acme:error:accountDoesNotExist". + return nil, fmt.Errorf("An account with this key does not exist: %w", acme.ErrAccountDoesNotExist) +} + +func (b *backend) acmeNewAccountCreateHandler(acmeCtx acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *acme.JWSCtx, data map[string]interface{}, contact []string, termsOfServiceAgreed bool) (*logical.Response, error) { + if userCtx.Existing { + return nil, fmt.Errorf("cannot submit to newAccount with 'kid': %w", acme.ErrMalformed) + } + + // If the account already exists, return the existing one. + if b.acmeState.DoesAccountExist(userCtx.Kid) { + return b.acmeNewAccountSearchHandler(acmeCtx, r, fields, userCtx, data) + } + + // TODO: Limit this only when ToS are required by the operator. + if !termsOfServiceAgreed { + return nil, fmt.Errorf("terms of service not agreed to: %w", acme.ErrUserActionRequired) + } + + account, err := b.acmeState.CreateAccount(userCtx, contact, termsOfServiceAgreed) + if err != nil { + return nil, fmt.Errorf("failed to create account: %w", err) + } + + location := acmeCtx.baseUrl.String() + "/acme/account/" + userCtx.Kid + return formatAccountResponse(location, account["status"].(string), account["contact"].([]string)), nil +} diff --git a/builtin/logical/pki/path_acme_nonce.go b/builtin/logical/pki/path_acme_nonce.go index 4189b9c1738c..35dba21bbd13 100644 --- a/builtin/logical/pki/path_acme_nonce.go +++ b/builtin/logical/pki/path_acme_nonce.go @@ -9,42 +9,27 @@ import ( ) func pathAcmeRootNonce(b *backend) *framework.Path { - return patternAcmeNonce(b, "acme/new-nonce", false /* requireRole */, false /* requireIssuer */) + return patternAcmeNonce(b, "acme/new-nonce") } func pathAcmeRoleNonce(b *backend) *framework.Path { - return patternAcmeNonce(b, "roles/"+framework.GenericNameRegex("role")+"/acme/new-nonce", - true /* requireRole */, false /* requireIssuer */) + return patternAcmeNonce(b, "roles/"+framework.GenericNameRegex("role")+"/acme/new-nonce") } func pathAcmeIssuerNonce(b *backend) *framework.Path { - return patternAcmeNonce(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/new-nonce", - false /* requireRole */, true /* requireIssuer */) + return patternAcmeNonce(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/new-nonce") } func pathAcmeIssuerAndRoleNonce(b *backend) *framework.Path { return patternAcmeNonce(b, - "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/roles/"+framework.GenericNameRegex( - "role")+"/acme/new-nonce", - true /* requireRole */, true /* requireIssuer */) + "issuer/"+framework.GenericNameRegex(issuerRefParam)+ + "/roles/"+framework.GenericNameRegex("role")+"/acme/new-nonce") } -func patternAcmeNonce(b *backend, pattern string, requireRole, requireIssuer bool) *framework.Path { +func patternAcmeNonce(b *backend, pattern string) *framework.Path { fields := map[string]*framework.FieldSchema{} - if requireRole { - fields["role"] = &framework.FieldSchema{ - Type: framework.TypeString, - Description: `The desired role for the acme request`, - Required: true, - } - } - if requireIssuer { - fields[issuerRefParam] = &framework.FieldSchema{ - Type: framework.TypeString, - Description: `Reference to an existing issuer name or issuer id`, - Required: true, - } - } + addFieldsForACMEPath(fields, pattern) + return &framework.Path{ Pattern: pattern, Fields: fields,