diff --git a/builtin/logical/pki/acme/state.go b/builtin/logical/pki/acme/state.go index 889d75eb5615..798bb7acae9e 100644 --- a/builtin/logical/pki/acme/state.go +++ b/builtin/logical/pki/acme/state.go @@ -20,11 +20,11 @@ type ACMEState struct { nonces *sync.Map // map[string]time.Time } -func NewACMEState() (*ACMEState, error) { +func NewACMEState() *ACMEState { return &ACMEState{ nextExpiry: new(atomic.Int64), nonces: new(sync.Map), - }, nil + } } func generateNonce() (string, error) { diff --git a/builtin/logical/pki/acme/state_test.go b/builtin/logical/pki/acme/state_test.go index e33dd91b8e0b..55bde1fd786d 100644 --- a/builtin/logical/pki/acme/state_test.go +++ b/builtin/logical/pki/acme/state_test.go @@ -9,8 +9,7 @@ import ( func TestAcmeNonces(t *testing.T) { t.Parallel() - a, err := NewACMEState() - require.NoError(t, err) + a := NewACMEState() // Simple operation should succeed. nonce, _, err := a.GetNonce() diff --git a/builtin/logical/pki/backend.go b/builtin/logical/pki/backend.go index e85efecd83ec..ec40948940ec 100644 --- a/builtin/logical/pki/backend.go +++ b/builtin/logical/pki/backend.go @@ -12,6 +12,8 @@ import ( "sync/atomic" "time" + "github.com/hashicorp/vault/builtin/logical/pki/acme" + atomic2 "go.uber.org/atomic" "github.com/hashicorp/vault/helper/constants" @@ -218,6 +220,11 @@ func Backend(conf *logical.BackendConfig) *backend { pathAcmeRoleDirectory(&b), pathAcmeIssuerDirectory(&b), pathAcmeIssuerAndRoleDirectory(&b), + + pathAcmeRootNonce(&b), + pathAcmeRoleNonce(&b), + pathAcmeIssuerNonce(&b), + pathAcmeIssuerAndRoleNonce(&b), }, Secrets: []*framework.Secret{ @@ -282,6 +289,7 @@ func Backend(conf *logical.BackendConfig) *backend { b.unifiedTransferStatus = newUnifiedTransferStatus() + b.acmeState = acme.NewACMEState() return &b } @@ -314,6 +322,7 @@ type backend struct { // Write lock around issuers and keys. issuersLock sync.RWMutex + acmeState *acme.ACMEState } type roleOperation func(ctx context.Context, req *logical.Request, data *framework.FieldData, role *roleEntry) (*logical.Response, error) diff --git a/builtin/logical/pki/backend_test.go b/builtin/logical/pki/backend_test.go index 094c3e950717..50c9b4014f49 100644 --- a/builtin/logical/pki/backend_test.go +++ b/builtin/logical/pki/backend_test.go @@ -6811,6 +6811,7 @@ func TestProperAuthing(t *testing.T) { // 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 + paths[acmePrefix+"acme/new-nonce"] = shouldBeUnauthedReadList } for path, checkerType := range paths { diff --git a/builtin/logical/pki/path_acme_nonce.go b/builtin/logical/pki/path_acme_nonce.go new file mode 100644 index 000000000000..4189b9c1738c --- /dev/null +++ b/builtin/logical/pki/path_acme_nonce.go @@ -0,0 +1,96 @@ +package pki + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +func pathAcmeRootNonce(b *backend) *framework.Path { + return patternAcmeNonce(b, "acme/new-nonce", false /* requireRole */, false /* requireIssuer */) +} + +func pathAcmeRoleNonce(b *backend) *framework.Path { + return patternAcmeNonce(b, "roles/"+framework.GenericNameRegex("role")+"/acme/new-nonce", + true /* requireRole */, false /* requireIssuer */) +} + +func pathAcmeIssuerNonce(b *backend) *framework.Path { + return patternAcmeNonce(b, "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/acme/new-nonce", + false /* requireRole */, true /* requireIssuer */) +} + +func pathAcmeIssuerAndRoleNonce(b *backend) *framework.Path { + return patternAcmeNonce(b, + "issuer/"+framework.GenericNameRegex(issuerRefParam)+"/roles/"+framework.GenericNameRegex( + "role")+"/acme/new-nonce", + true /* requireRole */, true /* requireIssuer */) +} + +func patternAcmeNonce(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.HeaderOperation: &framework.PathOperation{ + Callback: b.acmeWrapper(b.acmeNonceHandler), + ForwardPerformanceSecondary: false, + ForwardPerformanceStandby: true, + }, + logical.ReadOperation: &framework.PathOperation{ + Callback: b.acmeWrapper(b.acmeNonceHandler), + ForwardPerformanceSecondary: false, + ForwardPerformanceStandby: true, + }, + }, + + HelpSynopsis: pathAcmeDirectoryHelpSync, + HelpDescription: pathAcmeDirectoryHelpDesc, + } +} + +func (b *backend) acmeNonceHandler(ctx acmeContext, r *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + nonce, _, err := b.acmeState.GetNonce() + if err != nil { + return nil, err + } + + // Header operations return 200, GET return 204. + httpStatus := http.StatusOK + if r.Operation == logical.ReadOperation { + httpStatus = http.StatusNoContent + } + + return &logical.Response{ + Headers: map[string][]string{ + "Cache-Control": {"no-store"}, + "Replay-Nonce": {nonce}, + "Link": genAcmeLinkHeader(ctx), + }, + Data: map[string]interface{}{ + logical.HTTPStatusCode: httpStatus, + }, + }, nil +} + +func genAcmeLinkHeader(ctx acmeContext) []string { + path := fmt.Sprintf("<%s>;rel=\"index\"", ctx.baseUrl.JoinPath("/acme/directory").String()) + return []string{path} +} diff --git a/builtin/logical/pki/path_acme_directory_test.go b/builtin/logical/pki/path_acme_test.go similarity index 60% rename from builtin/logical/pki/path_acme_directory_test.go rename to builtin/logical/pki/path_acme_test.go index 19075e2bd56c..18f2bccf221b 100644 --- a/builtin/logical/pki/path_acme_directory_test.go +++ b/builtin/logical/pki/path_acme_test.go @@ -1,9 +1,12 @@ package pki import ( + "fmt" "net/http" "testing" + "github.com/hashicorp/vault/sdk/logical" + "github.com/stretchr/testify/require" "gopkg.in/square/go-jose.v2/json" ) @@ -12,16 +15,7 @@ import ( // 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) + b, s, pathConfig := setupAcmeBackend(t) cases := []struct { name string @@ -65,6 +59,65 @@ func TestAcmeDirectory(t *testing.T) { } } +func TestAcmeNonce(t *testing.T) { + t.Parallel() + b, s, pathConfig := setupAcmeBackend(t) + + cases := []struct { + name string + prefixUrl string + directoryUrl string + }{ + {"root", "", "acme/new-nonce"}, + {"role", "/roles/test-role", "roles/test-role/acme/new-nonce"}, + {"issuer", "/issuer/default", "issuer/default/acme/new-nonce"}, + {"issuer_role", "/issuer/default/roles/test-role", "issuer/default/roles/test-role/acme/new-nonce"}, + } + for _, tc := range cases { + for _, httpOp := range []string{"get", "header"} { + t.Run(fmt.Sprintf("%s-%s", tc.name, httpOp), func(t *testing.T) { + var resp *logical.Response + var err error + switch httpOp { + case "get": + resp, err = CBRead(b, s, tc.directoryUrl) + case "header": + resp, err = CBHeader(b, s, tc.directoryUrl) + } + require.NoError(t, err, "failed %s op for new-nouce", httpOp) + + // Proper Status Code + switch httpOp { + case "get": + require.Equal(t, http.StatusNoContent, resp.Data["http_status_code"]) + case "header": + require.Equal(t, http.StatusOK, resp.Data["http_status_code"]) + } + + // Make sure we return the Cache-Control header + require.Contains(t, resp.Headers, "Cache-Control", "missing Cache-Control header") + require.Contains(t, resp.Headers["Cache-Control"], "no-store", + "missing Cache-Control header with no-store header value") + require.Len(t, resp.Headers["Cache-Control"], 1, + "Cache-Control header should have only a single header") + + // Test for our nonce header value + require.Contains(t, resp.Headers, "Replay-Nonce", "missing Replay-Nonce header") + require.NotEmpty(t, resp.Headers["Replay-Nonce"], "missing Replay-Nonce header with an actual value") + require.Len(t, resp.Headers["Replay-Nonce"], 1, + "Replay-Nonce header should have only a single header") + + // Test Link header value + require.Contains(t, resp.Headers, "Link", "missing Link header") + expectedLinkHeader := fmt.Sprintf("<%s>;rel=\"index\"", pathConfig+tc.prefixUrl+"/acme/directory") + require.Contains(t, resp.Headers["Link"], expectedLinkHeader, + "different value for link header than expected") + require.Len(t, resp.Headers["Link"], 1, "Link header should have only a single header") + }) + } + } +} + // TestAcmeClusterPathNotConfigured basic testing of the ACME error handler. func TestAcmeClusterPathNotConfigured(t *testing.T) { t.Parallel() @@ -102,3 +155,17 @@ func TestAcmeClusterPathNotConfigured(t *testing.T) { }) } } + +func setupAcmeBackend(t *testing.T) (*backend, logical.Storage, string) { + 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) + return b, s, pathConfig +} diff --git a/builtin/logical/pki/test_helpers.go b/builtin/logical/pki/test_helpers.go index ef9b46834874..616abdca077e 100644 --- a/builtin/logical/pki/test_helpers.go +++ b/builtin/logical/pki/test_helpers.go @@ -211,6 +211,10 @@ func CBReq(b *backend, s logical.Storage, operation logical.Operation, path stri return resp, nil } +func CBHeader(b *backend, s logical.Storage, path string) (*logical.Response, error) { + return CBReq(b, s, logical.HeaderOperation, path, make(map[string]interface{})) +} + func CBRead(b *backend, s logical.Storage, path string) (*logical.Response, error) { return CBReq(b, s, logical.ReadOperation, path, make(map[string]interface{})) }