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,