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
This commit is contained in:
parent
e938f2080d
commit
92c1a2bd0a
|
@ -180,6 +180,7 @@ func Backend(conf *logical.BackendConfig) *backend {
|
|||
|
||||
// CRL Signing
|
||||
pathResignCrls(&b),
|
||||
pathSignRevocationList(&b),
|
||||
},
|
||||
|
||||
Secrets: []*framework.Secret{
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
```release-note:improvement
|
||||
secrets/pki: Added a new API that allows external actors to craft a CRL through JSON parameters
|
||||
```
|
|
@ -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: <required>)` - 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: <required>)` - 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": "<PEM encoded crl>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tidy
|
||||
|
||||
This endpoint allows tidying up the storage backend and/or CRL by removing
|
||||
|
|
Loading…
Reference in New Issue