diff --git a/builtin/logical/pki/acme_state.go b/builtin/logical/pki/acme_state.go
index 63962d933b..996639af1d 100644
--- a/builtin/logical/pki/acme_state.go
+++ b/builtin/logical/pki/acme_state.go
@@ -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)
}
diff --git a/builtin/logical/pki/backend.go b/builtin/logical/pki/backend.go
index 615380a826..de4f8c3f77 100644
--- a/builtin/logical/pki/backend.go
+++ b/builtin/logical/pki/backend.go
@@ -237,6 +237,8 @@ func Backend(conf *logical.BackendConfig) *backend {
pathAcmeConfig(&b),
pathAcmeEabList(&b),
pathAcmeEabDelete(&b),
+ pathAcmeMgmtAccountList(&b),
+ pathAcmeMgmtAccountRead(&b),
},
Secrets: []*framework.Secret{
diff --git a/builtin/logical/pki/backend_test.go b/builtin/logical/pki/backend_test.go
index 5c38989f8f..2fcd946286 100644
--- a/builtin/logical/pki/backend_test.go
+++ b/builtin/logical/pki/backend_test.go
@@ -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")
diff --git a/builtin/logical/pki/path_acme_account_mgmt.go b/builtin/logical/pki/path_acme_account_mgmt.go
new file mode 100644
index 0000000000..ef85aa5fda
--- /dev/null
+++ b/builtin/logical/pki/path_acme_account_mgmt.go
@@ -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),
+ }
+}
diff --git a/builtin/logical/pki/path_acme_eab.go b/builtin/logical/pki/path_acme_eab.go
index fa026a1c18..00b1cdaa13 100644
--- a/builtin/logical/pki/path_acme_eab.go
+++ b/builtin/logical/pki/path_acme_eab.go
@@ -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"`
diff --git a/builtin/logical/pki/path_acme_test.go b/builtin/logical/pki/path_acme_test.go
index ac16c36cc8..599e7d92b6 100644
--- a/builtin/logical/pki/path_acme_test.go
+++ b/builtin/logical/pki/path_acme_test.go
@@ -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{
diff --git a/builtin/logical/pki/storage.go b/builtin/logical/pki/storage.go
index 43c4853ba3..8b21d3e799 100644
--- a/builtin/logical/pki/storage.go
+++ b/builtin/logical/pki/storage.go
@@ -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
+}
diff --git a/changelog/29173.txt b/changelog/29173.txt
new file mode 100644
index 0000000000..7ccdfbdda3
--- /dev/null
+++ b/changelog/29173.txt
@@ -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
+```
diff --git a/website/content/api-docs/secret/pki/issuance.mdx b/website/content/api-docs/secret/pki/issuance.mdx
index c0cf65bdc4..80972618c8 100644
--- a/website/content/api-docs/secret/pki/issuance.mdx
+++ b/website/content/api-docs/secret/pki/issuance.mdx
@@ -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 ](#est-certificate-issuance)
- [EST Protocol Paths ](#est-protocol-paths)
- [Read EST Configuration ](#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.
+
+
+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.
+
+
#### 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: )` - 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: )` - ID of the target ACME account.
+
+
+### Parameters
+
+- `status` `(string: )` - The new account status. Must be one of:
+ `revoked`, `valid`.
+
+
+
+Revoking an ACME account forbids further operations on the account
+without revoking existing certificates. You must revoke any existing
+certificates manually.
+
+
+
+#### 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
Within Vault Enterprise, support can be enabled for the