diff --git a/builtin/logical/ssh/backend.go b/builtin/logical/ssh/backend.go index b7b1b5acf0..454937f16f 100644 --- a/builtin/logical/ssh/backend.go +++ b/builtin/logical/ssh/backend.go @@ -47,7 +47,7 @@ func Backend(conf *logical.BackendConfig) (*backend, error) { SealWrapStorage: []string{ caPrivateKey, caPrivateKeyStoragePath, - "keys/", + keysStoragePrefix, }, }, @@ -62,6 +62,7 @@ func Backend(conf *logical.BackendConfig) (*backend, error) { pathSign(&b), pathIssue(&b), pathFetchPublicKey(&b), + pathCleanupKeys(&b), }, Secrets: []*framework.Secret{ diff --git a/builtin/logical/ssh/backend_test.go b/builtin/logical/ssh/backend_test.go index 5b28e6ea20..6382d61d21 100644 --- a/builtin/logical/ssh/backend_test.go +++ b/builtin/logical/ssh/backend_test.go @@ -23,6 +23,8 @@ import ( vaulthttp "github.com/hashicorp/vault/http" "github.com/hashicorp/vault/vault" "github.com/mitchellh/mapstructure" + + "github.com/stretchr/testify/require" ) const ( @@ -2404,3 +2406,59 @@ func testCredsWrite(t *testing.T, roleName string, data map[string]interface{}, }, } } + +func TestBackend_CleanupDynamicHostKeys(t *testing.T) { + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + + b, err := Backend(config) + if err != nil { + t.Fatal(err) + } + err = b.Setup(context.Background(), config) + if err != nil { + t.Fatal(err) + } + + // Running on a clean mount shouldn't do anything. + cleanRequest := &logical.Request{ + Operation: logical.DeleteOperation, + Path: "tidy/dynamic-keys", + Storage: config.StorageView, + } + + resp, err := b.HandleRequest(context.Background(), cleanRequest) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.Data) + require.NotNil(t, resp.Data["message"]) + require.Contains(t, resp.Data["message"], "0 of 0") + + // Write a bunch of bogus entries. + for i := 0; i < 15; i++ { + data := map[string]interface{}{ + "host": "localhost", + "key": "nothing-to-see-here", + } + entry, err := logical.StorageEntryJSON(fmt.Sprintf("%vexample-%v", keysStoragePrefix, i), &data) + require.NoError(t, err) + err = config.StorageView.Put(context.Background(), entry) + require.NoError(t, err) + } + + // Should now have 15 + resp, err = b.HandleRequest(context.Background(), cleanRequest) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.Data) + require.NotNil(t, resp.Data["message"]) + require.Contains(t, resp.Data["message"], "15 of 15") + + // Should have none left. + resp, err = b.HandleRequest(context.Background(), cleanRequest) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.Data) + require.NotNil(t, resp.Data["message"]) + require.Contains(t, resp.Data["message"], "0 of 0") +} diff --git a/builtin/logical/ssh/path_cleanup_dynamic_host_keys.go b/builtin/logical/ssh/path_cleanup_dynamic_host_keys.go new file mode 100644 index 0000000000..4318e0b014 --- /dev/null +++ b/builtin/logical/ssh/path_cleanup_dynamic_host_keys.go @@ -0,0 +1,42 @@ +package ssh + +import ( + "context" + "fmt" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +const keysStoragePrefix = "keys/" + +func pathCleanupKeys(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "tidy/dynamic-keys", + Callbacks: map[logical.Operation]framework.OperationFunc{ + logical.DeleteOperation: b.handleCleanupKeys, + }, + HelpSynopsis: `This endpoint removes the stored host keys used for the removed Dynamic Key feature, if present.`, + HelpDescription: `For more information, refer to the API documentation.`, + } +} + +func (b *backend) handleCleanupKeys(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + names, err := req.Storage.List(ctx, keysStoragePrefix) + if err != nil { + return nil, fmt.Errorf("unable to list keys for removal: %w", err) + } + + for index, name := range names { + keyPath := keysStoragePrefix + name + if err := req.Storage.Delete(ctx, keyPath); err != nil { + return nil, fmt.Errorf("unable to delete key %v of %v: %w", index+1, len(names), err) + } + } + + return &logical.Response{ + Data: map[string]interface{}{ + "message": fmt.Sprintf("Removed %v of %v host keys.", len(names), len(names)), + }, + }, nil +} diff --git a/changelog/18939.txt b/changelog/18939.txt new file mode 100644 index 0000000000..aa7f8e7c66 --- /dev/null +++ b/changelog/18939.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/ssh: Allow removing SSH host keys from the dynamic keys feature. +``` diff --git a/website/content/api-docs/secret/ssh.mdx b/website/content/api-docs/secret/ssh.mdx index 97e5fa3cd7..2c2c1db0a2 100644 --- a/website/content/api-docs/secret/ssh.mdx +++ b/website/content/api-docs/secret/ssh.mdx @@ -879,3 +879,48 @@ $ curl \ "auth": null } ``` + +## Tidy Host Keys + +This endpoint removes all existing host keys from Vault, if any are present. +These keys were used with the Dynamic Keys functionality, which were removed +from this engine. + +~> Note: This does not clean up any pending dynamic key leases and will not + remove these keys from systems with authorized hosts entries created by + Vault. That must be done manually by an operator, potentially before the + removal of these host keys if they are necessary to access these + systems.

+ For a more effective cleanup process, it is suggest to stay on Vault 1.12, + manually revoke all dynamic key leases, wait for this to finish, and then + upgrade to Vault 1.13+. + +| Method | Path | +| :------- | :----------------------- | +| `DELETE` | `/ssh/tidy/dynamic-keys` | + +### Sample Request + +```shell-session +$ curl \ + --header "X-Vault-Token: ..." \ + --request DELETE \ + http://127.0.0.1:8200/v1/ssh/issue/my-role +``` + +### Sample Response + +```json +{ + "request_id": "", + "lease_id": "", + "renewable": false, + "lease_duration": 0, + "data": { + "message": "Removed 15 of 15 host keys." + }, + "wrap_info": null, + "warnings": null, + "auth": null +} +```