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