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

View File

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

View File

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

View File

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

View File

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

View File

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

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