forked from hashicorp/vault
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
PKI: Initial ACME directory API support (hashicorp#19803)
* PKI: Initial ACME directory API support along with basic tests for error handler and the directory itself across various paths.
- Loading branch information
1 parent
b6e0077
commit 4063d6e
Showing
4 changed files
with
288 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"]) | ||
}) | ||
} | ||
} |