From 92c1a2bd0a09bf6c5075cb69ae6cfda3a9b05204 Mon Sep 17 00:00:00 2001 From: Steven Clark Date: Tue, 22 Nov 2022 11:41:04 -0500 Subject: [PATCH] New PKI API to generate and sign a CRL based on input data (#18040) * New PKI API to generate and sign a CRL based on input data - Add a new PKI API that allows an end-user to feed in all the information required to generate and sign a CRL by a given issuer. - This is pretty powerful API allowing an escape hatch for 3rd parties to craft customized CRLs with extensions based on their individual needs * Add api-docs and error if reserved extension is provided as input * Fix copy/paste error in Object Identifier constants * Return nil on errors instead of partially filled slices * Add cl --- builtin/logical/pki/backend.go | 1 + builtin/logical/pki/path_resign_crls.go | 392 ++++++++++++++++++- builtin/logical/pki/path_resign_crls_test.go | 184 +++++++++ changelog/18040.txt | 3 + website/content/api-docs/secret/pki.mdx | 89 +++++ 5 files changed, 655 insertions(+), 14 deletions(-) create mode 100644 changelog/18040.txt diff --git a/builtin/logical/pki/backend.go b/builtin/logical/pki/backend.go index b41051178..5dbb94c77 100644 --- a/builtin/logical/pki/backend.go +++ b/builtin/logical/pki/backend.go @@ -180,6 +180,7 @@ func Backend(conf *logical.BackendConfig) *backend { // CRL Signing pathResignCrls(&b), + pathSignRevocationList(&b), }, Secrets: []*framework.Secret{ diff --git a/builtin/logical/pki/path_resign_crls.go b/builtin/logical/pki/path_resign_crls.go index 2f908b57a..7f8746aa9 100644 --- a/builtin/logical/pki/path_resign_crls.go +++ b/builtin/logical/pki/path_resign_crls.go @@ -5,14 +5,18 @@ import ( "crypto/rand" "crypto/x509" "crypto/x509/pkix" + "encoding/asn1" "encoding/base64" + "encoding/hex" "encoding/pem" "errors" "fmt" "math/big" + "strconv" "strings" "time" + "github.com/hashicorp/go-secure-stdlib/parseutil" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/helper/certutil" "github.com/hashicorp/vault/sdk/logical" @@ -26,6 +30,12 @@ const ( formatParam = "format" ) +var ( + akOid = asn1.ObjectIdentifier{2, 5, 29, 35} + crlNumOid = asn1.ObjectIdentifier{2, 5, 29, 20} + deltaCrlOid = asn1.ObjectIdentifier{2, 5, 29, 27} +) + func pathResignCrls(b *backend) *framework.Path { return &framework.Path{ Pattern: "issuer/" + framework.GenericNameRegex(issuerRefParam) + "/resign-crls", @@ -76,6 +86,62 @@ base64 encoded. Defaults to "pem".`, } } +func pathSignRevocationList(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "issuer/" + framework.GenericNameRegex(issuerRefParam) + "/sign-revocation-list", + Fields: map[string]*framework.FieldSchema{ + issuerRefParam: { + Type: framework.TypeString, + Description: `Reference to a existing issuer; either "default" +for the configured default issuer, an identifier or the name assigned +to the issuer.`, + Default: defaultRef, + }, + crlNumberParam: { + Type: framework.TypeInt, + Description: `The sequence number to be written within the CRL Number extension.`, + }, + deltaCrlBaseNumberParam: { + Type: framework.TypeInt, + Description: `Using a zero or greater value specifies the base CRL revision number to encode within + a Delta CRL indicator extension, otherwise the extension will not be added.`, + Default: -1, + }, + nextUpdateParam: { + Type: framework.TypeString, + Description: `The amount of time the generated CRL should be +valid; defaults to 72 hours.`, + Default: defaultCrlConfig.Expiry, + }, + formatParam: { + Type: framework.TypeString, + Description: `The format of the combined CRL, can be "pem" or "der". If "der", the value will be +base64 encoded. Defaults to "pem".`, + Default: "pem", + }, + "revoked_certs": { + Type: framework.TypeSlice, + Description: `A list of maps containing the keys serial_number (string), revocation_time (string), +and extensions (map with keys id (string), critical (bool), value (string))`, + }, + "extensions": { + Type: framework.TypeSlice, + Description: `A list of maps containing extensions with keys id (string), critical (bool), +value (string)`, + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathUpdateSignRevocationListHandler, + }, + }, + + HelpSynopsis: `Generate and sign a CRL based on the provided parameters.`, + HelpDescription: `Given a list of revoked certificates and other parameters, +return a signed CRL based on the parameter values.`, + } +} + func (b *backend) pathUpdateResignCrlsHandler(ctx context.Context, request *logical.Request, data *framework.FieldData) (*logical.Response, error) { if b.useLegacyBundleCaStorage() { return logical.ErrorResponse("This API cannot be used until the migration has completed"), nil @@ -87,12 +153,12 @@ func (b *backend) pathUpdateResignCrlsHandler(ctx context.Context, request *logi nextUpdateStr := data.Get(nextUpdateParam).(string) rawCrls := data.Get(crlsParam).([]string) - format, err := getCrlFormat(data.Get(formatParam).(string)) + format, err := parseCrlFormat(data.Get(formatParam).(string)) if err != nil { return logical.ErrorResponse(err.Error()), nil } - nextUpdateOffset, err := time.ParseDuration(nextUpdateStr) + nextUpdateOffset, err := parseutil.ParseDurationSecond(nextUpdateStr) if err != nil { return logical.ErrorResponse("invalid value for %s: %v", nextUpdateParam, err), nil } @@ -127,7 +193,7 @@ func (b *backend) pathUpdateResignCrlsHandler(ctx context.Context, request *logi return logical.ErrorResponse(err.Error()), nil } - revokedCerts, warnings, err := getAllRevokedCerts(providedCrls) + revokedCerts, warnings, err := getAllRevokedCertsFromPem(providedCrls) if err != nil { return logical.ErrorResponse(err.Error()), nil } @@ -164,6 +230,314 @@ func (b *backend) pathUpdateResignCrlsHandler(ctx context.Context, request *logi }, nil } +func (b *backend) pathUpdateSignRevocationListHandler(ctx context.Context, request *logical.Request, data *framework.FieldData) (*logical.Response, error) { + if b.useLegacyBundleCaStorage() { + return logical.ErrorResponse("This API cannot be used until the migration has completed"), nil + } + + issuerRef := getIssuerRef(data) + crlNumber := data.Get(crlNumberParam).(int) + deltaCrlBaseNumber := data.Get(deltaCrlBaseNumberParam).(int) + nextUpdateStr := data.Get(nextUpdateParam).(string) + nextUpdateOffset, err := parseutil.ParseDurationSecond(nextUpdateStr) + if err != nil { + return logical.ErrorResponse("invalid value for %s: %v", nextUpdateParam, err), nil + } + + if nextUpdateOffset <= 0 { + return logical.ErrorResponse("%s parameter must be greater than 0", nextUpdateParam), nil + } + + if crlNumber < 0 { + return logical.ErrorResponse("%s parameter must be 0 or greater", crlNumberParam), nil + } + if deltaCrlBaseNumber < -1 { + return logical.ErrorResponse("%s parameter must be -1 or greater", deltaCrlBaseNumberParam), nil + } + + if issuerRef == "" { + return logical.ErrorResponse("%s parameter cannot be blank", issuerRefParam), nil + } + + format, err := parseCrlFormat(data.Get(formatParam).(string)) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + + revokedCerts, err := parseRevokedCertsParam(data.Get("revoked_certs").([]interface{})) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + + crlExtensions, err := parseExtensionsParam(data.Get("extensions").([]interface{})) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + + sc := b.makeStorageContext(ctx, request.Storage) + caBundle, err := getCaBundle(sc, issuerRef) + if err != nil { + return logical.ErrorResponse(err.Error()), nil + } + + if deltaCrlBaseNumber > -1 { + ext, err := certutil.CreateDeltaCRLIndicatorExt(int64(deltaCrlBaseNumber)) + if err != nil { + return nil, fmt.Errorf("could not create crl delta indicator extension: %v", err) + } + crlExtensions = append(crlExtensions, ext) + } + + now := time.Now() + template := &x509.RevocationList{ + SignatureAlgorithm: caBundle.RevocationSigAlg, + RevokedCertificates: revokedCerts, + Number: big.NewInt(int64(crlNumber)), + ThisUpdate: now, + NextUpdate: now.Add(nextUpdateOffset), + ExtraExtensions: crlExtensions, + } + + crlBytes, err := x509.CreateRevocationList(rand.Reader, template, caBundle.Certificate, caBundle.PrivateKey) + if err != nil { + return nil, fmt.Errorf("error creating new CRL: %w", err) + } + + body := encodeResponse(crlBytes, format == "der") + + return &logical.Response{ + Data: map[string]interface{}{ + "crl": body, + }, + }, nil +} + +func parseRevokedCertsParam(revokedCerts []interface{}) ([]pkix.RevokedCertificate, error) { + var parsedCerts []pkix.RevokedCertificate + seenSerials := make(map[*big.Int]int) + for i, entry := range revokedCerts { + if revokedCert, ok := entry.(map[string]interface{}); ok { + serialNum, err := parseSerialNum(revokedCert) + if err != nil { + return nil, fmt.Errorf("failed parsing serial_number from entry %d: %w", i, err) + } + + if origEntry, exists := seenSerials[serialNum]; exists { + serialNumStr := revokedCert["serial_number"] + return nil, fmt.Errorf("duplicate serial number: %s, original entry %d and %d", serialNumStr, origEntry, i) + } + + seenSerials[serialNum] = i + + revocationTime, err := parseRevocationTime(revokedCert) + if err != nil { + return nil, fmt.Errorf("failed parsing revocation_time from entry %d: %w", i, err) + } + + extensions, err := parseCertExtensions(revokedCert) + if err != nil { + return nil, fmt.Errorf("failed parsing extensions from entry %d: %w", i, err) + } + + parsedCerts = append(parsedCerts, pkix.RevokedCertificate{ + SerialNumber: serialNum, + RevocationTime: revocationTime, + Extensions: extensions, + }) + } + } + + return parsedCerts, nil +} + +func parseCertExtensions(cert map[string]interface{}) ([]pkix.Extension, error) { + extRaw, exists := cert["extensions"] + if !exists || extRaw == nil || extRaw == "" { + // We don't require extensions to be populated + return []pkix.Extension{}, nil + } + + extListRaw, ok := extRaw.([]interface{}) + if !ok { + return nil, errors.New("'extensions' field did not contain a slice") + } + + return parseExtensionsParam(extListRaw) +} + +func parseExtensionsParam(extRawList []interface{}) ([]pkix.Extension, error) { + var extensions []pkix.Extension + seenOid := make(map[string]struct{}) + for i, entryRaw := range extRawList { + entry, ok := entryRaw.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("extension entry %d not a map", i) + } + extension, err := parseExtension(entry) + if err != nil { + return nil, fmt.Errorf("failed parsing extension entry %d: %w", i, err) + } + + parsedIdStr := extension.Id.String() + if _, exists := seenOid[parsedIdStr]; exists { + return nil, fmt.Errorf("duplicate extension id: %s", parsedIdStr) + } + + seenOid[parsedIdStr] = struct{}{} + + extensions = append(extensions, extension) + } + + return extensions, nil +} + +func parseExtension(entry map[string]interface{}) (pkix.Extension, error) { + asnObjectId, err := parseExtAsn1ObjectId(entry) + if err != nil { + return pkix.Extension{}, err + } + + if asnObjectId.Equal(akOid) { + return pkix.Extension{}, fmt.Errorf("authority key object identifier (%s) is reserved", akOid.String()) + } + + if asnObjectId.Equal(crlNumOid) { + return pkix.Extension{}, fmt.Errorf("crl number object identifier (%s) is reserved", crlNumOid.String()) + } + + if asnObjectId.Equal(deltaCrlOid) { + return pkix.Extension{}, fmt.Errorf("delta crl object identifier (%s) is reserved", deltaCrlOid.String()) + } + + critical, err := parseExtCritical(entry) + if err != nil { + return pkix.Extension{}, err + } + + extVal, err := parseExtValue(entry) + if err != nil { + return pkix.Extension{}, err + } + + return pkix.Extension{ + Id: asnObjectId, + Critical: critical, + Value: extVal, + }, nil +} + +func parseExtValue(entry map[string]interface{}) ([]byte, error) { + valRaw, exists := entry["value"] + if !exists { + return nil, errors.New("missing 'value' field") + } + + valStr, err := parseutil.ParseString(valRaw) + if err != nil { + return nil, fmt.Errorf("'value' field value was not a string: %w", err) + } + + if len(valStr) == 0 { + return []byte{}, nil + } + + decodeString, err := base64.StdEncoding.DecodeString(valStr) + if err != nil { + return nil, fmt.Errorf("failed base64 decoding 'value' field: %w", err) + } + return decodeString, nil +} + +func parseExtCritical(entry map[string]interface{}) (bool, error) { + critRaw, exists := entry["critical"] + if !exists || critRaw == nil || critRaw == "" { + // Optional field, so just return as if they provided the value false. + return false, nil + } + + myBool, err := parseutil.ParseBool(critRaw) + if err != nil { + return false, fmt.Errorf("critical field value failed to be parsed: %w", err) + } + + return myBool, nil +} + +func parseExtAsn1ObjectId(entry map[string]interface{}) (asn1.ObjectIdentifier, error) { + idRaw, idExists := entry["id"] + if !idExists { + return asn1.ObjectIdentifier{}, errors.New("missing id field") + } + + oidStr, err := parseutil.ParseString(idRaw) + if err != nil { + return nil, fmt.Errorf("'id' field value was not a string: %w", err) + } + + if len(oidStr) == 0 { + return asn1.ObjectIdentifier{}, errors.New("zero length object identifier") + } + + // Parse out dot notation + oidParts := strings.Split(oidStr, ".") + oid := make(asn1.ObjectIdentifier, len(oidParts), len(oidParts)) + for i := range oidParts { + oidIntVal, err := strconv.Atoi(oidParts[i]) + if err != nil { + return nil, fmt.Errorf("failed parsing asn1 index element %d value %s: %w", i, oidParts[i], err) + } + oid[i] = oidIntVal + } + return oid, nil +} + +func parseRevocationTime(cert map[string]interface{}) (time.Time, error) { + var revTime time.Time + revTimeRaw, exists := cert["revocation_time"] + if !exists { + return revTime, errors.New("missing 'revocation_time' field") + } + revTime, err := parseutil.ParseAbsoluteTime(revTimeRaw) + if err != nil { + return revTime, fmt.Errorf("failed parsing time %v: %w", revTimeRaw, err) + } + return revTime, nil +} + +func parseSerialNum(cert map[string]interface{}) (*big.Int, error) { + serialNumRaw, serialExists := cert["serial_number"] + if !serialExists { + return nil, errors.New("missing 'serial_number' field") + } + serialNumStr, err := parseutil.ParseString(serialNumRaw) + if err != nil { + return nil, fmt.Errorf("'serial_number' field value was not a string: %w", err) + } + // Clean up any provided serials to decoder + for _, separator := range []string{":", ".", "-", " "} { + serialNumStr = strings.ReplaceAll(serialNumStr, separator, "") + } + // Prefer hex.DecodeString over certutil.ParseHexFormatted as we don't need a separator + serialBytes, err := hex.DecodeString(serialNumStr) + if err != nil { + return nil, fmt.Errorf("'serial_number' failed converting to bytes: %w", err) + } + + bigIntSerial := big.Int{} + bigIntSerial.SetBytes(serialBytes) + return &bigIntSerial, nil +} + +func parseCrlFormat(requestedValue string) (string, error) { + format := strings.ToLower(requestedValue) + switch format { + case "pem", "der": + return format, nil + default: + return "", fmt.Errorf("unknown format value of %s", requestedValue) + } +} + func verifyCrlsAreFromIssuersKey(caCert *x509.Certificate, crls []*x509.RevocationList) error { for i, crl := range crls { // At this point we assume if the issuer's key signed the CRL that is a good enough check @@ -188,17 +562,7 @@ func encodeResponse(crlBytes []byte, derFormatRequested bool) string { return string(pem.EncodeToMemory(&block)) } -func getCrlFormat(requestedValue string) (string, error) { - format := strings.ToLower(requestedValue) - switch format { - case "pem", "der": - return format, nil - default: - return "", fmt.Errorf("unknown format value of %s", requestedValue) - } -} - -func getAllRevokedCerts(crls []*x509.RevocationList) ([]pkix.RevokedCertificate, []string, error) { +func getAllRevokedCertsFromPem(crls []*x509.RevocationList) ([]pkix.RevokedCertificate, []string, error) { uniqueCert := map[string]pkix.RevokedCertificate{} var warnings []string for _, crl := range crls { diff --git a/builtin/logical/pki/path_resign_crls_test.go b/builtin/logical/pki/path_resign_crls_test.go index 8100bfdea..4b3d9be03 100644 --- a/builtin/logical/pki/path_resign_crls_test.go +++ b/builtin/logical/pki/path_resign_crls_test.go @@ -4,11 +4,16 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/asn1" + "encoding/base64" "fmt" "math/big" "testing" "time" + "github.com/hashicorp/vault/api" + vaulthttp "github.com/hashicorp/vault/http" + "github.com/hashicorp/vault/vault" + "github.com/hashicorp/vault/sdk/logical" "github.com/stretchr/testify/require" ) @@ -217,6 +222,185 @@ func TestResignCrls_DeltaCrl(t *testing.T) { require.NoError(t, err, "failed signature check of CRL") } +func TestSignRevocationList(t *testing.T) { + t.Parallel() + + coreConfig := &vault.CoreConfig{ + LogicalBackends: map[string]logical.Factory{ + "pki": Factory, + }, + } + cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + }) + cluster.Start() + defer cluster.Cleanup() + client := cluster.Cores[0].Client + + // Mount PKI, use this form of backend so our request is closer to reality (json parsed) + err := client.Sys().Mount("pki", &api.MountInput{ + Type: "pki", + Config: api.MountConfigInput{ + DefaultLeaseTTL: "16h", + MaxLeaseTTL: "60h", + }, + }) + require.NoError(t, err) + + // Generate internal CA. + resp, err := client.Logical().Write("pki/root/generate/internal", map[string]interface{}{ + "ttl": "40h", + "common_name": "myvault.com", + }) + require.NoError(t, err) + caCert := parseCert(t, resp.Data["certificate"].(string)) + + resp, err = client.Logical().Write("pki/issuer/default/sign-revocation-list", map[string]interface{}{ + "crl_number": "1", + "next_update": "12h", + "format": "pem", + "revoked_certs": []map[string]interface{}{ + { + "serial_number": "37:60:16:e4:85:d5:96:38:3a:ed:31:06:8d:ed:7a:46:d4:22:63:d8", + "revocation_time": "1668614976", + "extensions": []map[string]interface{}{}, + }, + { + "serial_number": "27:03:89:76:5a:d4:d8:19:48:47:ca:96:db:6f:27:86:31:92:9f:82", + "revocation_time": "2022-11-16T16:09:36.739592Z", + }, + { + "serial_number": "27:03:89:76:5a:d4:d8:19:48:47:ca:96:db:6f:27:86:31:92:9f:81", + "revocation_time": "2022-10-16T16:09:36.739592Z", + "extensions": []map[string]interface{}{ + { + "id": "2.5.29.100", + "critical": "true", + "value": "aGVsbG8=", // "hello" base64 encoded + }, + { + "id": "2.5.29.101", + "critical": "false", + "value": "Ynll", // "bye" base64 encoded + }, + }, + }, + }, + "extensions": []map[string]interface{}{ + { + "id": "2.5.29.200", + "critical": "true", + "value": "aGVsbG8=", // "hello" base64 encoded + }, + { + "id": "2.5.29.201", + "critical": "false", + "value": "Ynll", // "bye" base64 encoded + }, + }, + }) + require.NoError(t, err) + pemCrl := resp.Data["crl"].(string) + crl, err := decodePemCrl(pemCrl) + require.NoError(t, err, "failed decoding CRL") + serials := extractSerialsFromCrl(t, crl) + require.Contains(t, serials, "37:60:16:e4:85:d5:96:38:3a:ed:31:06:8d:ed:7a:46:d4:22:63:d8") + require.Contains(t, serials, "27:03:89:76:5a:d4:d8:19:48:47:ca:96:db:6f:27:86:31:92:9f:82") + require.Contains(t, serials, "27:03:89:76:5a:d4:d8:19:48:47:ca:96:db:6f:27:86:31:92:9f:81") + require.Equal(t, 3, len(serials), "expected 3 serials within CRL") + + // Make sure extensions on serials match what we expect. + require.Equal(t, 0, len(crl.RevokedCertificates[0].Extensions), "Expected no extensions on 1st serial") + require.Equal(t, 0, len(crl.RevokedCertificates[1].Extensions), "Expected no extensions on 2nd serial") + require.Equal(t, 2, len(crl.RevokedCertificates[2].Extensions), "Expected 2 extensions on 3 serial") + require.Equal(t, "2.5.29.100", crl.RevokedCertificates[2].Extensions[0].Id.String()) + require.True(t, crl.RevokedCertificates[2].Extensions[0].Critical) + require.Equal(t, []byte("hello"), crl.RevokedCertificates[2].Extensions[0].Value) + + require.Equal(t, "2.5.29.101", crl.RevokedCertificates[2].Extensions[1].Id.String()) + require.False(t, crl.RevokedCertificates[2].Extensions[1].Critical) + require.Equal(t, []byte("bye"), crl.RevokedCertificates[2].Extensions[1].Value) + + // CRL Number and times + require.Equal(t, big.NewInt(int64(1)), crl.Number) + require.Equal(t, crl.ThisUpdate.Add(12*time.Hour), crl.NextUpdate) + + // Verify top level extensions are present + extensions := crl.Extensions + requireExtensionOid(t, []int{2, 5, 29, 20}, extensions) // CRL Number Extension + requireExtensionOid(t, []int{2, 5, 29, 35}, extensions) // akidOid + requireExtensionOid(t, []int{2, 5, 29, 200}, extensions) // Added value from param + requireExtensionOid(t, []int{2, 5, 29, 201}, extensions) // Added value from param + require.Equal(t, 4, len(extensions)) + + // Signature + err = crl.CheckSignatureFrom(caCert) + require.NoError(t, err, "failed signature check of CRL") +} + +func TestSignRevocationList_NoRevokedCerts(t *testing.T) { + t.Parallel() + b, s := CreateBackendWithStorage(t) + resp, err := CBWrite(b, s, "root/generate/internal", map[string]interface{}{ + "common_name": "test.com", + }) + requireSuccessNonNilResponse(t, resp, err) + + resp, err = CBWrite(b, s, "issuer/default/sign-revocation-list", map[string]interface{}{ + "crl_number": "10000", + "next_update": "12h", + "format": "pem", + }) + requireSuccessNonNilResponse(t, resp, err) + requireFieldsSetInResp(t, resp, "crl") + pemCrl := resp.Data["crl"].(string) + crl, err := decodePemCrl(pemCrl) + require.NoError(t, err, "failed decoding CRL") + + serials := extractSerialsFromCrl(t, crl) + require.Equal(t, 0, len(serials), "no serials were expected in CRL") + + require.Equal(t, big.NewInt(int64(10000)), crl.Number) + require.Equal(t, crl.ThisUpdate.Add(12*time.Hour), crl.NextUpdate) +} + +func TestSignRevocationList_ReservedExtensions(t *testing.T) { + t.Parallel() + + reservedOids := []asn1.ObjectIdentifier{ + akOid, deltaCrlOid, crlNumOid, + } + // Validate there isn't copy/paste issues with our constants... + require.Equal(t, asn1.ObjectIdentifier{2, 5, 29, 27}, deltaCrlOid) // Delta CRL Extension + require.Equal(t, asn1.ObjectIdentifier{2, 5, 29, 20}, crlNumOid) // CRL Number Extension + require.Equal(t, asn1.ObjectIdentifier{2, 5, 29, 35}, akOid) // akidOid + + for _, reservedOid := range reservedOids { + t.Run(reservedOid.String(), func(t *testing.T) { + b, s := CreateBackendWithStorage(t) + resp, err := CBWrite(b, s, "root/generate/internal", map[string]interface{}{ + "common_name": "test.com", + }) + requireSuccessNonNilResponse(t, resp, err) + + resp, err = CBWrite(b, s, "issuer/default/sign-revocation-list", map[string]interface{}{ + "crl_number": "1", + "next_update": "12h", + "format": "pem", + "extensions": []map[string]interface{}{ + { + "id": reservedOid.String(), + "critical": "false", + "value": base64.StdEncoding.EncodeToString([]byte("hello")), + }, + }, + }) + + require.ErrorContains(t, err, "is reserved") + }) + } +} + func setupResignCrlMounts(t *testing.T, b1 *backend, s1 logical.Storage, b2 *backend, s2 logical.Storage) (*x509.Certificate, string, string, string, string) { t.Helper() diff --git a/changelog/18040.txt b/changelog/18040.txt new file mode 100644 index 000000000..cb4eaf811 --- /dev/null +++ b/changelog/18040.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/pki: Added a new API that allows external actors to craft a CRL through JSON parameters +``` diff --git a/website/content/api-docs/secret/pki.mdx b/website/content/api-docs/secret/pki.mdx index 6b8ed22eb..dfe56b6d0 100644 --- a/website/content/api-docs/secret/pki.mdx +++ b/website/content/api-docs/secret/pki.mdx @@ -70,6 +70,7 @@ update your API calls accordingly. - [Rotate CRLs](#rotate-crls) - [Rotate Delta CRLs](#rotate-delta-crls) - [Combining CRLs from the same Issuer](#combine-crls-from-the-same-issuer) + - [Sign Revocation List](#sign-revocation-list) - [Tidy](#tidy) - [Configure Automatic Tidy](#configure-automatic-tidy) - [Tidy Status](#tidy-status) @@ -3359,6 +3360,94 @@ $ curl \ } ``` +### Sign Revocation List + +This endpoint allows generating a CRL based on the provided parameter data from any external +source and signed by the specified issuer. Values are taken verbatim from the parameters provided. + +**This is a potentially dangerous endpoint and only highly trusted users should have access.** + +| Method | Path | +|:-------|:-----------------------------------------------| +| `POST` | `/pki/issuer/:issuer_ref/sign-revocation-list` | + + +#### Parameters + +- `issuer_ref` `(string: )` - Reference to an existing issuer, + either by Vault-generated identifier, the literal string `default` to + refer to the currently configured default issuer, or the name assigned + to an issuer. This parameter is part of the request URL. +- `crl_number` `(int: )` - The sequence number to be written within the CRL + Number extension. +- `delta_crl_base_number` `(int: -1)` - Using a value of 0 or greater specifies the base CRL revision + number to encode within a Delta CRL indicator extension, otherwise the extension will + not be added; defaults to -1. +- `format` `(string: pem)` - The format of the combined CRL, can be "pem" or "der". + If "der", the value will be base64 encoded; Defaults to "pem". +- `next_update` `(string: 72h)` - The amount of time the generated CRL should be + valid; defaults to 72 hours. +- `revoked_certs` `(type: slice of maps)` - Each element contains revocation information for a + single serial number along with the revocation time and the serial's extensions if any. Each + element can have the following key/values + - `serial_number` `(type: string)` - the serial number of the revoked cert + - `revocation_time` `(type: string)` - the revocation time, unix int format or RFC3339 encoding supported + - `extensions` `(type: slice of maps)` - A slice of all extensions that should be added to the revoked + certificate entry. Each ele,ent contains a map with the following entries + - `id` `(type: string)` - an ASN1 object identifier in dot notation + - `critical` `(type: bool)` - should the extension be marked critical + - `value` `(type: string)` - base64 encoded bytes for extension value +- `extensions` `(type: slice of maps)` - A slice of all extensions that should be added to the generated + CRL each element containing a map with the following entries. + - `id` `(type: string)` - an ASN1 object identifier in dot notation + - `critical` `(type: bool)` - should the extension be marked critical + - `value` `(type: string)` - base64 encoded bytes for extension value + +~> **Note**:: The following extension ids are not allowed to be provided and can be influenced by other parameters + - `2.5.29.20`: CRL Number + - `2.5.29.27`: Delta CRL + - `2.5.29.35`: Authority Key Identifier + +#### Sample Payload + +```json +{ + "crl_number": "10", + "next_update": "24h", + "format": "pem", + "revoked_certs": [ + { + "serial_number": "39:dd:2e:90:b7:23:1f:8d:d3:7d:31:c5:1b:da:84:d0:5b:65:31:58", + "revocation_time": "2009-11-10T23:00:00Z" + }, + { + "serial_number": "40:33:2e:90:b7:23:1f:8d:d3:7d:31:c5:1b:da:84:d0:5b:65:31:58", + "revocation_time": "1257894000" + } + ] +} +``` + +#### Sample Request + +```shell-session +$ curl \ + --header "X-Vault-Token: ..." \ + -request POST \ + --data @payload.json \ + http://127.0.0.1:8200/v1/pki/issuer/default/sign-revocation-list +``` + +#### Sample Response + +```json +{ + "data": { + "crl": "" + } +} +``` + ### Tidy This endpoint allows tidying up the storage backend and/or CRL by removing