open-vault/builtin/logical/pki/acme_wrappers.go
Alexander Scheel 364a639cca
Integrate acme config enable/disable into tests (#20407)
* Add default ACME configuration, invalidate on write

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add enforcment of ACME enabled

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Validate requested role against ACME config

Co-authored-by: kitography <khaines@mit.edu>
Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add validation of issuer restrictions with ACME

Co-authored-by: kitography <khaines@mit.edu>
Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add validation around allowed config lenghts

Co-authored-by: kitography <khaines@mit.edu>
Co-authored-by: Steven Clark <steven.clark@hashicorp.com>
Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Prune later deemed unnecessary config options

Co-authored-by: kitography <khaines@mit.edu>
Co-authored-by: Steven Clark <steven.clark@hashicorp.com>
Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* make fmt

---------

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
Co-authored-by: kitography <khaines@mit.edu>
Co-authored-by: Steven Clark <steven.clark@hashicorp.com>
2023-04-27 20:31:13 +00:00

398 lines
12 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package pki
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
type acmeContext struct {
// baseUrl is the combination of the configured cluster local URL and the acmePath up to /acme/
baseUrl *url.URL
clusterUrl *url.URL
sc *storageContext
role *roleEntry
issuer *issuerEntry
// acmeDirectory is a string that can distinguish the various acme directories we have configured
// if something needs to remain locked into a directory path structure.
acmeDirectory string
}
type (
acmeOperation func(acmeCtx *acmeContext, r *logical.Request, _ *framework.FieldData) (*logical.Response, error)
acmeParsedOperation func(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}) (*logical.Response, error)
acmeAccountRequiredOperation func(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, userCtx *jwsCtx, data map[string]interface{}, acct *acmeAccount) (*logical.Response, error)
)
// acmeErrorWrapper the lowest level wrapper that will translate errors into proper ACME error responses
func acmeErrorWrapper(op framework.OperationFunc) framework.OperationFunc {
return func(ctx context.Context, r *logical.Request, data *framework.FieldData) (*logical.Response, error) {
resp, err := op(ctx, r, data)
if err != nil {
return TranslateError(err)
}
return resp, nil
}
}
// acmeWrapper a basic wrapper that all ACME handlers should leverage as the basis.
// This will create a basic ACME context, validate basic ACME configuration is setup
// for operations. This pulls in acmeErrorWrapper to translate error messages for users,
// but does not enforce any sort of ACME authentication.
func (b *backend) acmeWrapper(op acmeOperation) framework.OperationFunc {
return acmeErrorWrapper(func(ctx context.Context, r *logical.Request, data *framework.FieldData) (*logical.Response, error) {
sc := b.makeStorageContext(ctx, r.Storage)
config, err := sc.Backend.acmeState.getConfigWithUpdate(sc)
if err != nil {
return nil, fmt.Errorf("failed to fetch ACME configuration: %w", err)
}
if isAcmeDisabled(sc, config) {
return nil, ErrAcmeDisabled
}
if b.useLegacyBundleCaStorage() {
return nil, fmt.Errorf("%w: Can not perform ACME operations until migration has completed", ErrServerInternal)
}
acmeBaseUrl, clusterBase, err := getAcmeBaseUrl(sc, r.Path)
if err != nil {
return nil, err
}
role, issuer, err := getAcmeRoleAndIssuer(sc, data, config)
if err != nil {
return nil, err
}
acmeDirectory := getAcmeDirectory(data)
acmeCtx := &acmeContext{
baseUrl: acmeBaseUrl,
clusterUrl: clusterBase,
sc: sc,
role: role,
issuer: issuer,
acmeDirectory: acmeDirectory,
}
return op(acmeCtx, r, data)
})
}
// acmeParsedWrapper is an ACME wrapper that will parse out the ACME request parameters, validate
// that we have a proper signature and pass to the operation a decoded map of arguments received.
// This wrapper builds on top of acmeWrapper. Note that this does perform signature verification
// it does not enforce the account being in a valid state nor existing.
func (b *backend) acmeParsedWrapper(op acmeParsedOperation) framework.OperationFunc {
return b.acmeWrapper(func(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData) (*logical.Response, error) {
user, data, err := b.acmeState.ParseRequestParams(acmeCtx, r, fields)
if err != nil {
return nil, err
}
resp, err := op(acmeCtx, r, fields, user, data)
// Our response handlers might not add the necessary headers.
if resp != nil {
if resp.Headers == nil {
resp.Headers = map[string][]string{}
}
if _, ok := resp.Headers["Replay-Nonce"]; !ok {
nonce, _, err := b.acmeState.GetNonce()
if err != nil {
return nil, err
}
resp.Headers["Replay-Nonce"] = []string{nonce}
}
if _, ok := resp.Headers["Link"]; !ok {
resp.Headers["Link"] = genAcmeLinkHeader(acmeCtx)
} else {
directory := genAcmeLinkHeader(acmeCtx)[0]
addDirectory := true
for _, item := range resp.Headers["Link"] {
if item == directory {
addDirectory = false
break
}
}
if addDirectory {
resp.Headers["Link"] = append(resp.Headers["Link"], directory)
}
}
// ACME responses don't understand Vault's default encoding
// format. Rather than expecting everything to handle creating
// ACME-formatted responses, do the marshaling in one place.
if _, ok := resp.Data[logical.HTTPRawBody]; !ok {
ignored_values := map[string]bool{logical.HTTPContentType: true, logical.HTTPStatusCode: true}
fields := map[string]interface{}{}
body := map[string]interface{}{
logical.HTTPContentType: "application/json",
logical.HTTPStatusCode: http.StatusOK,
}
for key, value := range resp.Data {
if _, present := ignored_values[key]; !present {
fields[key] = value
} else {
body[key] = value
}
}
rawBody, err := json.Marshal(fields)
if err != nil {
return nil, fmt.Errorf("Error marshaling JSON body: %w", err)
}
body[logical.HTTPRawBody] = rawBody
resp.Data = body
}
}
return resp, err
})
}
// acmeAccountRequiredWrapper builds on top of acmeParsedWrapper, enforcing the
// request has a proper signature for an existing account, and that account is
// in a valid status. It passes to the operation a decoded form of the request
// parameters as well as the ACME account the request is for.
func (b *backend) acmeAccountRequiredWrapper(op acmeAccountRequiredOperation) framework.OperationFunc {
return b.acmeParsedWrapper(func(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData, uc *jwsCtx, data map[string]interface{}) (*logical.Response, error) {
if !uc.Existing {
return nil, fmt.Errorf("cannot process request without a 'kid': %w", ErrMalformed)
}
account, err := b.acmeState.LoadAccount(acmeCtx, uc.Kid)
if err != nil {
return nil, fmt.Errorf("error loading account: %w", err)
}
if account.Status != StatusValid {
// Treating "revoked" and "deactivated" as the same here.
return nil, fmt.Errorf("%w: account in status: %s", ErrUnauthorized, account.Status)
}
return op(acmeCtx, r, fields, uc, data, account)
})
}
// A helper function that will build up the various path patterns we want for ACME APIs.
func buildAcmeFrameworkPaths(b *backend, patternFunc func(b *backend, pattern string) *framework.Path, acmeApi string) []*framework.Path {
var patterns []*framework.Path
for _, baseUrl := range []string{
"acme",
"roles/" + framework.GenericNameRegex("role") + "/acme",
"issuer/" + framework.GenericNameRegex(issuerRefParam) + "/acme",
"issuer/" + framework.GenericNameRegex(issuerRefParam) + "/roles/" + framework.GenericNameRegex("role") + "/acme",
} {
if !strings.HasPrefix(acmeApi, "/") {
acmeApi = "/" + acmeApi
}
path := patternFunc(b, baseUrl+acmeApi)
patterns = append(patterns, path)
}
return patterns
}
func getAcmeBaseUrl(sc *storageContext, path string) (*url.URL, *url.URL, error) {
cfg, err := sc.getClusterConfig()
if err != nil {
return nil, nil, fmt.Errorf("failed loading cluster config: %w", err)
}
if cfg.Path == "" {
return nil, nil, fmt.Errorf("ACME feature requires local cluster path configuration to be set: %w", ErrServerInternal)
}
baseUrl, err := url.Parse(cfg.Path)
if err != nil {
return nil, nil, fmt.Errorf("ACME feature a proper URL configured in local cluster path: %w", ErrServerInternal)
}
directoryPrefix := ""
lastIndex := strings.LastIndex(path, "/acme/")
if lastIndex != -1 {
directoryPrefix = path[0:lastIndex]
}
return baseUrl.JoinPath(directoryPrefix, "/acme/"), baseUrl, nil
}
func getAcmeIssuer(sc *storageContext, issuerName string) (*issuerEntry, error) {
if issuerName == "" {
issuerName = defaultRef
}
issuerId, err := sc.resolveIssuerReference(issuerName)
if err != nil {
return nil, fmt.Errorf("%w: issuer does not exist", ErrMalformed)
}
issuer, err := sc.fetchIssuerById(issuerId)
if err != nil {
return nil, fmt.Errorf("issuer failed to load: %w", err)
}
if issuer.Usage.HasUsage(IssuanceUsage) && len(issuer.KeyID) > 0 {
return issuer, nil
}
return nil, fmt.Errorf("%w: issuer missing proper issuance usage or key", ErrServerInternal)
}
func getAcmeDirectory(data *framework.FieldData) string {
requestedIssuer := getRequestedAcmeIssuerFromPath(data)
requestedRole := getRequestedAcmeRoleFromPath(data)
return fmt.Sprintf("issuer-%s::role-%s", requestedIssuer, requestedRole)
}
func getAcmeRoleAndIssuer(sc *storageContext, data *framework.FieldData, config *acmeConfigEntry) (*roleEntry, *issuerEntry, error) {
requestedIssuer := getRequestedAcmeIssuerFromPath(data)
requestedRole := getRequestedAcmeRoleFromPath(data)
issuerToLoad := requestedIssuer
var wasVerbatim bool
var role *roleEntry
if len(requestedRole) > 0 || len(config.DefaultRole) > 0 {
if len(requestedRole) == 0 {
requestedRole = config.DefaultRole
}
var err error
role, err = sc.Backend.getRole(sc.Context, sc.Storage, requestedRole)
if err != nil {
return nil, nil, fmt.Errorf("%w: err loading role", ErrServerInternal)
}
if role == nil {
return nil, nil, fmt.Errorf("%w: role does not exist", ErrMalformed)
}
if role.NoStore {
return nil, nil, fmt.Errorf("%w: role can not be used as NoStore is set to true", ErrServerInternal)
}
// If we haven't loaded an issuer directly from our path and the specified
// role does specify an issuer prefer the role's issuer rather than the default issuer.
if len(role.Issuer) > 0 && len(requestedIssuer) == 0 {
issuerToLoad = role.Issuer
}
} else {
role = buildSignVerbatimRoleWithNoData(&roleEntry{
Issuer: requestedIssuer,
NoStore: false,
Name: requestedRole,
})
wasVerbatim = true
}
allowAnyRole := len(config.AllowedRoles) == 1 && config.AllowedRoles[0] == "*"
if !allowAnyRole {
if wasVerbatim {
return nil, nil, fmt.Errorf("%w: using the default directory without specifying a role is not supported by this configuration; specify 'default_role' in the acme config to the default directories", ErrServerInternal)
}
var foundRole bool
for _, name := range config.AllowedRoles {
if name == role.Name {
foundRole = true
break
}
}
if !foundRole {
return nil, nil, fmt.Errorf("%w: specified role not allowed by ACME policy", ErrServerInternal)
}
}
issuer, err := getAcmeIssuer(sc, issuerToLoad)
if err != nil {
return nil, nil, err
}
allowAnyIssuer := len(config.AllowedIssuers) == 1 && config.AllowedIssuers[0] == "*"
if !allowAnyIssuer {
var foundIssuer bool
for index, name := range config.AllowedIssuers {
candidateId, err := sc.resolveIssuerReference(name)
if err != nil {
return nil, nil, fmt.Errorf("failed to resolve reference for allowed_issuer entry %d: %w", index, err)
}
if candidateId == issuer.ID {
foundIssuer = true
break
}
}
if !foundIssuer {
return nil, nil, fmt.Errorf("%w: specified issuer not allowed by ACME policy", ErrServerInternal)
}
}
return role, issuer, nil
}
func getRequestedAcmeRoleFromPath(data *framework.FieldData) string {
requestedRole := ""
roleNameRaw, present := data.GetOk("role")
if present {
requestedRole = roleNameRaw.(string)
}
return requestedRole
}
func getRequestedAcmeIssuerFromPath(data *framework.FieldData) string {
requestedIssuer := ""
requestedIssuerRaw, present := data.GetOk(issuerRefParam)
if present {
requestedIssuer = requestedIssuerRaw.(string)
}
return requestedIssuer
}
func isAcmeDisabled(sc *storageContext, config *acmeConfigEntry) bool {
if !config.Enabled {
return true
}
if disableAcmeRaw := os.Getenv("VAULT_DISABLE_PUBLIC_ACME"); disableAcmeRaw != "" {
disableAcme, err := strconv.ParseBool(disableAcmeRaw)
if err != nil {
sc.Backend.Logger().Warn("could not parse env var VAULT_DISABLE_PUBLIC_ACME", "error", err)
disableAcme = false
}
// The OS environment if true will override any configuration option.
if disableAcme {
// TODO: If EAB is enforced in the configuration, don't mark
// ACME as disabled.
return true
}
}
return false
}