diff --git a/builtin/logical/pki/backend.go b/builtin/logical/pki/backend.go index 8aa021967..532187df2 100644 --- a/builtin/logical/pki/backend.go +++ b/builtin/logical/pki/backend.go @@ -134,6 +134,7 @@ func Backend(conf *logical.BackendConfig) *backend { pathRotateDeltaCRL(&b), pathRevoke(&b), pathRevokeWithKey(&b), + pathListCertsRevoked(&b), pathTidy(&b), pathTidyCancel(&b), pathTidyStatus(&b), diff --git a/builtin/logical/pki/backend_test.go b/builtin/logical/pki/backend_test.go index 6ef3af880..bfb96a847 100644 --- a/builtin/logical/pki/backend_test.go +++ b/builtin/logical/pki/backend_test.go @@ -5889,6 +5889,81 @@ func TestPKI_EmptyCRLConfigUpgraded(t *testing.T) { require.Equal(t, resp.Data["delta_rebuild_interval"], defaultCrlConfig.DeltaRebuildInterval) } +func TestPKI_ListRevokedCerts(t *testing.T) { + t.Parallel() + b, s := createBackendWithStorage(t) + + // Test empty cluster + resp, err := CBList(b, s, "certs/revoked") + requireSuccessNonNilResponse(t, resp, err, "failed listing empty cluster") + require.Empty(t, resp.Data, "response map contained data that we did not expect") + + // Set up a mount that we can revoke under (We will create 3 leaf certs, 2 of which will be revoked) + resp, err = CBWrite(b, s, "root/generate/internal", map[string]interface{}{ + "common_name": "test.com", + "key_type": "ec", + }) + requireSuccessNonNilResponse(t, resp, err, "error generating root CA") + requireFieldsSetInResp(t, resp, "serial_number") + issuerSerial := resp.Data["serial_number"] + + resp, err = CBWrite(b, s, "roles/test", map[string]interface{}{ + "allowed_domains": "test.com", + "allow_subdomains": "true", + "max_ttl": "1h", + }) + requireSuccessNilResponse(t, resp, err, "error setting up pki role") + + resp, err = CBWrite(b, s, "issue/test", map[string]interface{}{ + "common_name": "test1.test.com", + }) + requireSuccessNonNilResponse(t, resp, err, "error issuing cert 1") + requireFieldsSetInResp(t, resp, "serial_number") + serial1 := resp.Data["serial_number"] + + resp, err = CBWrite(b, s, "issue/test", map[string]interface{}{ + "common_name": "test2.test.com", + }) + requireSuccessNonNilResponse(t, resp, err, "error issuing cert 2") + requireFieldsSetInResp(t, resp, "serial_number") + serial2 := resp.Data["serial_number"] + + resp, err = CBWrite(b, s, "issue/test", map[string]interface{}{ + "common_name": "test3.test.com", + }) + requireSuccessNonNilResponse(t, resp, err, "error issuing cert 2") + requireFieldsSetInResp(t, resp, "serial_number") + serial3 := resp.Data["serial_number"] + + resp, err = CBWrite(b, s, "revoke", map[string]interface{}{"serial_number": serial1}) + requireSuccessNonNilResponse(t, resp, err, "error revoking cert 1") + + resp, err = CBWrite(b, s, "revoke", map[string]interface{}{"serial_number": serial2}) + requireSuccessNonNilResponse(t, resp, err, "error revoking cert 2") + + // Test that we get back the expected revoked serial numbers. + resp, err = CBList(b, s, "certs/revoked") + requireSuccessNonNilResponse(t, resp, err, "failed listing revoked certs") + requireFieldsSetInResp(t, resp, "keys") + revokedKeys := resp.Data["keys"].([]string) + + require.Contains(t, revokedKeys, serial1) + require.Contains(t, revokedKeys, serial2) + require.Equal(t, 2, len(revokedKeys), "Expected 2 revoked entries got %d: %v", len(revokedKeys), revokedKeys) + + // Test that listing our certs returns a different response + resp, err = CBList(b, s, "certs") + requireSuccessNonNilResponse(t, resp, err, "failed listing written certs") + requireFieldsSetInResp(t, resp, "keys") + certKeys := resp.Data["keys"].([]string) + + require.Contains(t, certKeys, serial1) + require.Contains(t, certKeys, serial2) + require.Contains(t, certKeys, serial3) + require.Contains(t, certKeys, issuerSerial) + require.Equal(t, 4, len(certKeys), "Expected 4 cert entries got %d: %v", len(certKeys), certKeys) +} + var ( initTest sync.Once rsaCAKey string diff --git a/builtin/logical/pki/path_revoke.go b/builtin/logical/pki/path_revoke.go index d33c013ca..d5aef7f22 100644 --- a/builtin/logical/pki/path_revoke.go +++ b/builtin/logical/pki/path_revoke.go @@ -18,6 +18,21 @@ import ( "github.com/hashicorp/vault/sdk/logical" ) +func pathListCertsRevoked(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "certs/revoked/?$", + + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ListOperation: &framework.PathOperation{ + Callback: b.pathListRevokedCertsHandler, + }, + }, + + HelpSynopsis: pathListRevokedHelpSyn, + HelpDescription: pathListRevokedHelpDesc, + } +} + func pathRevoke(b *backend) *framework.Path { return &framework.Path{ Pattern: `revoke`, @@ -466,6 +481,22 @@ func (b *backend) pathRotateDeltaCRLRead(ctx context.Context, req *logical.Reque return resp, nil } +func (b *backend) pathListRevokedCertsHandler(ctx context.Context, request *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + sc := b.makeStorageContext(ctx, request.Storage) + + revokedCerts, err := sc.listRevokedCerts() + if err != nil { + return nil, err + } + + // Normalize serial back to a format people are expecting. + for i, serial := range revokedCerts { + revokedCerts[i] = denormalizeSerial(serial) + } + + return logical.ListResponse(revokedCerts), nil +} + const pathRevokeHelpSyn = ` Revoke a certificate by serial number or with explicit certificate. @@ -493,3 +524,11 @@ Force a rebuild of the delta CRL. const pathRotateDeltaCRLHelpDesc = ` Force a rebuild of the delta CRL. This can be used to force an update of the otherwise periodically-rebuilt delta CRLs. ` + +const pathListRevokedHelpSyn = ` +List all revoked serial numbers within the local cluster +` + +const pathListRevokedHelpDesc = ` +Returns a list of serial numbers for revoked certificates in the local cluster. +` diff --git a/builtin/logical/pki/storage.go b/builtin/logical/pki/storage.go index 8bdd41149..e0abd6ed2 100644 --- a/builtin/logical/pki/storage.go +++ b/builtin/logical/pki/storage.go @@ -1216,3 +1216,12 @@ func (sc *storageContext) writeAutoTidyConfig(config *tidyConfig) error { return sc.Storage.Put(sc.Context, entry) } + +func (sc *storageContext) listRevokedCerts() ([]string, error) { + list, err := sc.Storage.List(sc.Context, revokedPath) + if err != nil { + return nil, fmt.Errorf("failed listing revoked certs: %w", err) + } + + return list, err +} diff --git a/changelog/17779.txt b/changelog/17779.txt new file mode 100644 index 000000000..a0b3bd6c3 --- /dev/null +++ b/changelog/17779.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/pki: Add a new API that returns the serial numbers of revoked certificates on the local cluster +``` diff --git a/website/content/api-docs/secret/pki.mdx b/website/content/api-docs/secret/pki.mdx index 198bc1cd8..91fff00bf 100644 --- a/website/content/api-docs/secret/pki.mdx +++ b/website/content/api-docs/secret/pki.mdx @@ -29,6 +29,7 @@ update your API calls accordingly. - [Sign Verbatim](#sign-verbatim) - [Revoke Certificate](#revoke-certificate) - [Revoke Certificate with Private Key](#revoke-certificate-with-private-key) + - [List Revoked Certificates](#list-revoked-certificates) - [Accessing Authority Information](#accessing-authority-information) - [List Issuers](#list-issuers) - [Read Issuer Certificate](#read-issuer-certificate) @@ -958,6 +959,36 @@ $ curl \ } ``` + +### List Revoked Certificates + +This endpoint returns a list of serial numbers that have been revoked on the local cluster. + +| Method | Path | +|:-------|:------------------| +| `LIST` | `/certs/revoked` | + +#### Sample Request + +```shell-session +$ curl \ + --header "X-Vault-Token: ..." \ + --request LIST \ + http://127.0.0.1:8200/v1/pki/certs/revoked +``` + +#### Sample Response + +```json +{ + "data": { + "keys": [ + "3d:80:91:c3:c2:34:3b:81:69:3d:92:a3:80:69:db:53:04:26:ab:b4" + ] + } +} +``` + --- ## Accessing Authority Information