PKI: Initial ACME directory API support (#19803)
* PKI: Initial ACME directory API support along with basic tests for error handler and the directory itself across various paths.
This commit is contained in:
parent
f2a4b23b7f
commit
bc57865998
|
@ -113,6 +113,8 @@ func Backend(conf *logical.BackendConfig) *backend {
|
|||
"unified-crl",
|
||||
"unified-ocsp", // Unified OCSP POST
|
||||
"unified-ocsp/*", // Unified OCSP GET
|
||||
|
||||
// ACME paths are added below
|
||||
},
|
||||
|
||||
LocalStorage: []string{
|
||||
|
@ -210,6 +212,12 @@ func Backend(conf *logical.BackendConfig) *backend {
|
|||
// CRL Signing
|
||||
pathResignCrls(&b),
|
||||
pathSignRevocationList(&b),
|
||||
|
||||
// ACME APIs
|
||||
pathAcmeRootDirectory(&b),
|
||||
pathAcmeRoleDirectory(&b),
|
||||
pathAcmeIssuerDirectory(&b),
|
||||
pathAcmeIssuerAndRoleDirectory(&b),
|
||||
},
|
||||
|
||||
Secrets: []*framework.Secret{
|
||||
|
@ -222,6 +230,15 @@ func Backend(conf *logical.BackendConfig) *backend {
|
|||
PeriodicFunc: b.periodicFunc,
|
||||
}
|
||||
|
||||
// Add specific un-auth'd paths for ACME APIs
|
||||
for _, acmePrefix := range []string{"", "issuer/+/", "roles/+/", "issuer/+/roles/+/"} {
|
||||
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/directory")
|
||||
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/new-nonce")
|
||||
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/new-order")
|
||||
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/revoke-cert")
|
||||
b.PathsSpecial.Unauthenticated = append(b.PathsSpecial.Unauthenticated, acmePrefix+"acme/key-change")
|
||||
}
|
||||
|
||||
if constants.IsEnterprise {
|
||||
// Unified CRL/OCSP paths are ENT only
|
||||
entOnly := []*framework.Path{
|
||||
|
|
|
@ -6807,6 +6807,12 @@ func TestProperAuthing(t *testing.T) {
|
|||
"unified-ocsp": shouldBeUnauthedWriteOnly,
|
||||
"unified-ocsp/dGVzdAo=": shouldBeUnauthedReadList,
|
||||
}
|
||||
|
||||
// Add ACME based paths to the test suite
|
||||
for _, acmePrefix := range []string{"", "issuer/default/", "roles/test/", "issuer/default/roles/test/"} {
|
||||
paths[acmePrefix+"acme/directory"] = shouldBeUnauthedReadList
|
||||
}
|
||||
|
||||
for path, checkerType := range paths {
|
||||
checker := pathAuthChckerMap[checkerType]
|
||||
checker(t, client, "pki/"+path, token)
|
||||
|
|
|
@ -0,0 +1,161 @@
|
|||
package pki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/vault/builtin/logical/pki/acme"
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
const (
|
||||
pathAcmeDirectoryHelpSync = `Read the proper URLs for various ACME operations`
|
||||
pathAcmeDirectoryHelpDesc = `Provide an ACME directory response that contains URLS for various ACME operations.`
|
||||
)
|
||||
|
||||
func pathAcmeRootDirectory(b *backend) *framework.Path {
|
||||
return patternAcmeDirectory(b, "acme/directory", false /* requireRole */, false /* requireIssuer */)
|
||||
}
|
||||
|
||||
func pathAcmeRoleDirectory(b *backend) *framework.Path {
|
||||
return patternAcmeDirectory(b, "roles/"+framework.GenericNameRegex("role")+"/acme/directory",
|
||||
true /* requireRole */, false /* requireIssuer */)
|
||||
}
|
||||
|
||||
func pathAcmeIssuerDirectory(b *backend) *framework.Path {
|
||||
return patternAcmeDirectory(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/directory",
|
||||
false /* requireRole */, true /* requireIssuer */)
|
||||
}
|
||||
|
||||
func pathAcmeIssuerAndRoleDirectory(b *backend) *framework.Path {
|
||||
return patternAcmeDirectory(b,
|
||||
"issuer/"+framework.GenericNameRegex(issuerRefParam)+"/roles/"+framework.GenericNameRegex(
|
||||
"role")+"/acme/directory",
|
||||
true /* requireRole */, true /* requireIssuer */)
|
||||
}
|
||||
|
||||
func patternAcmeDirectory(b *backend, pattern string, requireRole, requireIssuer bool) *framework.Path {
|
||||
fields := map[string]*framework.FieldSchema{}
|
||||
if requireRole {
|
||||
fields["role"] = &framework.FieldSchema{
|
||||
Type: framework.TypeString,
|
||||
Description: `The desired role for the acme request`,
|
||||
Required: true,
|
||||
}
|
||||
}
|
||||
if requireIssuer {
|
||||
fields[issuerRefParam] = &framework.FieldSchema{
|
||||
Type: framework.TypeString,
|
||||
Description: `Reference to an existing issuer name or issuer id`,
|
||||
Required: true,
|
||||
}
|
||||
}
|
||||
return &framework.Path{
|
||||
Pattern: pattern,
|
||||
Fields: fields,
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.ReadOperation: &framework.PathOperation{
|
||||
Callback: b.acmeWrapper(b.acmeDirectoryHandler),
|
||||
ForwardPerformanceSecondary: false,
|
||||
ForwardPerformanceStandby: true,
|
||||
},
|
||||
},
|
||||
|
||||
HelpSynopsis: pathAcmeDirectoryHelpSync,
|
||||
HelpDescription: pathAcmeDirectoryHelpDesc,
|
||||
}
|
||||
}
|
||||
|
||||
type acmeOperation func(acmeCtx acmeContext, r *logical.Request, _ *framework.FieldData) (*logical.Response, error)
|
||||
|
||||
type acmeContext struct {
|
||||
baseUrl *url.URL
|
||||
sc *storageContext
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if false {
|
||||
// TODO sclark: Check if ACME is enable here
|
||||
return nil, fmt.Errorf("ACME is disabled in configuration: %w", acme.ErrServerInternal)
|
||||
}
|
||||
|
||||
baseUrl, err := getAcmeBaseUrl(sc, r.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
acmeCtx := acmeContext{
|
||||
baseUrl: baseUrl,
|
||||
sc: sc,
|
||||
}
|
||||
|
||||
return op(acmeCtx, r, data)
|
||||
})
|
||||
}
|
||||
|
||||
func getAcmeBaseUrl(sc *storageContext, path string) (*url.URL, error) {
|
||||
cfg, err := sc.getClusterConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed loading cluster config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Path == "" {
|
||||
return nil, fmt.Errorf("ACME feature requires local cluster path configuration to be set: %w", acme.ErrServerInternal)
|
||||
}
|
||||
|
||||
baseUrl, err := url.Parse(cfg.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ACME feature a proper URL configured in local cluster path: %w", acme.ErrServerInternal)
|
||||
}
|
||||
|
||||
directoryPrefix := ""
|
||||
lastIndex := strings.LastIndex(path, "/acme/")
|
||||
if lastIndex != -1 {
|
||||
directoryPrefix = path[0:lastIndex]
|
||||
}
|
||||
|
||||
return baseUrl.JoinPath(directoryPrefix), nil
|
||||
}
|
||||
|
||||
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 acme.TranslateError(err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (b *backend) acmeDirectoryHandler(acmeCtx acmeContext, r *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
|
||||
rawBody, err := json.Marshal(map[string]interface{}{
|
||||
"newNonce": acmeCtx.baseUrl.JoinPath("/acme/new-nonce").String(),
|
||||
"newAccount": acmeCtx.baseUrl.JoinPath("/acme/new-account").String(),
|
||||
"newOrder": acmeCtx.baseUrl.JoinPath("/acme/new-order").String(),
|
||||
"revokeCert": acmeCtx.baseUrl.JoinPath("/acme/revoke-cert").String(),
|
||||
"keyChange": acmeCtx.baseUrl.JoinPath("/acme/key-change").String(),
|
||||
"meta": map[string]interface{}{
|
||||
"externalAccountRequired": false,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed encoding response: %w", err)
|
||||
}
|
||||
|
||||
return &logical.Response{
|
||||
Data: map[string]interface{}{
|
||||
logical.HTTPContentType: "application/json",
|
||||
logical.HTTPStatusCode: http.StatusOK,
|
||||
logical.HTTPRawBody: rawBody,
|
||||
},
|
||||
}, nil
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
package pki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/square/go-jose.v2/json"
|
||||
)
|
||||
|
||||
// TestAcmeDirectory a basic test that will validate the various directory APIs
|
||||
// are available and produce the correct responses.
|
||||
func TestAcmeDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, s := CreateBackendWithStorage(t)
|
||||
|
||||
// Setting templated AIAs should succeed.
|
||||
pathConfig := "https://localhost:8200/v1/pki"
|
||||
|
||||
_, err := CBWrite(b, s, "config/cluster", map[string]interface{}{
|
||||
"path": pathConfig,
|
||||
"aia_path": "http://localhost:8200/cdn/pki",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
prefixUrl string
|
||||
directoryUrl string
|
||||
}{
|
||||
{"root", "", "acme/directory"},
|
||||
{"role", "/roles/test-role", "roles/test-role/acme/directory"},
|
||||
{"issuer", "/issuer/default", "issuer/default/acme/directory"},
|
||||
{"issuer_role", "/issuer/default/roles/test-role", "issuer/default/roles/test-role/acme/directory"},
|
||||
{"issuer_role_acme", "/issuer/acme/roles/acme", "issuer/acme/roles/acme/acme/directory"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dirResp, err := CBRead(b, s, tc.directoryUrl)
|
||||
require.NoError(t, err, "failed reading ACME directory configuration")
|
||||
|
||||
require.Contains(t, dirResp.Data, "http_content_type", "missing Content-Type header")
|
||||
require.Contains(t, dirResp.Data["http_content_type"], "application/json",
|
||||
"missing appropriate content type in header")
|
||||
|
||||
requiredUrls := map[string]string{
|
||||
"newNonce": pathConfig + tc.prefixUrl + "/acme/new-nonce",
|
||||
"newAccount": pathConfig + tc.prefixUrl + "/acme/new-account",
|
||||
"newOrder": pathConfig + tc.prefixUrl + "/acme/new-order",
|
||||
"revokeCert": pathConfig + tc.prefixUrl + "/acme/revoke-cert",
|
||||
"keyChange": pathConfig + tc.prefixUrl + "/acme/key-change",
|
||||
}
|
||||
|
||||
rawBodyBytes := dirResp.Data["http_raw_body"].([]byte)
|
||||
respType := map[string]interface{}{}
|
||||
err = json.Unmarshal(rawBodyBytes, &respType)
|
||||
require.NoError(t, err, "failed unmarshalling ACME directory response body")
|
||||
|
||||
for key, expectedUrl := range requiredUrls {
|
||||
require.Contains(t, respType, key, "missing required value %s from data", key)
|
||||
require.Equal(t, expectedUrl, respType[key], "different URL returned for %s", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAcmeClusterPathNotConfigured basic testing of the ACME error handler.
|
||||
func TestAcmeClusterPathNotConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, s := CreateBackendWithStorage(t)
|
||||
|
||||
// Do not fill in the path option within the local cluster configuration
|
||||
cases := []struct {
|
||||
name string
|
||||
directoryUrl string
|
||||
}{
|
||||
{"root", "acme/directory"},
|
||||
{"role", "roles/test-role/acme/directory"},
|
||||
{"issuer", "issuer/default/acme/directory"},
|
||||
{"issuer_role", "issuer/default/roles/test-role/acme/directory"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dirResp, err := CBRead(b, s, tc.directoryUrl)
|
||||
require.NoError(t, err, "failed reading ACME directory configuration")
|
||||
|
||||
require.Contains(t, dirResp.Data, "http_content_type", "missing Content-Type header")
|
||||
require.Contains(t, dirResp.Data["http_content_type"], "application/problem+json",
|
||||
"missing appropriate content type in header")
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, dirResp.Data["http_status_code"])
|
||||
|
||||
require.Contains(t, dirResp.Data, "http_raw_body", "missing http_raw_body from data")
|
||||
rawBodyBytes := dirResp.Data["http_raw_body"].([]byte)
|
||||
respType := map[string]interface{}{}
|
||||
err = json.Unmarshal(rawBodyBytes, &respType)
|
||||
require.NoError(t, err, "failed unmarshalling ACME directory response body")
|
||||
|
||||
require.Equal(t, "urn:ietf:params:acme:error:serverInternal", respType["type"])
|
||||
require.NotEmpty(t, respType["detail"])
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue