Add ACME new account creation handlers (#19820)

* Identify whether JWKs existed or were created, set KIDs

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Reclassify ErrAccountDoesNotExist as 400 per spec

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add additional stub methods for ACME accounts

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* 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 <alex.scheel@hashicorp.com>

* Unify path fields handling, fix newAccount method

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

---------

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
This commit is contained in:
Alexander Scheel 2023-03-29 15:06:09 -04:00 committed by GitHub
parent 853e0e0fc1
commit 73c468787b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 271 additions and 64 deletions

View File

@ -73,7 +73,7 @@ var errIdMappings = map[error]string{
// Mapping of err->status codes; see table in RFC 8555 Section 6.7. Errors. // Mapping of err->status codes; see table in RFC 8555 Section 6.7. Errors.
var errCodeMappings = map[error]int{ 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, ErrAlreadyRevoked: http.StatusBadRequest,
ErrBadCSR: http.StatusBadRequest, ErrBadCSR: http.StatusBadRequest,
ErrBadNonce: http.StatusBadRequest, ErrBadNonce: http.StatusBadRequest,

View File

@ -1,6 +1,8 @@
package acme package acme
import ( import (
"crypto"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -22,12 +24,13 @@ var AllowedOuterJWSTypes = map[string]interface{}{
// This wraps a JWS message structure. // This wraps a JWS message structure.
type JWSCtx struct { type JWSCtx struct {
Algo string `json:"alg"` Algo string `json:"alg"`
Kid string `json:"kid"` Kid string `json:"kid"`
jwk json.RawMessage `json:"jwk"` jwk json.RawMessage `json:"jwk"`
Nonce string `json:"nonce"` Nonce string `json:"nonce"`
Url string `json:"url"` Url string `json:"url"`
key jose.JSONWebKey `json:"-"` key jose.JSONWebKey `json:"-"`
Existing bool `json:"-"`
} }
func (c *JWSCtx) UnmarshalJSON(a *ACMEState, jws []byte) error { 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 { if err != nil {
return err return err
} }
c.Existing = true
} }
if err = c.key.UnmarshalJSON(c.jwk); err != nil { 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") 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 return nil
} }

View File

@ -99,13 +99,23 @@ func (a *ACMEState) TidyNonces() {
a.nextExpiry.Store(nextRun.Unix()) 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 // TODO
return nil, nil 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) { func (a *ACMEState) LoadJWK(keyID string) ([]byte, error) {
key, err := a.LoadKey(keyID) key, err := a.LoadAccount(keyID)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -12,20 +12,17 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/hashicorp/vault/builtin/logical/pki/acme"
atomic2 "go.uber.org/atomic" atomic2 "go.uber.org/atomic"
"github.com/hashicorp/vault/helper/constants" "github.com/hashicorp/vault/builtin/logical/pki/acme"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/vault/sdk/helper/consts"
"github.com/armon/go-metrics" "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/metricsutil"
"github.com/hashicorp/vault/helper/namespace" "github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/helper/consts"
"github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/sdk/logical"
) )
@ -220,11 +217,14 @@ func Backend(conf *logical.BackendConfig) *backend {
pathAcmeRoleDirectory(&b), pathAcmeRoleDirectory(&b),
pathAcmeIssuerDirectory(&b), pathAcmeIssuerDirectory(&b),
pathAcmeIssuerAndRoleDirectory(&b), pathAcmeIssuerAndRoleDirectory(&b),
pathAcmeRootNonce(&b), pathAcmeRootNonce(&b),
pathAcmeRoleNonce(&b), pathAcmeRoleNonce(&b),
pathAcmeIssuerNonce(&b), pathAcmeIssuerNonce(&b),
pathAcmeIssuerAndRoleNonce(&b), pathAcmeIssuerAndRoleNonce(&b),
pathAcmeRootNewAccount(&b),
pathAcmeRoleNewAccount(&b),
pathAcmeIssuerNewAccount(&b),
pathAcmeIssuerAndRoleNewAccount(&b),
}, },
Secrets: []*framework.Secret{ Secrets: []*framework.Secret{
@ -241,6 +241,7 @@ func Backend(conf *logical.BackendConfig) *backend {
for _, acmePrefix := range []string{"", "issuer/+/", "roles/+/", "issuer/+/roles/+/"} { 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/directory")
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/new-nonce") 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/new-order")
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/revoke-cert") b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/revoke-cert")
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/key-change") b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/key-change")
@ -322,7 +323,9 @@ type backend struct {
// Write lock around issuers and keys. // Write lock around issuers and keys.
issuersLock sync.RWMutex 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) 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.Logger().Error("Could not initialize stored certificate counts", err)
b.certCountError = err.Error() b.certCountError = err.Error()
} }
return nil return nil
} }

View File

@ -6812,6 +6812,7 @@ func TestProperAuthing(t *testing.T) {
for _, acmePrefix := range []string{"", "issuer/default/", "roles/test/", "issuer/default/roles/test/"} { for _, acmePrefix := range []string{"", "issuer/default/", "roles/test/", "issuer/default/roles/test/"} {
paths[acmePrefix+"acme/directory"] = shouldBeUnauthedReadList paths[acmePrefix+"acme/directory"] = shouldBeUnauthedReadList
paths[acmePrefix+"acme/new-nonce"] = shouldBeUnauthedReadList paths[acmePrefix+"acme/new-nonce"] = shouldBeUnauthedReadList
paths[acmePrefix+"acme/new-account"] = shouldBeUnauthedWriteOnly
} }
for path, checkerType := range paths { for path, checkerType := range paths {

View File

@ -19,42 +19,27 @@ const (
) )
func pathAcmeRootDirectory(b *backend) *framework.Path { 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 { func pathAcmeRoleDirectory(b *backend) *framework.Path {
return patternAcmeDirectory(b, "roles/"+framework.GenericNameRegex("role")+"/acme/directory", return patternAcmeDirectory(b, "roles/"+framework.GenericNameRegex("role")+"/acme/directory")
true /* requireRole */, false /* requireIssuer */)
} }
func pathAcmeIssuerDirectory(b *backend) *framework.Path { func pathAcmeIssuerDirectory(b *backend) *framework.Path {
return patternAcmeDirectory(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/directory", return patternAcmeDirectory(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/directory")
false /* requireRole */, true /* requireIssuer */)
} }
func pathAcmeIssuerAndRoleDirectory(b *backend) *framework.Path { func pathAcmeIssuerAndRoleDirectory(b *backend) *framework.Path {
return patternAcmeDirectory(b, return patternAcmeDirectory(b,
"issuer/"+framework.GenericNameRegex(issuerRefParam)+"/roles/"+framework.GenericNameRegex( "issuer/"+framework.GenericNameRegex(issuerRefParam)+
"role")+"/acme/directory", "/roles/"+framework.GenericNameRegex("role")+"/acme/directory")
true /* requireRole */, true /* requireIssuer */)
} }
func patternAcmeDirectory(b *backend, pattern string, requireRole, requireIssuer bool) *framework.Path { func patternAcmeDirectory(b *backend, pattern string) *framework.Path {
fields := map[string]*framework.FieldSchema{} fields := map[string]*framework.FieldSchema{}
if requireRole { addFieldsForACMEPath(fields, pattern)
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,
}
}
return &framework.Path{ return &framework.Path{
Pattern: pattern, Pattern: pattern,
Fields: fields, Fields: fields,

View File

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

View File

@ -9,42 +9,27 @@ import (
) )
func pathAcmeRootNonce(b *backend) *framework.Path { 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 { func pathAcmeRoleNonce(b *backend) *framework.Path {
return patternAcmeNonce(b, "roles/"+framework.GenericNameRegex("role")+"/acme/new-nonce", return patternAcmeNonce(b, "roles/"+framework.GenericNameRegex("role")+"/acme/new-nonce")
true /* requireRole */, false /* requireIssuer */)
} }
func pathAcmeIssuerNonce(b *backend) *framework.Path { func pathAcmeIssuerNonce(b *backend) *framework.Path {
return patternAcmeNonce(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/new-nonce", return patternAcmeNonce(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/new-nonce")
false /* requireRole */, true /* requireIssuer */)
} }
func pathAcmeIssuerAndRoleNonce(b *backend) *framework.Path { func pathAcmeIssuerAndRoleNonce(b *backend) *framework.Path {
return patternAcmeNonce(b, return patternAcmeNonce(b,
"issuer/"+framework.GenericNameRegex(issuerRefParam)+"/roles/"+framework.GenericNameRegex( "issuer/"+framework.GenericNameRegex(issuerRefParam)+
"role")+"/acme/new-nonce", "/roles/"+framework.GenericNameRegex("role")+"/acme/new-nonce")
true /* requireRole */, true /* requireIssuer */)
} }
func patternAcmeNonce(b *backend, pattern string, requireRole, requireIssuer bool) *framework.Path { func patternAcmeNonce(b *backend, pattern string) *framework.Path {
fields := map[string]*framework.FieldSchema{} fields := map[string]*framework.FieldSchema{}
if requireRole { addFieldsForACMEPath(fields, pattern)
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,
}
}
return &framework.Path{ return &framework.Path{
Pattern: pattern, Pattern: pattern,
Fields: fields, Fields: fields,