Skip to content

Commit e153846

Browse files
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>
1 parent 4f32443 commit e153846

File tree

9 files changed

+549
-32
lines changed

9 files changed

+549
-32
lines changed

builtin/logical/pki/acme_state.go

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"io"
1212
"net"
1313
"path"
14-
"strings"
1514
"sync"
1615
"sync/atomic"
1716
"time"
@@ -311,7 +310,22 @@ func (a *acmeState) UpdateAccount(sc *storageContext, acct *acmeAccount) error {
311310
// LoadAccount will load the account object based on the passed in keyId field value
312311
// otherwise will return an error if the account does not exist.
313312
func (a *acmeState) LoadAccount(ac *acmeContext, keyId string) (*acmeAccount, error) {
314-
entry, err := ac.sc.Storage.Get(ac.sc.Context, acmeAccountPrefix+keyId)
313+
acct, err := a.LoadAccountWithoutDirEnforcement(ac.sc, keyId)
314+
if err != nil {
315+
return acct, err
316+
}
317+
318+
if acct.AcmeDirectory != ac.acmeDirectory {
319+
return nil, fmt.Errorf("%w: account part of different ACME directory path", ErrMalformed)
320+
}
321+
322+
return acct, nil
323+
}
324+
325+
// LoadAccountWithoutDirEnforcement will load the account object based on the passed in keyId field value,
326+
// but does not enforce the ACME directory path, normally this is used by non ACME specific APIs.
327+
func (a *acmeState) LoadAccountWithoutDirEnforcement(sc *storageContext, keyId string) (*acmeAccount, error) {
328+
entry, err := sc.Storage.Get(sc.Context, acmeAccountPrefix+keyId)
315329
if err != nil {
316330
return nil, fmt.Errorf("error loading account: %w", err)
317331
}
@@ -324,13 +338,7 @@ func (a *acmeState) LoadAccount(ac *acmeContext, keyId string) (*acmeAccount, er
324338
if err != nil {
325339
return nil, fmt.Errorf("error decoding account: %w", err)
326340
}
327-
328-
if acct.AcmeDirectory != ac.acmeDirectory {
329-
return nil, fmt.Errorf("%w: account part of different ACME directory path", ErrMalformed)
330-
}
331-
332341
acct.KeyId = keyId
333-
334342
return &acct, nil
335343
}
336344

@@ -536,6 +544,27 @@ func (a *acmeState) LoadOrder(ac *acmeContext, userCtx *jwsCtx, orderId string)
536544
return &order, nil
537545
}
538546

547+
// LoadAccountOrders will load all orders for a given account ID, this should be used by the
548+
// management interface only, not through any of the ACME APIs.
549+
func (a *acmeState) LoadAccountOrders(sc *storageContext, accountId string) ([]*acmeOrder, error) {
550+
orderIds, err := a.ListOrderIds(sc, accountId)
551+
if err != nil {
552+
return nil, fmt.Errorf("failed listing order ids for account id %s: %w", accountId, err)
553+
}
554+
555+
var orders []*acmeOrder
556+
for _, orderId := range orderIds {
557+
order, err := a.LoadOrder(&acmeContext{sc: sc}, &jwsCtx{Kid: accountId}, orderId)
558+
if err != nil {
559+
return nil, err
560+
}
561+
562+
orders = append(orders, order)
563+
}
564+
565+
return orders, nil
566+
}
567+
539568
func (a *acmeState) SaveOrder(ac *acmeContext, order *acmeOrder) error {
540569
if order.OrderId == "" {
541570
return fmt.Errorf("invalid order, missing order id")
@@ -565,15 +594,7 @@ func (a *acmeState) ListOrderIds(sc *storageContext, accountId string) ([]string
565594
return nil, fmt.Errorf("failed listing order ids for account %s: %w", accountId, err)
566595
}
567596

568-
orderIds := []string{}
569-
for _, order := range rawOrderIds {
570-
if strings.HasSuffix(order, "/") {
571-
// skip any folders we might have for some reason
572-
continue
573-
}
574-
orderIds = append(orderIds, order)
575-
}
576-
return orderIds, nil
597+
return filterDirEntries(rawOrderIds), nil
577598
}
578599

579600
type acmeCertEntry struct {
@@ -672,17 +693,20 @@ func (a *acmeState) ListEabIds(sc *storageContext) ([]string, error) {
672693
if err != nil {
673694
return nil, err
674695
}
675-
var ids []string
676-
for _, entry := range entries {
677-
if strings.HasSuffix(entry, "/") {
678-
continue
679-
}
680-
ids = append(ids, entry)
681-
}
696+
ids := filterDirEntries(entries)
682697

683698
return ids, nil
684699
}
685700

701+
func (a *acmeState) ListAccountIds(sc *storageContext) ([]string, error) {
702+
entries, err := sc.Storage.List(sc.Context, acmeAccountPrefix)
703+
if err != nil {
704+
return nil, fmt.Errorf("failed listing ACME account prefix directory %s: %w", acmeAccountPrefix, err)
705+
}
706+
707+
return filterDirEntries(entries), nil
708+
}
709+
686710
func getAcmeSerialToAccountTrackerPath(accountId string, serial string) string {
687711
return acmeAccountPrefix + accountId + "/certs/" + normalizeSerial(serial)
688712
}

builtin/logical/pki/backend.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ func Backend(conf *logical.BackendConfig) *backend {
237237
pathAcmeConfig(&b),
238238
pathAcmeEabList(&b),
239239
pathAcmeEabDelete(&b),
240+
pathAcmeMgmtAccountList(&b),
241+
pathAcmeMgmtAccountRead(&b),
240242
},
241243

242244
Secrets: []*framework.Secret{

builtin/logical/pki/backend_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6831,6 +6831,7 @@ func TestProperAuthing(t *testing.T) {
68316831
}
68326832
serial := resp.Data["serial_number"].(string)
68336833
eabKid := "13b80844-e60d-42d2-b7e9-152a8e834b90"
6834+
acmeKeyId := "hrKmDYTvicHoHGVN2-3uzZV_BPGdE0W_dNaqYTtYqeo="
68346835
paths := map[string]pathAuthChecker{
68356836
"ca_chain": shouldBeUnauthedReadList,
68366837
"cert/ca_chain": shouldBeUnauthedReadList,
@@ -6950,6 +6951,8 @@ func TestProperAuthing(t *testing.T) {
69506951
"unified-ocsp/dGVzdAo=": shouldBeUnauthedReadList,
69516952
"eab/": shouldBeAuthed,
69526953
"eab/" + eabKid: shouldBeAuthed,
6954+
"acme/mgmt/account/keyid/": shouldBeAuthed,
6955+
"acme/mgmt/account/keyid/" + acmeKeyId: shouldBeAuthed,
69536956
}
69546957

69556958
entPaths := getEntProperAuthingPaths(serial)
@@ -7020,7 +7023,10 @@ func TestProperAuthing(t *testing.T) {
70207023
raw_path = strings.ReplaceAll(raw_path, "{serial}", serial)
70217024
}
70227025
if strings.Contains(raw_path, "acme/account/") && strings.Contains(raw_path, "{kid}") {
7023-
raw_path = strings.ReplaceAll(raw_path, "{kid}", "hrKmDYTvicHoHGVN2-3uzZV_BPGdE0W_dNaqYTtYqeo=")
7026+
raw_path = strings.ReplaceAll(raw_path, "{kid}", acmeKeyId)
7027+
}
7028+
if strings.Contains(raw_path, "acme/mgmt/account/") && strings.Contains(raw_path, "{keyid}") {
7029+
raw_path = strings.ReplaceAll(raw_path, "{keyid}", acmeKeyId)
70247030
}
70257031
if strings.Contains(raw_path, "acme/") && strings.Contains(raw_path, "{auth_id}") {
70267032
raw_path = strings.ReplaceAll(raw_path, "{auth_id}", "29da8c38-7a09-465e-b9a6-3d76802b1afd")
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package pki
5+
6+
import (
7+
"context"
8+
"errors"
9+
"fmt"
10+
"strings"
11+
"time"
12+
13+
"github.com/hashicorp/vault/sdk/framework"
14+
"github.com/hashicorp/vault/sdk/logical"
15+
)
16+
17+
func pathAcmeMgmtAccountList(b *backend) *framework.Path {
18+
return &framework.Path{
19+
Pattern: "acme/mgmt/account/keyid/?$",
20+
21+
Operations: map[logical.Operation]framework.OperationHandler{
22+
logical.ListOperation: &framework.PathOperation{
23+
Callback: b.pathAcmeMgmtListAccounts,
24+
DisplayAttrs: &framework.DisplayAttributes{
25+
OperationPrefix: operationPrefixPKI,
26+
OperationVerb: "list-acme-account-keys",
27+
Description: "List all ACME account key identifiers.",
28+
},
29+
},
30+
},
31+
32+
HelpSynopsis: "List all ACME account key identifiers.",
33+
HelpDescription: `Allows an operator to list all ACME account key identifiers.`,
34+
}
35+
}
36+
37+
func pathAcmeMgmtAccountRead(b *backend) *framework.Path {
38+
return &framework.Path{
39+
Pattern: "acme/mgmt/account/keyid/" + framework.GenericNameRegex("keyid"),
40+
Fields: map[string]*framework.FieldSchema{
41+
"keyid": {
42+
Type: framework.TypeString,
43+
Description: "The key identifier of the account.",
44+
Required: true,
45+
},
46+
"status": {
47+
Type: framework.TypeString,
48+
Description: "The status of the account.",
49+
Required: true,
50+
AllowedValues: []interface{}{AccountStatusValid.String(), AccountStatusRevoked.String()},
51+
},
52+
},
53+
Operations: map[logical.Operation]framework.OperationHandler{
54+
logical.ReadOperation: &framework.PathOperation{
55+
Callback: b.pathAcmeMgmtReadAccount,
56+
DisplayAttrs: &framework.DisplayAttributes{
57+
OperationPrefix: operationPrefixPKI,
58+
OperationSuffix: "acme-key-id",
59+
},
60+
},
61+
logical.UpdateOperation: &framework.PathOperation{
62+
Callback: b.pathAcmeMgmtUpdateAccount,
63+
DisplayAttrs: &framework.DisplayAttributes{
64+
OperationPrefix: operationPrefixPKI,
65+
OperationSuffix: "acme-key-id",
66+
},
67+
},
68+
},
69+
70+
HelpSynopsis: "Fetch the details or update the status of an ACME account by key identifier.",
71+
HelpDescription: `Allows an operator to retrieve details of an ACME account and to update the account status.`,
72+
}
73+
}
74+
75+
func (b *backend) pathAcmeMgmtListAccounts(ctx context.Context, r *logical.Request, d *framework.FieldData) (*logical.Response, error) {
76+
sc := b.makeStorageContext(ctx, r.Storage)
77+
78+
accountIds, err := b.GetAcmeState().ListAccountIds(sc)
79+
if err != nil {
80+
return nil, err
81+
}
82+
83+
return logical.ListResponse(accountIds), nil
84+
}
85+
86+
func (b *backend) pathAcmeMgmtReadAccount(ctx context.Context, r *logical.Request, d *framework.FieldData) (*logical.Response, error) {
87+
keyId := d.Get("keyid").(string)
88+
if len(keyId) == 0 {
89+
return logical.ErrorResponse("keyid is required"), logical.ErrInvalidRequest
90+
}
91+
92+
sc := b.makeStorageContext(ctx, r.Storage)
93+
as := b.GetAcmeState()
94+
95+
accountEntry, err := as.LoadAccountWithoutDirEnforcement(sc, keyId)
96+
if err != nil {
97+
if errors.Is(err, ErrAccountDoesNotExist) {
98+
return logical.ErrorResponse("ACME key id %s did not exist", keyId), logical.ErrNotFound
99+
}
100+
return nil, fmt.Errorf("failed loading ACME account id %q: %w", keyId, err)
101+
}
102+
103+
orders, err := as.LoadAccountOrders(sc, accountEntry.KeyId)
104+
if err != nil {
105+
return nil, fmt.Errorf("failed loading orders for account %q: %w", accountEntry.KeyId, err)
106+
}
107+
108+
orderData := make([]map[string]interface{}, 0, len(orders))
109+
for _, order := range orders {
110+
orderData = append(orderData, acmeOrderToDataMap(order))
111+
}
112+
113+
dataMap := acmeAccountToDataMap(accountEntry)
114+
dataMap["orders"] = orderData
115+
return &logical.Response{Data: dataMap}, nil
116+
}
117+
118+
func (b *backend) pathAcmeMgmtUpdateAccount(ctx context.Context, r *logical.Request, d *framework.FieldData) (*logical.Response, error) {
119+
keyId := d.Get("keyid").(string)
120+
if len(keyId) == 0 {
121+
return logical.ErrorResponse("keyid is required"), logical.ErrInvalidRequest
122+
}
123+
124+
status, err := convertToAccountStatus(d.Get("status"))
125+
if err != nil {
126+
return logical.ErrorResponse(err.Error()), logical.ErrInvalidRequest
127+
}
128+
if status != AccountStatusValid && status != AccountStatusRevoked {
129+
return logical.ErrorResponse("invalid status %q", status), logical.ErrInvalidRequest
130+
}
131+
132+
sc := b.makeStorageContext(ctx, r.Storage)
133+
as := b.GetAcmeState()
134+
135+
accountEntry, err := as.LoadAccountWithoutDirEnforcement(sc, keyId)
136+
if err != nil {
137+
if errors.Is(err, ErrAccountDoesNotExist) {
138+
return logical.ErrorResponse("ACME key id %q did not exist", keyId), logical.ErrNotFound
139+
}
140+
return nil, fmt.Errorf("failed loading ACME account id %q: %w", keyId, err)
141+
}
142+
143+
if accountEntry.Status != status {
144+
accountEntry.Status = status
145+
146+
switch status {
147+
case AccountStatusRevoked:
148+
accountEntry.AccountRevokedDate = time.Now()
149+
case AccountStatusValid:
150+
accountEntry.AccountRevokedDate = time.Time{}
151+
}
152+
153+
if err := as.UpdateAccount(sc, accountEntry); err != nil {
154+
return nil, fmt.Errorf("failed saving account %q: %w", keyId, err)
155+
}
156+
}
157+
158+
dataMap := acmeAccountToDataMap(accountEntry)
159+
return &logical.Response{Data: dataMap}, nil
160+
}
161+
162+
func convertToAccountStatus(status any) (ACMEAccountStatus, error) {
163+
if status == nil {
164+
return "", fmt.Errorf("status is required")
165+
}
166+
167+
statusStr, ok := status.(string)
168+
if !ok {
169+
return "", fmt.Errorf("status must be a string")
170+
}
171+
172+
switch strings.ToLower(strings.TrimSpace(statusStr)) {
173+
case AccountStatusValid.String():
174+
return AccountStatusValid, nil
175+
case AccountStatusRevoked.String():
176+
return AccountStatusRevoked, nil
177+
case AccountStatusDeactivated.String():
178+
return AccountStatusDeactivated, nil
179+
default:
180+
return "", fmt.Errorf("invalid status %q", statusStr)
181+
}
182+
}
183+
184+
func acmeAccountToDataMap(accountEntry *acmeAccount) map[string]interface{} {
185+
revokedDate := ""
186+
if !accountEntry.AccountRevokedDate.IsZero() {
187+
revokedDate = accountEntry.AccountRevokedDate.Format(time.RFC3339)
188+
}
189+
190+
eab := map[string]string{}
191+
if accountEntry.Eab != nil {
192+
eab["eab_id"] = accountEntry.Eab.KeyID
193+
eab["directory"] = accountEntry.Eab.AcmeDirectory
194+
eab["created_time"] = accountEntry.Eab.CreatedOn.Format(time.RFC3339)
195+
eab["key_type"] = accountEntry.Eab.KeyType
196+
}
197+
198+
return map[string]interface{}{
199+
"key_id": accountEntry.KeyId,
200+
"status": accountEntry.Status,
201+
"contacts": accountEntry.Contact,
202+
"created_time": accountEntry.AccountCreatedDate.Format(time.RFC3339),
203+
"revoked_time": revokedDate,
204+
"directory": accountEntry.AcmeDirectory,
205+
"eab": eab,
206+
}
207+
}
208+
209+
func acmeOrderToDataMap(order *acmeOrder) map[string]interface{} {
210+
identifiers := make([]string, 0, len(order.Identifiers))
211+
for _, identifier := range order.Identifiers {
212+
identifiers = append(identifiers, identifier.Value)
213+
}
214+
var certExpiry string
215+
if !order.CertificateExpiry.IsZero() {
216+
certExpiry = order.CertificateExpiry.Format(time.RFC3339)
217+
}
218+
return map[string]interface{}{
219+
"order_id": order.OrderId,
220+
"status": string(order.Status),
221+
"identifiers": identifiers,
222+
"cert_serial_number": strings.ReplaceAll(order.CertificateSerialNumber, "-", ":"),
223+
"cert_expiry": certExpiry,
224+
"order_expiry": order.Expires.Format(time.RFC3339),
225+
}
226+
}

builtin/logical/pki/path_acme_eab.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ a warning that it did not exist.`,
173173
}
174174

175175
type eabType struct {
176-
KeyID string `json:"-"`
176+
KeyID string `json:"key-id"`
177177
KeyType string `json:"key-type"`
178178
PrivateBytes []byte `json:"private-bytes"`
179179
AcmeDirectory string `json:"acme-directory"`

0 commit comments

Comments
 (0)