Add External Account Binding support to ACME (#20523)

* Add Vault APIS to create, list, delete ACME EAB keys

 - Add Vault authenticated APIs to create, list and delete ACME
   EAB keys.
 - Add supporting tests for all new apis

* Add require_eab to acme configuration

* Add EAB support to ACME

* Add EAB support to ACME

* PR feedback 1

 - Address missing err return within DeleteEab
 - Move verifyEabPayload to acme_jws.go no code changes in this PR
 - Update error message returned for error on account storage with EAB.

* PR feedback 2

 - Verify JWK signature payload after signature verification

* Introduce an ACME eab_policy in configuration

 - Instead of a boolean on/off for require_eab, introduce named policies for ACME behaviour enforcing eab.
 - The default policy of always-required, will force new accounts to have an EAB, and all operations in the future, will make sure the account has an EAB associated with it.
 - Two other policies, not-required will allow any anonymous users to use ACME within PKI and 'new-account-required' will enforce new accounts going forward to require an EAB, but existing accounts will still be allowed to use ACME if they don't have an EAB associated with the account.
 - Having 'always-required' as a policy, will override the environment variable to disable public acme as well.

* Add missing go-docs to new tests.

* Add valid eab_policy values in error message.
This commit is contained in:
Steven Clark
2023-05-15 13:15:20 -04:00
committed by GitHub
parent 4bd857ca84
commit 0b9f4048af
13 changed files with 739 additions and 25 deletions

View File

@@ -0,0 +1,69 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package pki
import (
"fmt"
"strings"
)
type EabPolicyName string
const (
eabPolicyNotRequired EabPolicyName = "not-required"
eabPolicyNewAccountRequired EabPolicyName = "new-account-required"
eabPolicyAlwaysRequired EabPolicyName = "always-required"
)
func getEabPolicyByString(name string) (EabPolicy, error) {
lcName := strings.TrimSpace(strings.ToLower(name))
switch lcName {
case string(eabPolicyNotRequired):
return getEabPolicyByName(eabPolicyNotRequired), nil
case string(eabPolicyNewAccountRequired):
return getEabPolicyByName(eabPolicyNewAccountRequired), nil
case string(eabPolicyAlwaysRequired):
return getEabPolicyByName(eabPolicyAlwaysRequired), nil
default:
return getEabPolicyByName(eabPolicyAlwaysRequired), fmt.Errorf("unknown eab policy name: %s", name)
}
}
func getEabPolicyByName(name EabPolicyName) EabPolicy {
return EabPolicy{Name: name}
}
type EabPolicy struct {
Name EabPolicyName
}
// EnforceForNewAccount for new account creations, should we require an EAB.
func (ep EabPolicy) EnforceForNewAccount(eabData *eabType) error {
if (ep.Name == eabPolicyAlwaysRequired || ep.Name == eabPolicyNewAccountRequired) && eabData == nil {
return ErrExternalAccountRequired
}
return nil
}
// EnforceForExistingAccount for all operations within ACME, does the account being used require an EAB attached to it.
func (ep EabPolicy) EnforceForExistingAccount(account *acmeAccount) error {
if ep.Name == eabPolicyAlwaysRequired && account.Eab == nil {
return ErrExternalAccountRequired
}
return nil
}
// IsExternalAccountRequired for new accounts incoming does is an EAB required
func (ep EabPolicy) IsExternalAccountRequired() bool {
return ep.Name == eabPolicyAlwaysRequired || ep.Name == eabPolicyNewAccountRequired
}
// OverrideEnvDisablingPublicAcme determines if ACME is enabled but the OS environment variable
// has said to disable public acme support, if we can override that environment variable to
// turn on ACME support
func (ep EabPolicy) OverrideEnvDisablingPublicAcme() bool {
return ep.Name == eabPolicyAlwaysRequired
}

View File

@@ -4,6 +4,7 @@
package pki
import (
"bytes"
"crypto"
"encoding/base64"
"encoding/json"
@@ -26,6 +27,12 @@ var AllowedOuterJWSTypes = map[string]interface{}{
"EdDSA2": true,
}
var AllowedEabJWSTypes = map[string]interface{}{
"HS256": true,
"HS384": true,
"HS512": true,
}
// This wraps a JWS message structure.
type jwsCtx struct {
Algo string `json:"alg"`
@@ -45,7 +52,25 @@ func (c *jwsCtx) GetKeyThumbprint() (string, error) {
return base64.RawURLEncoding.EncodeToString(keyThumbprint), nil
}
func (c *jwsCtx) UnmarshalJSON(a *acmeState, ac *acmeContext, jws []byte) error {
func UnmarshalEabJwsJson(eabBytes []byte) (*jwsCtx, error) {
var eabJws jwsCtx
var err error
if err = json.Unmarshal(eabBytes, &eabJws); err != nil {
return nil, err
}
if eabJws.Kid == "" {
return nil, fmt.Errorf("invalid header: got missing required field 'kid': %w", ErrMalformed)
}
if _, present := AllowedEabJWSTypes[eabJws.Algo]; !present {
return nil, fmt.Errorf("invalid header: unexpected value for 'algo': %w", ErrMalformed)
}
return &eabJws, nil
}
func (c *jwsCtx) UnmarshalOuterJwsJson(a *acmeState, ac *acmeContext, jws []byte) error {
var err error
if err = json.Unmarshal(jws, c); err != nil {
return err
@@ -158,3 +183,91 @@ func (c *jwsCtx) VerifyJWS(signature string) (map[string]interface{}, error) {
return m, nil
}
func verifyEabPayload(acmeState *acmeState, ac *acmeContext, outer *jwsCtx, expectedPath string, payload map[string]interface{}) (*eabType, error) {
// Parse the key out.
rawProtectedBase64, ok := payload["protected"]
if !ok {
return nil, fmt.Errorf("missing required field 'protected': %w", ErrMalformed)
}
jwkBase64 := rawProtectedBase64.(string)
jwkBytes, err := base64.RawURLEncoding.DecodeString(jwkBase64)
if err != nil {
return nil, fmt.Errorf("failed to base64 parse eab 'protected': %s: %w", err, ErrMalformed)
}
eabJws, err := UnmarshalEabJwsJson(jwkBytes)
if err != nil {
return nil, fmt.Errorf("failed to json unmarshal eab 'protected': %w", err)
}
if len(eabJws.Url) == 0 {
return nil, fmt.Errorf("missing required parameter 'url' in eab 'protected': %w", ErrMalformed)
}
expectedUrl := ac.clusterUrl.JoinPath(expectedPath).String()
if expectedUrl != eabJws.Url {
return nil, fmt.Errorf("invalid value for 'url' in eab 'protected': got '%v' expected '%v': %w", eabJws.Url, expectedUrl, ErrUnauthorized)
}
if len(eabJws.Nonce) != 0 {
return nil, fmt.Errorf("nonce should not be provided in eab 'protected': %w", ErrMalformed)
}
rawPayloadBase64, ok := payload["payload"]
if !ok {
return nil, fmt.Errorf("missing required field eab 'payload': %w", ErrMalformed)
}
payloadBase64, ok := rawPayloadBase64.(string)
if !ok {
return nil, fmt.Errorf("failed to parse 'payload' field: %w", ErrMalformed)
}
rawSignatureBase64, ok := payload["signature"]
if !ok {
return nil, fmt.Errorf("missing required field 'signature': %w", ErrMalformed)
}
signatureBase64, ok := rawSignatureBase64.(string)
if !ok {
return nil, fmt.Errorf("failed to parse 'signature' field: %w", ErrMalformed)
}
// go-jose only seems to support compact signature encodings.
compactSig := fmt.Sprintf("%v.%v.%v", jwkBase64, payloadBase64, signatureBase64)
sig, err := jose.ParseSigned(compactSig)
if err != nil {
return nil, fmt.Errorf("error parsing eab signature: %s: %w", err, ErrMalformed)
}
if len(sig.Signatures) > 1 {
// See RFC 8555 Section 6.2. Request Authentication:
//
// > The JWS MUST NOT have multiple signatures
return nil, fmt.Errorf("eab had multiple signatures: %w", ErrMalformed)
}
if hasValues(sig.Signatures[0].Unprotected) {
// See RFC 8555 Section 6.2. Request Authentication:
//
// > The JWS Unprotected Header [RFC7515] MUST NOT be used
return nil, fmt.Errorf("eab had unprotected headers: %w", ErrMalformed)
}
// Load the EAB to validate the signature against
eabEntry, err := acmeState.LoadEab(ac.sc, eabJws.Kid)
if err != nil {
return nil, fmt.Errorf("%w: failed to verify eab", ErrUnauthorized)
}
verifiedPayload, err := sig.Verify(eabEntry.MacKey)
if err != nil {
return nil, err
}
// Make sure how eab payload matches the outer JWK key value
if !bytes.Equal(outer.Jwk, verifiedPayload) {
return nil, fmt.Errorf("eab payload does not match outer JWK key: %w", ErrMalformed)
}
return eabEntry, nil
}

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"io"
"net"
"path"
"strings"
"sync"
"sync/atomic"
@@ -35,6 +36,7 @@ const (
acmeAccountPrefix = acmePathPrefix + "accounts/"
acmeThumbprintPrefix = acmePathPrefix + "account-thumbprints/"
acmeValidationPrefix = acmePathPrefix + "validations/"
acmeEabPrefix = acmePathPrefix + "eab/"
)
type acmeState struct {
@@ -219,6 +221,7 @@ type acmeAccount struct {
AcmeDirectory string `json:"acme-directory"`
AccountCreatedDate time.Time `json:"account_created_date"`
AccountRevokedDate time.Time `json:"account_revoked_date"`
Eab *eabType `json:"eab"`
}
type acmeOrder struct {
@@ -257,7 +260,7 @@ func (o acmeOrder) getIdentifierIPValues() []net.IP {
return identifiers
}
func (a *acmeState) CreateAccount(ac *acmeContext, c *jwsCtx, contact []string, termsOfServiceAgreed bool) (*acmeAccount, error) {
func (a *acmeState) CreateAccount(ac *acmeContext, c *jwsCtx, contact []string, termsOfServiceAgreed bool, eab *eabType) (*acmeAccount, error) {
// Write out the thumbprint value/entry out first, if we get an error mid-way through
// this is easier to recover from. The new kid with the same existing public key
// will rewrite the thumbprint entry. This goes in hand with LoadAccountByKey that
@@ -291,6 +294,7 @@ func (a *acmeState) CreateAccount(ac *acmeContext, c *jwsCtx, contact []string,
Status: StatusValid,
AcmeDirectory: ac.acmeDirectory,
AccountCreatedDate: time.Now(),
Eab: eab,
}
json, err := logical.StorageEntryJSON(acmeAccountPrefix+c.Kid, acct)
if err != nil {
@@ -470,7 +474,7 @@ func (a *acmeState) ParseRequestParams(ac *acmeContext, req *logical.Request, da
if err != nil {
return nil, nil, fmt.Errorf("failed to base64 parse 'protected': %s: %w", err, ErrMalformed)
}
if err = c.UnmarshalJSON(a, ac, jwkBytes); err != nil {
if err = c.UnmarshalOuterJwsJson(a, ac, jwkBytes); err != nil {
return nil, nil, fmt.Errorf("failed to json unmarshal 'protected': %w", err)
}
@@ -633,6 +637,65 @@ func (a *acmeState) GetIssuedCert(ac *acmeContext, accountId string, serial stri
return &cert, nil
}
func (a *acmeState) SaveEab(sc *storageContext, eab *eabType) error {
json, err := logical.StorageEntryJSON(path.Join(acmeEabPrefix, eab.KeyID), eab)
if err != nil {
return err
}
return sc.Storage.Put(sc.Context, json)
}
func (a *acmeState) LoadEab(sc *storageContext, eabKid string) (*eabType, error) {
rawEntry, err := sc.Storage.Get(sc.Context, path.Join(acmeEabPrefix, eabKid))
if err != nil {
return nil, err
}
if rawEntry == nil {
return nil, fmt.Errorf("no eab found for kid %s", eabKid)
}
var eab eabType
err = rawEntry.DecodeJSON(&eab)
if err != nil {
return nil, err
}
eab.KeyID = eabKid
return &eab, nil
}
func (a *acmeState) DeleteEab(sc *storageContext, eabKid string) (bool, error) {
rawEntry, err := sc.Storage.Get(sc.Context, path.Join(acmeEabPrefix, eabKid))
if err != nil {
return false, err
}
if rawEntry == nil {
return false, nil
}
err = sc.Storage.Delete(sc.Context, path.Join(acmeEabPrefix, eabKid))
if err != nil {
return false, err
}
return true, nil
}
func (a *acmeState) ListEabIds(sc *storageContext) ([]string, error) {
entries, err := sc.Storage.List(sc.Context, acmeEabPrefix)
if err != nil {
return nil, err
}
var ids []string
for _, entry := range entries {
if strings.HasSuffix(entry, "/") {
continue
}
ids = append(ids, entry)
}
return ids, nil
}
func getAuthorizationPath(accountId string, authId string) string {
return acmeAccountPrefix + accountId + "/authorizations/" + authId
}

View File

@@ -27,6 +27,7 @@ type acmeContext struct {
// acmeDirectory is a string that can distinguish the various acme directories we have configured
// if something needs to remain locked into a directory path structure.
acmeDirectory string
eabPolicy EabPolicy
}
type (
@@ -60,7 +61,13 @@ func (b *backend) acmeWrapper(op acmeOperation) framework.OperationFunc {
return nil, fmt.Errorf("failed to fetch ACME configuration: %w", err)
}
if isAcmeDisabled(sc, config) {
// use string form in case someone messes up our config from raw storage.
eabPolicy, err := getEabPolicyByString(string(config.EabPolicyName))
if err != nil {
return nil, err
}
if isAcmeDisabled(sc, config, eabPolicy) {
return nil, ErrAcmeDisabled
}
@@ -87,6 +94,7 @@ func (b *backend) acmeWrapper(op acmeOperation) framework.OperationFunc {
role: role,
issuer: issuer,
acmeDirectory: acmeDirectory,
eabPolicy: eabPolicy,
}
return op(acmeCtx, r, data)
@@ -185,6 +193,10 @@ func (b *backend) acmeAccountRequiredWrapper(op acmeAccountRequiredOperation) fr
return nil, fmt.Errorf("error loading account: %w", err)
}
if err = acmeCtx.eabPolicy.EnforceForExistingAccount(account); err != nil {
return nil, err
}
if account.Status != StatusValid {
// Treating "revoked" and "deactivated" as the same here.
return nil, fmt.Errorf("%w: account in status: %s", ErrUnauthorized, account.Status)
@@ -373,7 +385,7 @@ func getRequestedAcmeIssuerFromPath(data *framework.FieldData) string {
return requestedIssuer
}
func isAcmeDisabled(sc *storageContext, config *acmeConfigEntry) bool {
func isAcmeDisabled(sc *storageContext, config *acmeConfigEntry, policy EabPolicy) bool {
if !config.Enabled {
return true
}
@@ -387,8 +399,9 @@ func isAcmeDisabled(sc *storageContext, config *acmeConfigEntry) bool {
// The OS environment if true will override any configuration option.
if disableAcme {
// TODO: If EAB is enforced in the configuration, don't mark
// ACME as disabled.
if policy.OverrideEnvDisablingPublicAcme() {
return false
}
return true
}
}

View File

@@ -218,6 +218,8 @@ func Backend(conf *logical.BackendConfig) *backend {
// ACME
pathAcmeConfig(&b),
pathAcmeEabCreateList(&b),
pathAcmeEabDelete(&b),
},
Secrets: []*framework.Secret{

View File

@@ -6721,7 +6721,7 @@ func TestProperAuthing(t *testing.T) {
t.Fatal(err)
}
serial := resp.Data["serial_number"].(string)
eabKid := "13b80844-e60d-42d2-b7e9-152a8e834b90"
paths := map[string]pathAuthChecker{
"ca_chain": shouldBeUnauthedReadList,
"cert/ca_chain": shouldBeUnauthedReadList,
@@ -6839,6 +6839,8 @@ func TestProperAuthing(t *testing.T) {
"unified-crl/delta/pem": shouldBeUnauthedReadList,
"unified-ocsp": shouldBeUnauthedWriteOnly,
"unified-ocsp/dGVzdAo=": shouldBeUnauthedReadList,
"acme/eab": shouldBeAuthed,
"acme/eab/" + eabKid: shouldBeAuthed,
}
// Add ACME based paths to the test suite
@@ -6912,6 +6914,9 @@ func TestProperAuthing(t *testing.T) {
if strings.Contains(raw_path, "acme/") && strings.Contains(raw_path, "{order_id}") {
raw_path = strings.ReplaceAll(raw_path, "{order_id}", "13b80844-e60d-42d2-b7e9-152a8e834b90")
}
if strings.Contains(raw_path, "acme/eab") && strings.Contains(raw_path, "{key_id}") {
raw_path = strings.ReplaceAll(raw_path, "{key_id}", eabKid)
}
handler, present := paths[raw_path]
if !present {

View File

@@ -109,6 +109,7 @@ func (b *backend) acmeNewAccountHandler(acmeCtx *acmeContext, r *logical.Request
var contacts []string
var termsOfServiceAgreed bool
var status string
var eabData map[string]interface{}
rawContact, present := data["contact"]
if present {
@@ -152,20 +153,39 @@ func (b *backend) acmeNewAccountHandler(acmeCtx *acmeContext, r *logical.Request
}
}
// We ignore the EAB parameter as it is currently not supported.
if eabDataRaw, ok := data["externalAccountBinding"]; ok {
eabData, ok = eabDataRaw.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("%w: externalAccountBinding field was unparseable", ErrMalformed)
}
}
// We have two paths here: search or create.
if onlyReturnExisting {
return b.acmeNewAccountSearchHandler(acmeCtx, userCtx)
return b.acmeAccountSearchHandler(acmeCtx, userCtx)
}
// Pass through the /new-account API calls to this specific handler as its requirements are different
// from the account update handler.
if strings.HasSuffix(r.Path, "/new-account") {
return b.acmeNewAccountCreateHandler(acmeCtx, userCtx, contacts, termsOfServiceAgreed)
return b.acmeNewAccountCreateHandler(acmeCtx, userCtx, contacts, termsOfServiceAgreed, r, eabData)
}
return b.acmeNewAccountUpdateHandler(acmeCtx, userCtx, contacts, status)
return b.acmeNewAccountUpdateHandler(acmeCtx, userCtx, contacts, status, eabData)
}
func formatNewAccountResponse(acmeCtx *acmeContext, acct *acmeAccount, eabData map[string]interface{}) *logical.Response {
resp := formatAccountResponse(acmeCtx, acct)
// Per RFC 8555 Section 7.1.2. Account Objects
// Including this field in a newAccount request indicates approval by
// the holder of an existing non-ACME account to bind that account to
// this ACME account
if acct.Eab != nil && len(eabData) != 0 {
resp.Data["externalAccountBinding"] = eabData
}
return resp
}
func formatAccountResponse(acmeCtx *acmeContext, acct *acmeAccount) *logical.Response {
@@ -188,7 +208,7 @@ func formatAccountResponse(acmeCtx *acmeContext, acct *acmeAccount) *logical.Res
return resp
}
func (b *backend) acmeNewAccountSearchHandler(acmeCtx *acmeContext, userCtx *jwsCtx) (*logical.Response, error) {
func (b *backend) acmeAccountSearchHandler(acmeCtx *acmeContext, userCtx *jwsCtx) (*logical.Response, error) {
thumbprint, err := userCtx.GetKeyThumbprint()
if err != nil {
return nil, fmt.Errorf("failed generating thumbprint for key: %w", err)
@@ -200,6 +220,9 @@ func (b *backend) acmeNewAccountSearchHandler(acmeCtx *acmeContext, userCtx *jws
}
if account != nil {
if err = acmeCtx.eabPolicy.EnforceForExistingAccount(account); err != nil {
return nil, err
}
return formatAccountResponse(acmeCtx, account), nil
}
@@ -211,7 +234,7 @@ func (b *backend) acmeNewAccountSearchHandler(acmeCtx *acmeContext, userCtx *jws
return nil, fmt.Errorf("An account with this key does not exist: %w", ErrAccountDoesNotExist)
}
func (b *backend) acmeNewAccountCreateHandler(acmeCtx *acmeContext, userCtx *jwsCtx, contact []string, termsOfServiceAgreed bool) (*logical.Response, error) {
func (b *backend) acmeNewAccountCreateHandler(acmeCtx *acmeContext, userCtx *jwsCtx, contact []string, termsOfServiceAgreed bool, r *logical.Request, eabData map[string]interface{}) (*logical.Response, error) {
if userCtx.Existing {
return nil, fmt.Errorf("cannot submit to newAccount with 'kid': %w", ErrMalformed)
}
@@ -228,23 +251,58 @@ func (b *backend) acmeNewAccountCreateHandler(acmeCtx *acmeContext, userCtx *jws
}
if accountByKey != nil {
if err = acmeCtx.eabPolicy.EnforceForExistingAccount(accountByKey); err != nil {
return nil, err
}
return formatAccountResponse(acmeCtx, accountByKey), nil
}
var eab *eabType
if len(eabData) != 0 {
eab, err = verifyEabPayload(b.acmeState, acmeCtx, userCtx, r.Path, eabData)
if err != nil {
return nil, err
}
}
// Verify against our EAB policy
if err = acmeCtx.eabPolicy.EnforceForNewAccount(eab); err != nil {
return nil, err
}
// TODO: Limit this only when ToS are required or set by the operator, since we don't have a
// ToS URL in the directory at the moment, we can not enforce this.
//if !termsOfServiceAgreed {
// return nil, fmt.Errorf("terms of service not agreed to: %w", ErrUserActionRequired)
//}
if eab != nil {
// We delete the EAB to prevent future re-use after associating it with an account, worst
// case if we fail creating the account we simply nuked the EAB which they can create another
// and retry
wasDeleted, err := b.acmeState.DeleteEab(acmeCtx.sc, eab.KeyID)
if err != nil {
return nil, fmt.Errorf("failed to delete eab reference: %w", err)
}
if !wasDeleted {
// Something consumed our EAB before we did bail...
return nil, fmt.Errorf("eab was already used: %w", ErrUnauthorized)
}
}
b.acmeAccountLock.RLock() // Prevents Account Creation and Tidy Interfering
defer b.acmeAccountLock.RUnlock()
accountByKid, err := b.acmeState.CreateAccount(acmeCtx, userCtx, contact, termsOfServiceAgreed)
accountByKid, err := b.acmeState.CreateAccount(acmeCtx, userCtx, contact, termsOfServiceAgreed, eab)
if err != nil {
if eab != nil {
return nil, fmt.Errorf("failed to create account: %w; the EAB key used for this request has been deleted as a result of this operation; fetch a new EAB key before retrying", err)
}
return nil, fmt.Errorf("failed to create account: %w", err)
}
resp := formatAccountResponse(acmeCtx, accountByKid)
resp := formatNewAccountResponse(acmeCtx, accountByKid, eabData)
// Per RFC 8555 Section 7.3. Account Management:
//
@@ -254,16 +312,24 @@ func (b *backend) acmeNewAccountCreateHandler(acmeCtx *acmeContext, userCtx *jws
return resp, nil
}
func (b *backend) acmeNewAccountUpdateHandler(acmeCtx *acmeContext, userCtx *jwsCtx, contact []string, status string) (*logical.Response, error) {
func (b *backend) acmeNewAccountUpdateHandler(acmeCtx *acmeContext, userCtx *jwsCtx, contact []string, status string, eabData map[string]interface{}) (*logical.Response, error) {
if !userCtx.Existing {
return nil, fmt.Errorf("cannot submit to account updates without a 'kid': %w", ErrMalformed)
}
if len(eabData) != 0 {
return nil, fmt.Errorf("%w: not allowed to update EAB data in accounts", ErrMalformed)
}
account, err := b.acmeState.LoadAccount(acmeCtx, userCtx.Kid)
if err != nil {
return nil, fmt.Errorf("error loading account: %w", err)
}
if err = acmeCtx.eabPolicy.EnforceForExistingAccount(account); err != nil {
return nil, err
}
// Per RFC 8555 7.3.6 Account deactivation, if we were previously deactivated, we should return
// unauthorized. There is no way to reactivate any accounts per ACME RFC.
if account.Status != StatusValid {

View File

@@ -48,8 +48,9 @@ func (b *backend) acmeDirectoryHandler(acmeCtx *acmeContext, r *logical.Request,
"newOrder": acmeCtx.baseUrl.JoinPath("new-order").String(),
"revokeCert": acmeCtx.baseUrl.JoinPath("revoke-cert").String(),
"keyChange": acmeCtx.baseUrl.JoinPath("key-change").String(),
// This is purposefully missing newAuthz as we don't support pre-authorization
"meta": map[string]interface{}{
"externalAccountRequired": false,
"externalAccountRequired": acmeCtx.eabPolicy.IsExternalAccountRequired(),
},
})
if err != nil {

View File

@@ -0,0 +1,200 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package pki
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/x509"
"encoding/base64"
"fmt"
"io"
"time"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
/*
* This file unlike the other path_acme_xxx.go are VAULT APIs to manage the
* ACME External Account Bindings, this isn't providing any APIs that an ACME
* client would use.
*/
func pathAcmeEabCreateList(b *backend) *framework.Path {
return &framework.Path{
Pattern: "acme/eab",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: operationPrefixPKI,
},
Fields: map[string]*framework.FieldSchema{},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ListOperation: &framework.PathOperation{
DisplayAttrs: &framework.DisplayAttributes{
OperationSuffix: "acme-configuration",
},
Callback: b.pathAcmeListEab,
},
logical.UpdateOperation: &framework.PathOperation{
Callback: b.pathAcmeCreateEab,
ForwardPerformanceSecondary: false,
ForwardPerformanceStandby: true,
DisplayAttrs: &framework.DisplayAttributes{
OperationVerb: "configure",
OperationSuffix: "acme",
},
},
},
HelpSynopsis: "",
HelpDescription: "",
}
}
func pathAcmeEabDelete(b *backend) *framework.Path {
return &framework.Path{
Pattern: "acme/eab/" + uuidNameRegex("key_id"),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: operationPrefixPKI,
},
Fields: map[string]*framework.FieldSchema{
"key_id": {
Type: framework.TypeString,
Description: "EAB key identifier",
Required: true,
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.DeleteOperation: &framework.PathOperation{
DisplayAttrs: &framework.DisplayAttributes{
OperationSuffix: "acme-configuration",
},
Callback: b.pathAcmeDeleteEab,
ForwardPerformanceSecondary: false,
ForwardPerformanceStandby: true,
},
},
HelpSynopsis: "",
HelpDescription: "",
}
}
type eabType struct {
KeyID string `json:"-"`
KeyType string `json:"key-type"`
KeyBits string `json:"key-bits"`
MacKey []byte `json:"mac-key"`
CreatedOn time.Time `json:"created-on"`
}
func (b *backend) pathAcmeListEab(ctx context.Context, r *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
sc := b.makeStorageContext(ctx, r.Storage)
eabIds, err := b.acmeState.ListEabIds(sc)
if err != nil {
return nil, err
}
var warnings []string
var keyIds []string
keyInfos := map[string]interface{}{}
for _, eabKey := range eabIds {
eab, err := b.acmeState.LoadEab(sc, eabKey)
if err != nil {
warnings = append(warnings, fmt.Sprintf("failed loading eab entry %s: %v", eabKey, err))
continue
}
keyIds = append(keyIds, eab.KeyID)
keyInfos[eab.KeyID] = map[string]interface{}{
"key_type": eab.KeyType,
"key_bits": eab.KeyBits,
"created_on": eab.CreatedOn.Format(time.RFC3339),
}
}
resp := logical.ListResponseWithInfo(keyIds, keyInfos)
for _, warning := range warnings {
resp.AddWarning(warning)
}
return resp, nil
}
func (b *backend) pathAcmeCreateEab(ctx context.Context, r *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
kid := genUuid()
macKey, err := generateEabKey(b.GetRandomReader())
if err != nil {
return nil, fmt.Errorf("failed generating eab key: %w", err)
}
eab := &eabType{
KeyID: kid,
KeyType: "ec",
KeyBits: "256",
MacKey: macKey,
CreatedOn: time.Now(),
}
sc := b.makeStorageContext(ctx, r.Storage)
err = b.acmeState.SaveEab(sc, eab)
if err != nil {
return nil, fmt.Errorf("failed saving generated eab: %w", err)
}
encodedKey := base64.RawURLEncoding.EncodeToString(macKey)
return &logical.Response{
Data: map[string]interface{}{
"id": eab.KeyID,
"key_type": eab.KeyType,
"key_bits": eab.KeyBits,
"private_key": encodedKey,
"created_on": eab.CreatedOn.Format(time.RFC3339),
},
}, nil
}
func (b *backend) pathAcmeDeleteEab(ctx context.Context, r *logical.Request, d *framework.FieldData) (*logical.Response, error) {
sc := b.makeStorageContext(ctx, r.Storage)
keyId := d.Get("key_id").(string)
_, err := uuid.ParseUUID(keyId)
if err != nil {
return nil, fmt.Errorf("badly formatted key_id field")
}
deleted, err := b.acmeState.DeleteEab(sc, keyId)
if err != nil {
return nil, fmt.Errorf("failed deleting key id: %w", err)
}
resp := &logical.Response{}
if !deleted {
resp.AddWarning("No key id found with id: " + keyId)
}
return resp, nil
}
func generateEabKey(random io.Reader) ([]byte, error) {
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), random)
if err != nil {
return nil, err
}
keyBytes, err := x509.MarshalECPrivateKey(ecKey)
if err != nil {
return nil, err
}
return keyBytes, nil
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package pki
import (
"crypto/x509"
"encoding/base64"
"testing"
"time"
"github.com/hashicorp/go-uuid"
"github.com/stretchr/testify/require"
)
// TestACME_EabVaultAPIs verify the various Vault auth'd APIs for EAB work as expected,
// with creation, listing and deletions.
func TestACME_EabVaultAPIs(t *testing.T) {
b, s := CreateBackendWithStorage(t)
var ids []string
// Generate an EAB
resp, err := CBWrite(b, s, "acme/eab", map[string]interface{}{})
requireSuccessNonNilResponse(t, resp, err, "Failed generating eab")
requireFieldsSetInResp(t, resp, "id", "key_type", "key_bits", "private_key", "created_on")
require.Equal(t, "ec", resp.Data["key_type"])
require.Equal(t, "256", resp.Data["key_bits"])
ids = append(ids, resp.Data["id"].(string))
_, err = uuid.ParseUUID(resp.Data["id"].(string))
require.NoError(t, err, "failed parsing id as a uuid")
key, err := base64.RawURLEncoding.DecodeString(resp.Data["private_key"].(string))
require.NoError(t, err, "failed base64 decoding private key")
_, err = x509.ParseECPrivateKey(key)
require.NoError(t, err, "failed parsing private key")
// Generate another EAB
resp, err = CBWrite(b, s, "acme/eab", map[string]interface{}{})
requireSuccessNonNilResponse(t, resp, err, "Failed generating eab")
ids = append(ids, resp.Data["id"].(string))
// List our EABs
resp, err = CBList(b, s, "acme/eab")
requireSuccessNonNilResponse(t, resp, err, "failed list")
require.ElementsMatch(t, ids, resp.Data["keys"])
keyInfo := resp.Data["key_info"].(map[string]interface{})
id0Map := keyInfo[ids[0]].(map[string]interface{})
require.Equal(t, "ec", id0Map["key_type"])
require.Equal(t, "256", id0Map["key_bits"])
require.NotEmpty(t, id0Map["created_on"])
_, err = time.Parse(time.RFC3339, id0Map["created_on"].(string))
require.NoError(t, err, "failed to parse created_on date: %s", id0Map["created_on"])
id1Map := keyInfo[ids[1]].(map[string]interface{})
require.Equal(t, "ec", id1Map["key_type"])
require.Equal(t, "256", id1Map["key_bits"])
require.NotEmpty(t, id1Map["created_on"])
// Delete an EAB
resp, err = CBDelete(b, s, "acme/eab/"+ids[0])
requireSuccessNonNilResponse(t, resp, err, "failed deleting eab identifier")
require.Len(t, resp.Warnings, 0, "no warnings should have been set on delete")
// Make sure it's really gone
resp, err = CBList(b, s, "acme/eab")
requireSuccessNonNilResponse(t, resp, err, "failed list post delete")
require.Len(t, resp.Data["keys"], 1)
require.Contains(t, resp.Data["keys"], ids[1])
// Delete the same EAB again, we should just get a warning but still success.
resp, err = CBDelete(b, s, "acme/eab/"+ids[0])
requireSuccessNonNilResponse(t, resp, err, "failed deleting eab identifier")
require.Len(t, resp.Warnings, 1, "expected a warning to be set on repeated delete call")
}

View File

@@ -73,6 +73,7 @@ func TestAcmeBasicWorkflow(t *testing.T) {
require.Equal(t, discoveryBaseUrl+"new-order", discovery.OrderURL)
require.Equal(t, discoveryBaseUrl+"revoke-cert", discovery.RevokeURL)
require.Equal(t, discoveryBaseUrl+"key-change", discovery.KeyChangeURL)
require.False(t, discovery.ExternalAccountRequired, "bad value for external account required in directory")
// Attempt to update prior to creating an account
t.Logf("Testing updates with no proper account fail on %s", baseAcmeURL)
@@ -310,6 +311,75 @@ func TestAcmeBasicWorkflow(t *testing.T) {
}
}
// TestAcmeBasicWorkflowWithEab verify that new accounts require EAB's if enforced by configuration.
func TestAcmeBasicWorkflowWithEab(t *testing.T) {
t.Parallel()
cluster, client, _ := setupAcmeBackend(t)
defer cluster.Cleanup()
testCtx := context.Background()
// Enable EAB
_, err := client.Logical().WriteWithContext(context.Background(), "pki/config/acme", map[string]interface{}{
"enabled": true,
"eab_policy": "always-required",
})
require.NoError(t, err)
baseAcmeURL := "/v1/pki/acme/"
accountKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "failed creating ec key")
acmeClient := getAcmeClientForCluster(t, cluster, baseAcmeURL, accountKey)
t.Logf("Testing discover on %s", baseAcmeURL)
discovery, err := acmeClient.Discover(testCtx)
require.NoError(t, err, "failed acme discovery call")
require.True(t, discovery.ExternalAccountRequired, "bad value for external account required in directory")
// Create new account without EAB, should fail
t.Logf("Testing register on %s", baseAcmeURL)
_, err = acmeClient.Register(testCtx, &acme.Account{}, func(tosURL string) bool { return true })
require.ErrorContains(t, err, "urn:ietf:params:acme:error:externalAccountRequired",
"expected failure creating an account without eab")
kid, eabKeyBytes := getEABKey(t, client)
acct := &acme.Account{
ExternalAccountBinding: &acme.ExternalAccountBinding{
KID: kid,
Key: eabKeyBytes,
},
}
// Create new account with EAB
t.Logf("Testing register on %s", baseAcmeURL)
_, err = acmeClient.Register(testCtx, acct, func(tosURL string) bool { return true })
require.NoError(t, err, "failed registering new account with eab")
// Make sure our EAB is no longer available
resp, err := client.Logical().ListWithContext(context.Background(), "pki/acme/eab")
require.NoError(t, err, "failed to list eab tokens")
require.Nil(t, resp, "list response for eab tokens should have been nil due to empty list")
// Attempt to create another account with the same EAB as before -- should fail
accountKey2, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "failed creating ec key")
acmeClient2 := getAcmeClientForCluster(t, cluster, baseAcmeURL, accountKey2)
acct2 := &acme.Account{
ExternalAccountBinding: &acme.ExternalAccountBinding{
KID: kid,
Key: eabKeyBytes,
},
}
_, err = acmeClient2.Register(testCtx, acct2, func(tosURL string) bool { return true })
require.ErrorContains(t, err, "urn:ietf:params:acme:error:unauthorized", "should fail due to EAB re-use")
// We can lookup/find an existing account without EAB if we have the account key
_, err = acmeClient.GetReg(testCtx /* unused url */, "")
require.NoError(t, err, "expected to lookup existing account without eab")
}
// TestAcmeNonce a basic test that will validate we get back a nonce with the proper status codes
// based on the
func TestAcmeNonce(t *testing.T) {
@@ -477,7 +547,8 @@ func setupAcmeBackend(t *testing.T) (*vault.TestCluster, *api.Client, string) {
require.NoError(t, err)
_, err = client.Logical().WriteWithContext(context.Background(), "pki/config/acme", map[string]interface{}{
"enabled": true,
"enabled": true,
"eab_policy": "not-required",
})
require.NoError(t, err)
@@ -621,3 +692,18 @@ func getAcmeClientForCluster(t *testing.T, cluster *vault.TestCluster, baseUrl s
DirectoryURL: baseAcmeURL + "directory",
}
}
func getEABKey(t *testing.T, client *api.Client) (string, []byte) {
resp, err := client.Logical().WriteWithContext(ctx, "pki/acme/eab", map[string]interface{}{})
require.NoError(t, err, "failed getting eab key")
require.NotNil(t, resp, "eab key returned nil response")
require.NotEmpty(t, resp.Data["id"], "eab key response missing id field")
kid := resp.Data["id"].(string)
require.NotEmpty(t, resp.Data["private_key"], "eab key response missing private_key field")
base64Key := resp.Data["private_key"].(string)
privateKeyBytes, err := base64.RawURLEncoding.DecodeString(base64Key)
require.NoError(t, err, "failed base 64 decoding eab key response")
return kid, privateKeyBytes
}

View File

@@ -17,11 +17,12 @@ const (
)
type acmeConfigEntry struct {
Enabled bool `json:"enabled"`
AllowedIssuers []string `json:"allowed_issuers="`
AllowedRoles []string `json:"allowed_roles"`
DefaultRole string `json:"default_role"`
DNSResolver string `json:"dns_resolver"`
Enabled bool `json:"enabled"`
AllowedIssuers []string `json:"allowed_issuers="`
AllowedRoles []string `json:"allowed_roles"`
DefaultRole string `json:"default_role"`
DNSResolver string `json:"dns_resolver"`
EabPolicyName EabPolicyName `json:"eab_policy_name"`
}
var defaultAcmeConfig = acmeConfigEntry{
@@ -30,6 +31,7 @@ var defaultAcmeConfig = acmeConfigEntry{
AllowedRoles: []string{"*"},
DefaultRole: "",
DNSResolver: "",
EabPolicyName: eabPolicyAlwaysRequired,
}
func (sc *storageContext) getAcmeConfig() (*acmeConfigEntry, error) {
@@ -99,6 +101,11 @@ func pathAcmeConfig(b *backend) *framework.Path {
Description: `DNS resolver to use for domain resolution on this mount. Defaults to using the default system resolver. Must be in the format <host>:<port>, with both parts mandatory.`,
Default: "",
},
"eab_policy": {
Type: framework.TypeString,
Description: `Specify the policy to use for external account binding behaviour, 'not-required', 'new-account-required' or 'always-required'`,
Default: "always-required",
},
},
Operations: map[logical.Operation]framework.OperationHandler{
@@ -143,6 +150,7 @@ func genResponseFromAcmeConfig(config *acmeConfigEntry) *logical.Response {
"default_role": config.DefaultRole,
"enabled": config.Enabled,
"dns_resolver": config.DNSResolver,
"eab_policy": config.EabPolicyName,
},
}
@@ -197,6 +205,15 @@ func (b *backend) pathAcmeWrite(ctx context.Context, req *logical.Request, d *fr
}
}
if eabPolicyRaw, ok := d.GetOk("eab_policy"); ok {
eabPolicy, err := getEabPolicyByString(eabPolicyRaw.(string))
if err != nil {
return nil, fmt.Errorf("invalid eab policy name provided, valid values are '%s', '%s', '%s'",
eabPolicyNotRequired, eabPolicyNewAccountRequired, eabPolicyAlwaysRequired)
}
config.EabPolicyName = eabPolicy.Name
}
allowAnyRole := len(config.AllowedRoles) == 1 && config.AllowedRoles[0] == "*"
if !allowAnyRole {
foundDefault := len(config.DefaultRole) == 0

View File

@@ -189,7 +189,9 @@ func (vpc *VaultPkiCluster) CreateAcmeMount(mountName string) (*VaultPkiMount, e
return nil, fmt.Errorf("failed updating cluster config: %w", err)
}
cfg := map[string]interface{}{}
cfg := map[string]interface{}{
"eab_policy": "not-required",
}
if vpc.Dns != nil {
cfg["dns_resolver"] = vpc.Dns.GetRemoteAddr()
}