mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 17:52:32 +00:00
PKI: Add management APIs for ACME accounts (#29173)
* Allow a Vault operator to list, read and update PKI ACME accounts - This allows an operator to list the ACME account key ids, read the ACME account getting all the various information along with the account's associated orders and update the ACME account's status to either valid or revoked * Add tests for new ACME management APIs * Update PKI api-docs * Add cl * Add missing error handling and a few more test assertions * PR feedback * Fix Note tags within the website * Apply suggestions from docscode review Co-authored-by: Sarah Chavis <62406755+schavis@users.noreply.github.com> * Update website/content/api-docs/secret/pki/issuance.mdx * Update website/content/api-docs/secret/pki/issuance.mdx * Update website/content/api-docs/secret/pki/issuance.mdx --------- Co-authored-by: Sarah Chavis <62406755+schavis@users.noreply.github.com>
This commit is contained in:
@@ -11,7 +11,6 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -311,7 +310,22 @@ func (a *acmeState) UpdateAccount(sc *storageContext, acct *acmeAccount) error {
|
||||
// LoadAccount will load the account object based on the passed in keyId field value
|
||||
// otherwise will return an error if the account does not exist.
|
||||
func (a *acmeState) LoadAccount(ac *acmeContext, keyId string) (*acmeAccount, error) {
|
||||
entry, err := ac.sc.Storage.Get(ac.sc.Context, acmeAccountPrefix+keyId)
|
||||
acct, err := a.LoadAccountWithoutDirEnforcement(ac.sc, keyId)
|
||||
if err != nil {
|
||||
return acct, err
|
||||
}
|
||||
|
||||
if acct.AcmeDirectory != ac.acmeDirectory {
|
||||
return nil, fmt.Errorf("%w: account part of different ACME directory path", ErrMalformed)
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// LoadAccountWithoutDirEnforcement will load the account object based on the passed in keyId field value,
|
||||
// but does not enforce the ACME directory path, normally this is used by non ACME specific APIs.
|
||||
func (a *acmeState) LoadAccountWithoutDirEnforcement(sc *storageContext, keyId string) (*acmeAccount, error) {
|
||||
entry, err := sc.Storage.Get(sc.Context, acmeAccountPrefix+keyId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading account: %w", err)
|
||||
}
|
||||
@@ -324,13 +338,7 @@ func (a *acmeState) LoadAccount(ac *acmeContext, keyId string) (*acmeAccount, er
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding account: %w", err)
|
||||
}
|
||||
|
||||
if acct.AcmeDirectory != ac.acmeDirectory {
|
||||
return nil, fmt.Errorf("%w: account part of different ACME directory path", ErrMalformed)
|
||||
}
|
||||
|
||||
acct.KeyId = keyId
|
||||
|
||||
return &acct, nil
|
||||
}
|
||||
|
||||
@@ -536,6 +544,27 @@ func (a *acmeState) LoadOrder(ac *acmeContext, userCtx *jwsCtx, orderId string)
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// LoadAccountOrders will load all orders for a given account ID, this should be used by the
|
||||
// management interface only, not through any of the ACME APIs.
|
||||
func (a *acmeState) LoadAccountOrders(sc *storageContext, accountId string) ([]*acmeOrder, error) {
|
||||
orderIds, err := a.ListOrderIds(sc, accountId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed listing order ids for account id %s: %w", accountId, err)
|
||||
}
|
||||
|
||||
var orders []*acmeOrder
|
||||
for _, orderId := range orderIds {
|
||||
order, err := a.LoadOrder(&acmeContext{sc: sc}, &jwsCtx{Kid: accountId}, orderId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orders = append(orders, order)
|
||||
}
|
||||
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (a *acmeState) SaveOrder(ac *acmeContext, order *acmeOrder) error {
|
||||
if order.OrderId == "" {
|
||||
return fmt.Errorf("invalid order, missing order id")
|
||||
@@ -565,15 +594,7 @@ func (a *acmeState) ListOrderIds(sc *storageContext, accountId string) ([]string
|
||||
return nil, fmt.Errorf("failed listing order ids for account %s: %w", accountId, err)
|
||||
}
|
||||
|
||||
orderIds := []string{}
|
||||
for _, order := range rawOrderIds {
|
||||
if strings.HasSuffix(order, "/") {
|
||||
// skip any folders we might have for some reason
|
||||
continue
|
||||
}
|
||||
orderIds = append(orderIds, order)
|
||||
}
|
||||
return orderIds, nil
|
||||
return filterDirEntries(rawOrderIds), nil
|
||||
}
|
||||
|
||||
type acmeCertEntry struct {
|
||||
@@ -672,17 +693,20 @@ func (a *acmeState) ListEabIds(sc *storageContext) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []string
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry, "/") {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, entry)
|
||||
}
|
||||
ids := filterDirEntries(entries)
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (a *acmeState) ListAccountIds(sc *storageContext) ([]string, error) {
|
||||
entries, err := sc.Storage.List(sc.Context, acmeAccountPrefix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed listing ACME account prefix directory %s: %w", acmeAccountPrefix, err)
|
||||
}
|
||||
|
||||
return filterDirEntries(entries), nil
|
||||
}
|
||||
|
||||
func getAcmeSerialToAccountTrackerPath(accountId string, serial string) string {
|
||||
return acmeAccountPrefix + accountId + "/certs/" + normalizeSerial(serial)
|
||||
}
|
||||
|
||||
@@ -237,6 +237,8 @@ func Backend(conf *logical.BackendConfig) *backend {
|
||||
pathAcmeConfig(&b),
|
||||
pathAcmeEabList(&b),
|
||||
pathAcmeEabDelete(&b),
|
||||
pathAcmeMgmtAccountList(&b),
|
||||
pathAcmeMgmtAccountRead(&b),
|
||||
},
|
||||
|
||||
Secrets: []*framework.Secret{
|
||||
|
||||
@@ -6831,6 +6831,7 @@ func TestProperAuthing(t *testing.T) {
|
||||
}
|
||||
serial := resp.Data["serial_number"].(string)
|
||||
eabKid := "13b80844-e60d-42d2-b7e9-152a8e834b90"
|
||||
acmeKeyId := "hrKmDYTvicHoHGVN2-3uzZV_BPGdE0W_dNaqYTtYqeo="
|
||||
paths := map[string]pathAuthChecker{
|
||||
"ca_chain": shouldBeUnauthedReadList,
|
||||
"cert/ca_chain": shouldBeUnauthedReadList,
|
||||
@@ -6950,6 +6951,8 @@ func TestProperAuthing(t *testing.T) {
|
||||
"unified-ocsp/dGVzdAo=": shouldBeUnauthedReadList,
|
||||
"eab/": shouldBeAuthed,
|
||||
"eab/" + eabKid: shouldBeAuthed,
|
||||
"acme/mgmt/account/keyid/": shouldBeAuthed,
|
||||
"acme/mgmt/account/keyid/" + acmeKeyId: shouldBeAuthed,
|
||||
}
|
||||
|
||||
entPaths := getEntProperAuthingPaths(serial)
|
||||
@@ -7020,7 +7023,10 @@ func TestProperAuthing(t *testing.T) {
|
||||
raw_path = strings.ReplaceAll(raw_path, "{serial}", serial)
|
||||
}
|
||||
if strings.Contains(raw_path, "acme/account/") && strings.Contains(raw_path, "{kid}") {
|
||||
raw_path = strings.ReplaceAll(raw_path, "{kid}", "hrKmDYTvicHoHGVN2-3uzZV_BPGdE0W_dNaqYTtYqeo=")
|
||||
raw_path = strings.ReplaceAll(raw_path, "{kid}", acmeKeyId)
|
||||
}
|
||||
if strings.Contains(raw_path, "acme/mgmt/account/") && strings.Contains(raw_path, "{keyid}") {
|
||||
raw_path = strings.ReplaceAll(raw_path, "{keyid}", acmeKeyId)
|
||||
}
|
||||
if strings.Contains(raw_path, "acme/") && strings.Contains(raw_path, "{auth_id}") {
|
||||
raw_path = strings.ReplaceAll(raw_path, "{auth_id}", "29da8c38-7a09-465e-b9a6-3d76802b1afd")
|
||||
|
||||
226
builtin/logical/pki/path_acme_account_mgmt.go
Normal file
226
builtin/logical/pki/path_acme_account_mgmt.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// Copyright (c) HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package pki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
func pathAcmeMgmtAccountList(b *backend) *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "acme/mgmt/account/keyid/?$",
|
||||
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.ListOperation: &framework.PathOperation{
|
||||
Callback: b.pathAcmeMgmtListAccounts,
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: operationPrefixPKI,
|
||||
OperationVerb: "list-acme-account-keys",
|
||||
Description: "List all ACME account key identifiers.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
HelpSynopsis: "List all ACME account key identifiers.",
|
||||
HelpDescription: `Allows an operator to list all ACME account key identifiers.`,
|
||||
}
|
||||
}
|
||||
|
||||
func pathAcmeMgmtAccountRead(b *backend) *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "acme/mgmt/account/keyid/" + framework.GenericNameRegex("keyid"),
|
||||
Fields: map[string]*framework.FieldSchema{
|
||||
"keyid": {
|
||||
Type: framework.TypeString,
|
||||
Description: "The key identifier of the account.",
|
||||
Required: true,
|
||||
},
|
||||
"status": {
|
||||
Type: framework.TypeString,
|
||||
Description: "The status of the account.",
|
||||
Required: true,
|
||||
AllowedValues: []interface{}{AccountStatusValid.String(), AccountStatusRevoked.String()},
|
||||
},
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.ReadOperation: &framework.PathOperation{
|
||||
Callback: b.pathAcmeMgmtReadAccount,
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: operationPrefixPKI,
|
||||
OperationSuffix: "acme-key-id",
|
||||
},
|
||||
},
|
||||
logical.UpdateOperation: &framework.PathOperation{
|
||||
Callback: b.pathAcmeMgmtUpdateAccount,
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: operationPrefixPKI,
|
||||
OperationSuffix: "acme-key-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
HelpSynopsis: "Fetch the details or update the status of an ACME account by key identifier.",
|
||||
HelpDescription: `Allows an operator to retrieve details of an ACME account and to update the account status.`,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backend) pathAcmeMgmtListAccounts(ctx context.Context, r *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
||||
sc := b.makeStorageContext(ctx, r.Storage)
|
||||
|
||||
accountIds, err := b.GetAcmeState().ListAccountIds(sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return logical.ListResponse(accountIds), nil
|
||||
}
|
||||
|
||||
func (b *backend) pathAcmeMgmtReadAccount(ctx context.Context, r *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
||||
keyId := d.Get("keyid").(string)
|
||||
if len(keyId) == 0 {
|
||||
return logical.ErrorResponse("keyid is required"), logical.ErrInvalidRequest
|
||||
}
|
||||
|
||||
sc := b.makeStorageContext(ctx, r.Storage)
|
||||
as := b.GetAcmeState()
|
||||
|
||||
accountEntry, err := as.LoadAccountWithoutDirEnforcement(sc, keyId)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAccountDoesNotExist) {
|
||||
return logical.ErrorResponse("ACME key id %s did not exist", keyId), logical.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed loading ACME account id %q: %w", keyId, err)
|
||||
}
|
||||
|
||||
orders, err := as.LoadAccountOrders(sc, accountEntry.KeyId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed loading orders for account %q: %w", accountEntry.KeyId, err)
|
||||
}
|
||||
|
||||
orderData := make([]map[string]interface{}, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
orderData = append(orderData, acmeOrderToDataMap(order))
|
||||
}
|
||||
|
||||
dataMap := acmeAccountToDataMap(accountEntry)
|
||||
dataMap["orders"] = orderData
|
||||
return &logical.Response{Data: dataMap}, nil
|
||||
}
|
||||
|
||||
func (b *backend) pathAcmeMgmtUpdateAccount(ctx context.Context, r *logical.Request, d *framework.FieldData) (*logical.Response, error) {
|
||||
keyId := d.Get("keyid").(string)
|
||||
if len(keyId) == 0 {
|
||||
return logical.ErrorResponse("keyid is required"), logical.ErrInvalidRequest
|
||||
}
|
||||
|
||||
status, err := convertToAccountStatus(d.Get("status"))
|
||||
if err != nil {
|
||||
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
|
||||
}
|
||||
if status != AccountStatusValid && status != AccountStatusRevoked {
|
||||
return logical.ErrorResponse("invalid status %q", status), logical.ErrInvalidRequest
|
||||
}
|
||||
|
||||
sc := b.makeStorageContext(ctx, r.Storage)
|
||||
as := b.GetAcmeState()
|
||||
|
||||
accountEntry, err := as.LoadAccountWithoutDirEnforcement(sc, keyId)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAccountDoesNotExist) {
|
||||
return logical.ErrorResponse("ACME key id %q did not exist", keyId), logical.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed loading ACME account id %q: %w", keyId, err)
|
||||
}
|
||||
|
||||
if accountEntry.Status != status {
|
||||
accountEntry.Status = status
|
||||
|
||||
switch status {
|
||||
case AccountStatusRevoked:
|
||||
accountEntry.AccountRevokedDate = time.Now()
|
||||
case AccountStatusValid:
|
||||
accountEntry.AccountRevokedDate = time.Time{}
|
||||
}
|
||||
|
||||
if err := as.UpdateAccount(sc, accountEntry); err != nil {
|
||||
return nil, fmt.Errorf("failed saving account %q: %w", keyId, err)
|
||||
}
|
||||
}
|
||||
|
||||
dataMap := acmeAccountToDataMap(accountEntry)
|
||||
return &logical.Response{Data: dataMap}, nil
|
||||
}
|
||||
|
||||
func convertToAccountStatus(status any) (ACMEAccountStatus, error) {
|
||||
if status == nil {
|
||||
return "", fmt.Errorf("status is required")
|
||||
}
|
||||
|
||||
statusStr, ok := status.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("status must be a string")
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(statusStr)) {
|
||||
case AccountStatusValid.String():
|
||||
return AccountStatusValid, nil
|
||||
case AccountStatusRevoked.String():
|
||||
return AccountStatusRevoked, nil
|
||||
case AccountStatusDeactivated.String():
|
||||
return AccountStatusDeactivated, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid status %q", statusStr)
|
||||
}
|
||||
}
|
||||
|
||||
func acmeAccountToDataMap(accountEntry *acmeAccount) map[string]interface{} {
|
||||
revokedDate := ""
|
||||
if !accountEntry.AccountRevokedDate.IsZero() {
|
||||
revokedDate = accountEntry.AccountRevokedDate.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
eab := map[string]string{}
|
||||
if accountEntry.Eab != nil {
|
||||
eab["eab_id"] = accountEntry.Eab.KeyID
|
||||
eab["directory"] = accountEntry.Eab.AcmeDirectory
|
||||
eab["created_time"] = accountEntry.Eab.CreatedOn.Format(time.RFC3339)
|
||||
eab["key_type"] = accountEntry.Eab.KeyType
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"key_id": accountEntry.KeyId,
|
||||
"status": accountEntry.Status,
|
||||
"contacts": accountEntry.Contact,
|
||||
"created_time": accountEntry.AccountCreatedDate.Format(time.RFC3339),
|
||||
"revoked_time": revokedDate,
|
||||
"directory": accountEntry.AcmeDirectory,
|
||||
"eab": eab,
|
||||
}
|
||||
}
|
||||
|
||||
func acmeOrderToDataMap(order *acmeOrder) map[string]interface{} {
|
||||
identifiers := make([]string, 0, len(order.Identifiers))
|
||||
for _, identifier := range order.Identifiers {
|
||||
identifiers = append(identifiers, identifier.Value)
|
||||
}
|
||||
var certExpiry string
|
||||
if !order.CertificateExpiry.IsZero() {
|
||||
certExpiry = order.CertificateExpiry.Format(time.RFC3339)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"order_id": order.OrderId,
|
||||
"status": string(order.Status),
|
||||
"identifiers": identifiers,
|
||||
"cert_serial_number": strings.ReplaceAll(order.CertificateSerialNumber, "-", ":"),
|
||||
"cert_expiry": certExpiry,
|
||||
"order_expiry": order.Expires.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,7 @@ a warning that it did not exist.`,
|
||||
}
|
||||
|
||||
type eabType struct {
|
||||
KeyID string `json:"-"`
|
||||
KeyID string `json:"key-id"`
|
||||
KeyType string `json:"key-type"`
|
||||
PrivateBytes []byte `json:"private-bytes"`
|
||||
AcmeDirectory string `json:"acme-directory"`
|
||||
|
||||
@@ -1625,7 +1625,7 @@ func TestAcmeRevocationAcrossAccounts(t *testing.T) {
|
||||
|
||||
acmeClient1 := getAcmeClientForCluster(t, cluster, baseAcmeURL, accountKey1)
|
||||
|
||||
leafKey, certs := doACMEWorkflow(t, vaultClient, acmeClient1)
|
||||
_, leafKey, certs := doACMEWorkflow(t, vaultClient, acmeClient1)
|
||||
acmeCert, err := x509.ParseCertificate(certs[0])
|
||||
require.NoError(t, err, "failed parsing acme cert bytes")
|
||||
|
||||
@@ -1675,7 +1675,7 @@ func TestAcmeRevocationAcrossAccounts(t *testing.T) {
|
||||
"revocation time was not greater than 0, cert was not revoked: %v", revocationTimeInt)
|
||||
|
||||
// Make sure we can revoke a certificate without a registered ACME account
|
||||
leafKey2, certs2 := doACMEWorkflow(t, vaultClient, acmeClient1)
|
||||
_, leafKey2, certs2 := doACMEWorkflow(t, vaultClient, acmeClient1)
|
||||
|
||||
acmeClient3 := getAcmeClientForCluster(t, cluster, baseAcmeURL, nil)
|
||||
err = acmeClient3.RevokeCert(ctx, leafKey2, certs2[0], acme.CRLReasonUnspecified)
|
||||
@@ -1781,7 +1781,94 @@ func TestAcmeMaxTTL(t *testing.T) {
|
||||
require.Less(t, acmeCertNotAfter, maxTTL.Add(buffer), "ACME cert: %v should have been less than max TTL was %v", acmeCert.NotAfter, maxTTL)
|
||||
}
|
||||
|
||||
func doACMEWorkflow(t *testing.T, vaultClient *api.Client, acmeClient *acme.Client) (*ecdsa.PrivateKey, [][]byte) {
|
||||
// TestVaultOperatorACMEDisableWorkflow validates that the Vault management API for ACME accounts works as expected.
|
||||
func TestVaultOperatorACMEDisableWorkflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
cluster, vaultClient, _ := setupAcmeBackend(t)
|
||||
defer cluster.Cleanup()
|
||||
testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Make sure we can call list on an empty ACME mount
|
||||
resp, err := vaultClient.Logical().ListWithContext(testCtx, "pki/acme/mgmt/account/keyid")
|
||||
require.NoError(t, err, "failed listing acme accounts")
|
||||
require.Nil(t, resp, "expected nil, nil response on list of an empty mount")
|
||||
|
||||
// Make sure we get nil, nil response when trying to read a non-existent key (the API returns this for a 404)
|
||||
resp, err = vaultClient.Logical().ReadWithContext(testCtx, "pki/acme/mgmt/account/keyid/doesnotexist")
|
||||
require.NoError(t, err, "failed reading non-existent ACME key")
|
||||
require.Nil(t, resp, "expected nil, nil response on a non-existent ACME key")
|
||||
|
||||
// Make sure we get an error response when trying to write to a non-existent key, unlike read the write call returns an error.
|
||||
_, err = vaultClient.Logical().WriteWithContext(testCtx, "pki/acme/mgmt/account/keyid/doesnotexist", map[string]interface{}{"status": "valid"})
|
||||
require.ErrorContains(t, err, "did not exist", "failed writing non-existent ACME key")
|
||||
|
||||
baseAcmeURL := "/v1/pki/acme/"
|
||||
accountKey1, err := cryptoutil.GenerateRSAKey(rand.Reader, 2048)
|
||||
require.NoError(t, err, "failed creating rsa key")
|
||||
|
||||
acmeClient := getAcmeClientForCluster(t, cluster, baseAcmeURL, accountKey1)
|
||||
acct, _, _ := doACMEWorkflow(t, vaultClient, acmeClient)
|
||||
|
||||
// ACME client KID is formatted as https://127.0.0.1:60777/v1/pki/acme/account/45f52b66-a3ed-4080-dec7-cdcff1ef189f
|
||||
acmeClientKid := acmeClient.KID
|
||||
|
||||
// Make sure we can call list on an empty ACME mount
|
||||
resp, err = vaultClient.Logical().ListWithContext(testCtx, "pki/acme/mgmt/account/keyid")
|
||||
require.NoError(t, err, "failed listing acme accounts")
|
||||
require.NotNil(t, resp, "expected non-nil response on list on ACME keyid")
|
||||
keysFromList := resp.Data["keys"].([]interface{})
|
||||
require.Len(t, keysFromList, 1, "expected one key in the list")
|
||||
kid := keysFromList[0].(string)
|
||||
require.True(t, strings.HasSuffix(string(acmeClientKid), kid), "expected key to match the one the ACME client has")
|
||||
|
||||
// Make sure we can read the key we just listed
|
||||
resp, err = vaultClient.Logical().ReadWithContext(testCtx, "pki/acme/mgmt/account/keyid/"+kid)
|
||||
require.NoError(t, err, "failed reading ACME with account key")
|
||||
require.NotNil(t, resp, "read response was nil on ACME keyid")
|
||||
require.Equal(t, "acme/", resp.Data["directory"], "expected directory field in response")
|
||||
require.Equal(t, kid, resp.Data["key_id"], "expected key_id field in response")
|
||||
require.Equal(t, "valid", resp.Data["status"], "expected status field in response")
|
||||
require.NotEmpty(t, resp.Data["created_time"], "expected created_time field in response")
|
||||
require.NotEmpty(t, resp.Data["orders"], "expected orders field in response")
|
||||
require.Empty(t, resp.Data["revoked_time"], "expected revoked_time field in response")
|
||||
require.Empty(t, resp.Data["eab"], "expected eab field in response to be empty")
|
||||
orders := resp.Data["orders"].([]interface{})
|
||||
require.Len(t, orders, 1, "expected one order in the list")
|
||||
order := orders[0].(map[string]interface{})
|
||||
require.NotEmpty(t, order["order_id"], "expected order_id field in response")
|
||||
require.NotEmpty(t, order["cert_expiry"], "expected cert_expiry field in response")
|
||||
require.NotEmpty(t, order["cert_serial_number"], "expected cert_serial_number field in response")
|
||||
|
||||
// Make sure we can update the status of the account to revoked and the revoked_time field is set
|
||||
resp, err = vaultClient.Logical().WriteWithContext(testCtx, "pki/acme/mgmt/account/keyid/"+kid, map[string]interface{}{"status": "revoked"})
|
||||
require.NoError(t, err, "failed updating writing ACME with account key")
|
||||
require.NotNil(t, resp, "expected non-nil response on write to ACME keyid")
|
||||
require.Equal(t, "acme/", resp.Data["directory"], "expected directory field in response")
|
||||
require.Equal(t, kid, resp.Data["key_id"], "expected key_id field in response")
|
||||
require.Equal(t, "revoked", resp.Data["status"], "expected status field in response")
|
||||
require.NotEmpty(t, resp.Data["created_time"], "expected created_time field in response")
|
||||
require.NotEmpty(t, resp.Data["revoked_time"], "expected revoked_time field in response")
|
||||
require.Empty(t, resp.Data["eab"], "expected eab field in response to be empty")
|
||||
require.Empty(t, resp.Data["orders"], "write response should not contain orders")
|
||||
|
||||
// Now make sure that we can't use the ACME account anymore
|
||||
identifiers := []string{"*.localdomain"}
|
||||
_, err = acmeClient.AuthorizeOrder(testCtx, []acme.AuthzID{
|
||||
{Type: "dns", Value: identifiers[0]},
|
||||
})
|
||||
require.ErrorContains(t, err, "account in status: revoked", "Requesting an order with a revoked account should have failed")
|
||||
|
||||
// Switch the account back to valid and make sure we can use it again
|
||||
resp, err = vaultClient.Logical().WriteWithContext(testCtx, "pki/acme/mgmt/account/keyid/"+kid, map[string]interface{}{"status": "valid"})
|
||||
require.NoError(t, err, "failed updating writing ACME with account key")
|
||||
require.Empty(t, resp.Data["revoked_time"], "revoked_time should have been reset")
|
||||
require.Equal(t, "valid", resp.Data["status"], "status should have been reset to valid")
|
||||
|
||||
doACMEOrderWorkflow(t, vaultClient, acmeClient, acct)
|
||||
}
|
||||
|
||||
func doACMEWorkflow(t *testing.T, vaultClient *api.Client, acmeClient *acme.Client) (*acme.Account, *ecdsa.PrivateKey, [][]byte) {
|
||||
testCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -1796,6 +1883,14 @@ func doACMEWorkflow(t *testing.T, vaultClient *api.Client, acmeClient *acme.Clie
|
||||
}
|
||||
}
|
||||
|
||||
csrKey, certs := doACMEOrderWorkflow(t, vaultClient, acmeClient, acct)
|
||||
return acct, csrKey, certs
|
||||
}
|
||||
|
||||
func doACMEOrderWorkflow(t *testing.T, vaultClient *api.Client, acmeClient *acme.Client, acct *acme.Account) (*ecdsa.PrivateKey, [][]byte) {
|
||||
testCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create an order
|
||||
identifiers := []string{"*.localdomain"}
|
||||
order, err := acmeClient.AuthorizeOrder(testCtx, []acme.AuthzID{
|
||||
|
||||
@@ -829,3 +829,15 @@ func fetchRevocationInfo(sc pki_backend.StorageContext, serial string) (*revocat
|
||||
|
||||
return revInfo, nil
|
||||
}
|
||||
|
||||
// filterDirEntries filters out directory entries from a list of entries normally from a List operation.
|
||||
func filterDirEntries(entries []string) []string {
|
||||
ids := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry, "/") {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, entry)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
3
changelog/29173.txt
Normal file
3
changelog/29173.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
```release-note:improvement
|
||||
secrets/pki: Add a new set of APIs that allow listing ACME account key ids, retrieving ACME account information along with the associated order and certificate information and updating an ACME account's status
|
||||
```
|
||||
@@ -13,6 +13,9 @@ description: This is the API documentation for the issuance protocol support in
|
||||
- [Delete Unused ACME EAB Binding Tokens](#delete-unused-acme-eab-binding-tokens)
|
||||
- [Get ACME Configuration](#get-acme-configuration)
|
||||
- [Set ACME Configuration](#set-acme-configuration)
|
||||
- [List ACME Account Keys](#list-acme-account-keys)
|
||||
- [Get ACME Account Info](#get-acme-account-info)
|
||||
- [Update ACME Account Info](#update-acme-account-info)
|
||||
- [EST - Certificate Issuance <EnterpriseAlert inline="true" />](#est-certificate-issuance)
|
||||
- [EST Protocol Paths <EnterpriseAlert inline="true" />](#est-protocol-paths)
|
||||
- [Read EST Configuration <EnterpriseAlert inline="true" />](#read-est-configuration)
|
||||
@@ -109,9 +112,13 @@ fetch an EAB token and pass it to the ACME client for use on the initial
|
||||
registration: this binds the ACME client's registration to an authenticated
|
||||
Vault endpoint, but not further to the client's entity or other information.
|
||||
|
||||
~> Note: Enabling EAB is strongly recommended for public-facing Vault
|
||||
deployments. Use of the `VAULT_DISABLE_PUBLIC_ACME` environment variable
|
||||
can be used to enforce all ACME instances have EAB enabled.
|
||||
<Note title="Require EAB for public-facing Vault deployments">
|
||||
|
||||
We strongly recommend enabling EAB for public-facing Vault
|
||||
deployments. Use the `VAULT_DISABLE_PUBLIC_ACME` environment
|
||||
variable to force-enable EAB for all ACME instances.
|
||||
|
||||
</Note>
|
||||
|
||||
#### ACME accounts
|
||||
|
||||
@@ -367,6 +374,148 @@ $ curl \
|
||||
}
|
||||
```
|
||||
|
||||
### List ACME account keys
|
||||
|
||||
The `ListAcmeAccountKeys` endpoint returns a list of ACME account key
|
||||
identifiers.
|
||||
|
||||
| Method | Path |
|
||||
|:-------|:-------------------------------|
|
||||
| `LIST` | `/pki/acme/mgmt/account/keyid` |
|
||||
|
||||
#### Sample request
|
||||
|
||||
```
|
||||
$ curl \
|
||||
--header "X-Vault-Token: ..." \
|
||||
--request LIST \
|
||||
http://127.0.0.1:8200/v1/pki/acme/mgmt/account/keyid
|
||||
```
|
||||
|
||||
#### Sample response
|
||||
|
||||
```
|
||||
{
|
||||
"data": {
|
||||
"keys": [
|
||||
"2ea9859a-eba8-ff24-cd03-2a51639fc7d5"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get ACME account info
|
||||
|
||||
The `GetAcmeAccountInfo` endpoint returns account information,
|
||||
including orders and certificate details, for the provided ACME account
|
||||
key.
|
||||
|
||||
| Method | Path |
|
||||
|:-------|:---------------------------------------|
|
||||
| `GET` | `/pki/acme/mgmt/account/keyid/:key_id` |
|
||||
|
||||
#### Path parameters
|
||||
|
||||
- `key_id` `(string: <required>)` - ID of the target ACME account.
|
||||
|
||||
#### Sample request
|
||||
|
||||
```
|
||||
$ curl \
|
||||
--header "X-Vault-Token: ..." \
|
||||
http://127.0.0.1:8200/v1/pki/acme/mgmt/account/keyid/2ea9859a-eba8-ff24-cd03-2a51639fc7d5
|
||||
```
|
||||
|
||||
#### Sample response
|
||||
|
||||
```
|
||||
{
|
||||
"data": {
|
||||
"contacts": null,
|
||||
"created_time": "2024-12-12T12:55:50-05:00",
|
||||
"directory": "acme/",
|
||||
"eab": {
|
||||
"created_time": "2024-12-12T12:55:29-05:00",
|
||||
"directory": "acme/",
|
||||
"eab_id": "24c0673a-df53-0671-a628-e7b9c995485c",
|
||||
"key_type": "hs"
|
||||
},
|
||||
"key_id": "2ea9859a-eba8-ff24-cd03-2a51639fc7d5",
|
||||
"orders": [
|
||||
{
|
||||
"cert_expiry": "2024-12-13T17:55:28Z",
|
||||
"cert_serial_number": "4a:6f:d0:f7:13:55:f7:c9:19:82:fc:34:69:67:77:2e:58:27:02:8b",
|
||||
"identifiers": [
|
||||
"testing.dadgarcorp.com"
|
||||
],
|
||||
"order_expiry": "2024-12-13T12:56:04-05:00",
|
||||
"order_id": "90699994-8863-571c-26b0-46755e0db351",
|
||||
"status": "valid"
|
||||
}
|
||||
],
|
||||
"revoked_time": "",
|
||||
"status": "valid"
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Update ACME account info
|
||||
|
||||
The `UpdateAcmeAccountInfo` endpoint revokes or re-enables an ACME
|
||||
account and returns the account details excluding order or certificate
|
||||
details.
|
||||
|
||||
| Method | Path |
|
||||
|:-------|:---------------------------------------|
|
||||
| `POST` | `/pki/acme/mgmt/account/keyid/:key_id` |
|
||||
|
||||
#### Path parameters
|
||||
|
||||
- `key_id` `(string: <required>)` - ID of the target ACME account.
|
||||
|
||||
|
||||
### Parameters
|
||||
|
||||
- `status` `(string: <required>)` - The new account status. Must be one of:
|
||||
`revoked`, `valid`.
|
||||
|
||||
<Note title="ACME account revocation does not revoke certificates">
|
||||
|
||||
Revoking an ACME account forbids further operations on the account
|
||||
without revoking existing certificates. You must revoke any existing
|
||||
certificates manually.
|
||||
|
||||
</Note>
|
||||
|
||||
#### Sample request
|
||||
|
||||
```
|
||||
$ curl \
|
||||
--header "X-Vault-Token: ..." \
|
||||
http://127.0.0.1:8200/v1/pki/acme/mgmt/account/keyid/2ea9859a-eba8-ff24-cd03-2a51639fc7d5
|
||||
```
|
||||
|
||||
#### Sample response
|
||||
|
||||
```
|
||||
{
|
||||
"data": {
|
||||
"contacts": null,
|
||||
"created_time": "2024-12-12T12:55:50-05:00",
|
||||
"directory": "acme/",
|
||||
"eab": {
|
||||
"created_time": "2024-12-12T12:55:29-05:00",
|
||||
"directory": "acme/",
|
||||
"eab_id": "24c0673a-df53-0671-a628-e7b9c995485c",
|
||||
"key_type": "hs"
|
||||
},
|
||||
"key_id": "2ea9859a-eba8-ff24-cd03-2a51639fc7d5",
|
||||
"revoked_time": "2024-12-12T12:59:02-05:00",
|
||||
"status": "revoked"
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## EST Certificate issuance <EnterpriseAlert inline="true" />
|
||||
|
||||
Within Vault Enterprise, support can be enabled for the
|
||||
|
||||
Reference in New Issue
Block a user