476 lines
15 KiB
Go
476 lines
15 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package pki
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/go-secure-stdlib/strutil"
|
|
|
|
"github.com/hashicorp/vault/sdk/framework"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
)
|
|
|
|
func uuidNameRegex(name string) string {
|
|
return fmt.Sprintf("(?P<%s>[[:alnum:]]{8}-[[:alnum:]]{4}-[[:alnum:]]{4}-[[:alnum:]]{4}-[[:alnum:]]{12}?)", name)
|
|
}
|
|
|
|
func pathAcmeNewAccount(b *backend) []*framework.Path {
|
|
return buildAcmeFrameworkPaths(b, patternAcmeNewAccount, "/new-account")
|
|
}
|
|
|
|
func pathAcmeUpdateAccount(b *backend) []*framework.Path {
|
|
return buildAcmeFrameworkPaths(b, patternAcmeNewAccount, "/account/"+uuidNameRegex("kid"))
|
|
}
|
|
|
|
func addFieldsForACMEPath(fields map[string]*framework.FieldSchema, pattern string) map[string]*framework.FieldSchema {
|
|
if strings.Contains(pattern, framework.GenericNameRegex("role")) {
|
|
fields["role"] = &framework.FieldSchema{
|
|
Type: framework.TypeString,
|
|
Description: `The desired role for the acme request`,
|
|
Required: true,
|
|
}
|
|
}
|
|
if strings.Contains(pattern, framework.GenericNameRegex(issuerRefParam)) {
|
|
fields[issuerRefParam] = &framework.FieldSchema{
|
|
Type: framework.TypeString,
|
|
Description: `Reference to an existing issuer name or issuer id`,
|
|
Required: true,
|
|
}
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
func addFieldsForACMERequest(fields map[string]*framework.FieldSchema) map[string]*framework.FieldSchema {
|
|
fields["protected"] = &framework.FieldSchema{
|
|
Type: framework.TypeString,
|
|
Description: "ACME request 'protected' value",
|
|
Required: false,
|
|
}
|
|
|
|
fields["payload"] = &framework.FieldSchema{
|
|
Type: framework.TypeString,
|
|
Description: "ACME request 'payload' value",
|
|
Required: false,
|
|
}
|
|
|
|
fields["signature"] = &framework.FieldSchema{
|
|
Type: framework.TypeString,
|
|
Description: "ACME request 'signature' value",
|
|
Required: false,
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
func addFieldsForACMEKidRequest(fields map[string]*framework.FieldSchema, pattern string) map[string]*framework.FieldSchema {
|
|
if strings.Contains(pattern, uuidNameRegex("kid")) {
|
|
fields["kid"] = &framework.FieldSchema{
|
|
Type: framework.TypeString,
|
|
Description: `The key identifier provided by the CA`,
|
|
Required: true,
|
|
}
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
func patternAcmeNewAccount(b *backend, pattern string) *framework.Path {
|
|
fields := map[string]*framework.FieldSchema{}
|
|
addFieldsForACMEPath(fields, pattern)
|
|
addFieldsForACMERequest(fields)
|
|
addFieldsForACMEKidRequest(fields, pattern)
|
|
|
|
return &framework.Path{
|
|
Pattern: pattern,
|
|
Fields: fields,
|
|
Operations: map[logical.Operation]framework.OperationHandler{
|
|
logical.UpdateOperation: &framework.PathOperation{
|
|
Callback: b.acmeParsedWrapper(b.acmeNewAccountHandler),
|
|
ForwardPerformanceSecondary: false,
|
|
ForwardPerformanceStandby: true,
|
|
},
|
|
},
|
|
|
|
HelpSynopsis: pathAcmeHelpSync,
|
|
HelpDescription: pathAcmeHelpDesc,
|
|
}
|
|
}
|
|
|
|
func (b *backend) acmeNewAccountHandler(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}) (*logical.Response, error) {
|
|
// Parameters
|
|
var ok bool
|
|
var onlyReturnExisting bool
|
|
var contacts []string
|
|
var termsOfServiceAgreed bool
|
|
var status string
|
|
var eabData map[string]interface{}
|
|
|
|
rawContact, present := data["contact"]
|
|
if present {
|
|
listContact, ok := rawContact.([]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid type (%T) for field 'contact': %w", rawContact, ErrMalformed)
|
|
}
|
|
|
|
for index, singleContact := range listContact {
|
|
contact, ok := singleContact.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid type (%T) for field 'contact' item %d: %w", singleContact, index, ErrMalformed)
|
|
}
|
|
|
|
contacts = append(contacts, contact)
|
|
}
|
|
}
|
|
|
|
rawTermsOfServiceAgreed, present := data["termsOfServiceAgreed"]
|
|
if present {
|
|
termsOfServiceAgreed, ok = rawTermsOfServiceAgreed.(bool)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid type (%T) for field 'termsOfServiceAgreed': %w", rawTermsOfServiceAgreed, ErrMalformed)
|
|
}
|
|
}
|
|
|
|
rawOnlyReturnExisting, present := data["onlyReturnExisting"]
|
|
if present {
|
|
onlyReturnExisting, ok = rawOnlyReturnExisting.(bool)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid type (%T) for field 'onlyReturnExisting': %w", rawOnlyReturnExisting, ErrMalformed)
|
|
}
|
|
}
|
|
|
|
// Per RFC 8555 7.3.6 Account deactivation, we will handle it within our update API.
|
|
rawStatus, present := data["status"]
|
|
if present {
|
|
status, ok = rawStatus.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid type (%T) for field 'onlyReturnExisting': %w", rawOnlyReturnExisting, ErrMalformed)
|
|
}
|
|
}
|
|
|
|
if eabDataRaw, ok := data["externalAccountBinding"]; ok {
|
|
eabData, ok = eabDataRaw.(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: externalAccountBinding field was unparseable", ErrMalformed)
|
|
}
|
|
}
|
|
|
|
// We have two paths here: search or create.
|
|
if onlyReturnExisting {
|
|
return b.acmeAccountSearchHandler(acmeCtx, userCtx)
|
|
}
|
|
|
|
// Pass through the /new-account API calls to this specific handler as its requirements are different
|
|
// from the account update handler.
|
|
if strings.HasSuffix(r.Path, "/new-account") {
|
|
return b.acmeNewAccountCreateHandler(acmeCtx, userCtx, contacts, termsOfServiceAgreed, r, eabData)
|
|
}
|
|
|
|
return b.acmeNewAccountUpdateHandler(acmeCtx, userCtx, contacts, status, eabData)
|
|
}
|
|
|
|
func formatNewAccountResponse(acmeCtx *acmeContext, acct *acmeAccount, eabData map[string]interface{}) *logical.Response {
|
|
resp := formatAccountResponse(acmeCtx, acct)
|
|
|
|
// Per RFC 8555 Section 7.1.2. Account Objects
|
|
// Including this field in a newAccount request indicates approval by
|
|
// the holder of an existing non-ACME account to bind that account to
|
|
// this ACME account
|
|
if acct.Eab != nil && len(eabData) != 0 {
|
|
resp.Data["externalAccountBinding"] = eabData
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
func formatAccountResponse(acmeCtx *acmeContext, acct *acmeAccount) *logical.Response {
|
|
location := acmeCtx.baseUrl.String() + "account/" + acct.KeyId
|
|
|
|
resp := &logical.Response{
|
|
Data: map[string]interface{}{
|
|
"status": acct.Status,
|
|
"orders": location + "/orders",
|
|
},
|
|
Headers: map[string][]string{
|
|
"Location": {location},
|
|
},
|
|
}
|
|
|
|
if len(acct.Contact) > 0 {
|
|
resp.Data["contact"] = acct.Contact
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
func (b *backend) acmeAccountSearchHandler(acmeCtx *acmeContext, userCtx *jwsCtx) (*logical.Response, error) {
|
|
thumbprint, err := userCtx.GetKeyThumbprint()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed generating thumbprint for key: %w", err)
|
|
}
|
|
|
|
account, err := b.acmeState.LoadAccountByKey(acmeCtx, thumbprint)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load account by thumbprint: %w", err)
|
|
}
|
|
|
|
if account != nil {
|
|
if err = acmeCtx.eabPolicy.EnforceForExistingAccount(account); err != nil {
|
|
return nil, err
|
|
}
|
|
return formatAccountResponse(acmeCtx, account), nil
|
|
}
|
|
|
|
// Per RFC 8555 Section 7.3.1. Finding an Account URL Given a Key:
|
|
//
|
|
// > If a client sends such a request and an account does not exist,
|
|
// > then the server MUST return an error response with status code
|
|
// > 400 (Bad Request) and type "urn:ietf:params:acme:error:accountDoesNotExist".
|
|
return nil, fmt.Errorf("An account with this key does not exist: %w", ErrAccountDoesNotExist)
|
|
}
|
|
|
|
func (b *backend) acmeNewAccountCreateHandler(acmeCtx *acmeContext, userCtx *jwsCtx, contact []string, termsOfServiceAgreed bool, r *logical.Request, eabData map[string]interface{}) (*logical.Response, error) {
|
|
if userCtx.Existing {
|
|
return nil, fmt.Errorf("cannot submit to newAccount with 'kid': %w", ErrMalformed)
|
|
}
|
|
|
|
// If the account already exists, return the existing one.
|
|
thumbprint, err := userCtx.GetKeyThumbprint()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed generating thumbprint for key: %w", err)
|
|
}
|
|
|
|
accountByKey, err := b.acmeState.LoadAccountByKey(acmeCtx, thumbprint)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load account by thumbprint: %w", err)
|
|
}
|
|
|
|
if accountByKey != nil {
|
|
if err = acmeCtx.eabPolicy.EnforceForExistingAccount(accountByKey); err != nil {
|
|
return nil, err
|
|
}
|
|
return formatAccountResponse(acmeCtx, accountByKey), nil
|
|
}
|
|
|
|
var eab *eabType
|
|
if len(eabData) != 0 {
|
|
eab, err = verifyEabPayload(b.acmeState, acmeCtx, userCtx, r.Path, eabData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Verify against our EAB policy
|
|
if err = acmeCtx.eabPolicy.EnforceForNewAccount(eab); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// TODO: Limit this only when ToS are required or set by the operator, since we don't have a
|
|
// ToS URL in the directory at the moment, we can not enforce this.
|
|
//if !termsOfServiceAgreed {
|
|
// return nil, fmt.Errorf("terms of service not agreed to: %w", ErrUserActionRequired)
|
|
//}
|
|
|
|
if eab != nil {
|
|
// We delete the EAB to prevent future re-use after associating it with an account, worst
|
|
// case if we fail creating the account we simply nuked the EAB which they can create another
|
|
// and retry
|
|
wasDeleted, err := b.acmeState.DeleteEab(acmeCtx.sc, eab.KeyID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to delete eab reference: %w", err)
|
|
}
|
|
|
|
if !wasDeleted {
|
|
// Something consumed our EAB before we did bail...
|
|
return nil, fmt.Errorf("eab was already used: %w", ErrUnauthorized)
|
|
}
|
|
}
|
|
|
|
b.acmeAccountLock.RLock() // Prevents Account Creation and Tidy Interfering
|
|
defer b.acmeAccountLock.RUnlock()
|
|
|
|
accountByKid, err := b.acmeState.CreateAccount(acmeCtx, userCtx, contact, termsOfServiceAgreed, eab)
|
|
if err != nil {
|
|
if eab != nil {
|
|
return nil, fmt.Errorf("failed to create account: %w; the EAB key used for this request has been deleted as a result of this operation; fetch a new EAB key before retrying", err)
|
|
}
|
|
return nil, fmt.Errorf("failed to create account: %w", err)
|
|
}
|
|
|
|
resp := formatNewAccountResponse(acmeCtx, accountByKid, eabData)
|
|
|
|
// Per RFC 8555 Section 7.3. Account Management:
|
|
//
|
|
// > The server returns this account object in a 201 (Created) response,
|
|
// > with the account URL in a Location header field.
|
|
resp.Data[logical.HTTPStatusCode] = http.StatusCreated
|
|
return resp, nil
|
|
}
|
|
|
|
func (b *backend) acmeNewAccountUpdateHandler(acmeCtx *acmeContext, userCtx *jwsCtx, contact []string, status string, eabData map[string]interface{}) (*logical.Response, error) {
|
|
if !userCtx.Existing {
|
|
return nil, fmt.Errorf("cannot submit to account updates without a 'kid': %w", ErrMalformed)
|
|
}
|
|
|
|
if len(eabData) != 0 {
|
|
return nil, fmt.Errorf("%w: not allowed to update EAB data in accounts", ErrMalformed)
|
|
}
|
|
|
|
account, err := b.acmeState.LoadAccount(acmeCtx, userCtx.Kid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error loading account: %w", err)
|
|
}
|
|
|
|
if err = acmeCtx.eabPolicy.EnforceForExistingAccount(account); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Per RFC 8555 7.3.6 Account deactivation, if we were previously deactivated, we should return
|
|
// unauthorized. There is no way to reactivate any accounts per ACME RFC.
|
|
if account.Status != AccountStatusValid {
|
|
// Treating "revoked" and "deactivated" as the same here.
|
|
return nil, ErrUnauthorized
|
|
}
|
|
|
|
shouldUpdate := false
|
|
// Check to see if we should update, we don't really care about ordering
|
|
if !strutil.EquivalentSlices(account.Contact, contact) {
|
|
shouldUpdate = true
|
|
account.Contact = contact
|
|
}
|
|
|
|
// Check to process account de-activation status was requested.
|
|
// 7.3.6. Account Deactivation
|
|
if string(AccountStatusDeactivated) == status {
|
|
shouldUpdate = true
|
|
// TODO: This should cancel any ongoing operations (do not revoke certs),
|
|
// perhaps we should delete this account here?
|
|
account.Status = AccountStatusDeactivated
|
|
account.AccountRevokedDate = time.Now()
|
|
}
|
|
|
|
if shouldUpdate {
|
|
err = b.acmeState.UpdateAccount(acmeCtx.sc, account)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to update account: %w", err)
|
|
}
|
|
}
|
|
|
|
resp := formatAccountResponse(acmeCtx, account)
|
|
return resp, nil
|
|
}
|
|
|
|
func (b *backend) tidyAcmeAccountByThumbprint(as *acmeState, sc *storageContext, keyThumbprint string, certTidyBuffer, accountTidyBuffer time.Duration) error {
|
|
thumbprintEntry, err := sc.Storage.Get(sc.Context, path.Join(acmeThumbprintPrefix, keyThumbprint))
|
|
if err != nil {
|
|
return fmt.Errorf("error retrieving thumbprint entry %v, unable to find corresponding account entry: %w", keyThumbprint, err)
|
|
}
|
|
if thumbprintEntry == nil {
|
|
return fmt.Errorf("empty thumbprint entry %v, unable to find corresponding account entry", keyThumbprint)
|
|
}
|
|
|
|
var thumbprint acmeThumbprint
|
|
err = thumbprintEntry.DecodeJSON(&thumbprint)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to decode thumbprint entry %v to find account entry: %w", keyThumbprint, err)
|
|
}
|
|
|
|
if len(thumbprint.Kid) == 0 {
|
|
return fmt.Errorf("unable to find account entry: empty kid within thumbprint entry: %s", keyThumbprint)
|
|
}
|
|
|
|
// Now Get the Account:
|
|
accountEntry, err := sc.Storage.Get(sc.Context, acmeAccountPrefix+thumbprint.Kid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if accountEntry == nil {
|
|
// We delete the Thumbprint Associated with the Account, and we are done
|
|
err = sc.Storage.Delete(sc.Context, path.Join(acmeThumbprintPrefix, keyThumbprint))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.tidyStatusIncDeletedAcmeAccountCount()
|
|
return nil
|
|
}
|
|
|
|
var account acmeAccount
|
|
err = accountEntry.DecodeJSON(&account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
account.KeyId = thumbprint.Kid
|
|
|
|
// Tidy Orders On the Account
|
|
orderIds, err := as.ListOrderIds(sc, thumbprint.Kid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
allOrdersTidied := true
|
|
maxCertExpiryUpdated := false
|
|
for _, orderId := range orderIds {
|
|
wasTidied, orderExpiry, err := b.acmeTidyOrder(sc, thumbprint.Kid, getOrderPath(thumbprint.Kid, orderId), certTidyBuffer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !wasTidied {
|
|
allOrdersTidied = false
|
|
}
|
|
|
|
if !orderExpiry.IsZero() && account.MaxCertExpiry.Before(orderExpiry) {
|
|
account.MaxCertExpiry = orderExpiry
|
|
maxCertExpiryUpdated = true
|
|
}
|
|
}
|
|
|
|
now := time.Now()
|
|
if allOrdersTidied &&
|
|
now.After(account.AccountCreatedDate.Add(accountTidyBuffer)) &&
|
|
now.After(account.MaxCertExpiry.Add(accountTidyBuffer)) {
|
|
// Tidy this account
|
|
// If it is Revoked or Deactivated:
|
|
if (account.Status == AccountStatusRevoked || account.Status == AccountStatusDeactivated) && now.After(account.AccountRevokedDate.Add(accountTidyBuffer)) {
|
|
// We Delete the Account Associated with this Thumbprint:
|
|
err = sc.Storage.Delete(sc.Context, path.Join(acmeAccountPrefix, thumbprint.Kid))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Now we delete the Thumbprint Associated with the Account:
|
|
err = sc.Storage.Delete(sc.Context, path.Join(acmeThumbprintPrefix, keyThumbprint))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.tidyStatusIncDeletedAcmeAccountCount()
|
|
} else if account.Status == AccountStatusValid {
|
|
// Revoke This Account
|
|
account.AccountRevokedDate = now
|
|
account.Status = AccountStatusRevoked
|
|
err := as.UpdateAccount(sc, &account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.tidyStatusIncRevAcmeAccountCount()
|
|
}
|
|
}
|
|
|
|
// Only update the account if we modified the max cert expiry values and the account is still valid,
|
|
// to prevent us from adding back a deleted account or not re-writing the revoked account that was
|
|
// already written above.
|
|
if maxCertExpiryUpdated && account.Status == AccountStatusValid {
|
|
// Update our expiry time we previously setup.
|
|
err := as.UpdateAccount(sc, &account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|