Files
vault/builtin/logical/pki/crl_util.go
Alexander Scheel 6e6914547e Let PKI tidy associate revoked certs with their issuers (#16871)
* Refactor tidy steps into two separate helpers

This refactors the tidy go routine into two separate helpers, making it
clear where the boundaries of each are: variables are passed into these
method and concerns are separated. As more operations are rolled into
tidy, we can continue adding more helpers as appropriate. Additionally,
as we move to make auto-tidy occur, we can use these as points to hook
into periodic tidying.

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Refactor revInfo checking to helper

This allows us to validate whether or not a revInfo entry contains a
presently valid issuer, from the existing mapping. Coupled with the
changeset to identify the issuer on revocation, we can begin adding
capabilities to tidy to update this association, decreasing CRL build
time and increasing the performance of OCSP.

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Refactor issuer fetching for revocation purposes

Revocation needs to gracefully handle using the old legacy cert bundle,
so fetching issuers (and parsing them) needs to be done slightly
differently than other places. Refactor this from revokeCert into a
common helper that can be used by tidy.

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Allow tidy to associate revoked certs, issuers

When revoking a certificate, we need to associate the issuer that signed
its certificate back to the revInfo entry. Historically this was
performed during CRL building (and still remains so), but when running
without CRL building and with only OCSP, performance will degrade as the
issuer needs to be found each time.

Instead, allow the tidy operation to take over this role, allowing us to
increase the performance of OCSP and CRL in this scenario, by decoupling
issuer identification from CRL building in the ideal case.

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add tests for tidy updates

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add changelog entry

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add documentation on new tidy parameter, metrics

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Refactor tidy config into shared struct

Finish adding metrics, status messages about new tidy operation.

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
2022-08-26 10:13:45 -07:00

946 lines
33 KiB
Go

package pki
import (
"bytes"
"context"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"strings"
"sync"
"sync/atomic"
"time"
atomic2 "go.uber.org/atomic"
"github.com/hashicorp/vault/sdk/helper/consts"
"github.com/hashicorp/vault/sdk/helper/errutil"
"github.com/hashicorp/vault/sdk/logical"
)
const revokedPath = "revoked/"
type revocationInfo struct {
CertificateBytes []byte `json:"certificate_bytes"`
RevocationTime int64 `json:"revocation_time"`
RevocationTimeUTC time.Time `json:"revocation_time_utc"`
CertificateIssuer issuerID `json:"issuer_id"`
}
// crlBuilder is gatekeeper for controlling various read/write operations to the storage of the CRL.
// The extra complexity arises from secondary performance clusters seeing various writes to its storage
// without the actual API calls. During the storage invalidation process, we do not have the required state
// to actually rebuild the CRLs, so we need to schedule it in a deferred fashion. This allows either
// read or write calls to perform the operation if required, or have the flag reset upon a write operation
//
// The CRL builder also tracks the revocation configuration.
type crlBuilder struct {
m sync.Mutex
forceRebuild uint32
c sync.RWMutex
dirty *atomic2.Bool
config crlConfig
}
const (
_ignoreForceFlag = true
_enforceForceFlag = false
)
func newCRLBuilder() *crlBuilder {
return &crlBuilder{
dirty: atomic2.NewBool(true),
config: defaultCrlConfig,
}
}
func (cb *crlBuilder) markConfigDirty() {
cb.dirty.Store(true)
}
func (cb *crlBuilder) reloadConfigIfRequired(sc *storageContext) error {
if cb.dirty.Load() {
// Acquire a write lock.
cb.c.Lock()
defer cb.c.Unlock()
if !cb.dirty.Load() {
// Someone else might've been reloading the config; no need
// to do it twice.
return nil
}
config, err := sc.getRevocationConfig()
if err != nil {
return err
}
// Set the default config if none was returned to us.
if config != nil {
cb.config = *config
} else {
cb.config = defaultCrlConfig
}
// Updated the config; unset dirty.
cb.dirty.Store(false)
}
return nil
}
func (cb *crlBuilder) getConfigWithUpdate(sc *storageContext) (*crlConfig, error) {
// Config may mutate immediately after accessing, but will be freshly
// fetched if necessary.
if err := cb.reloadConfigIfRequired(sc); err != nil {
return nil, err
}
cb.c.RLock()
defer cb.c.RUnlock()
configCopy := cb.config
return &configCopy, nil
}
func (cb *crlBuilder) checkForAutoRebuild(sc *storageContext) error {
cfg, err := cb.getConfigWithUpdate(sc)
if err != nil {
return err
}
if cfg.Disable || !cfg.AutoRebuild || atomic.LoadUint32(&cb.forceRebuild) == 1 {
// Not enabled, not on auto-rebuilder, or we're already scheduled to
// rebuild so there's no point to interrogate CRL values...
return nil
}
// Auto-Rebuild is enabled. We need to check each issuer's CRL and see
// if its about to expire. If it is, we've gotta rebuild it (and well,
// every other CRL since we don't have a fine-toothed rebuilder).
//
// We store a list of all (unique) CRLs in the cluster-local CRL
// configuration along with their expiration dates.
crlConfig, err := sc.getLocalCRLConfig()
if err != nil {
return fmt.Errorf("error checking for auto-rebuild status: unable to fetch cluster-local CRL configuration: %v", err)
}
// If there's no config, assume we've gotta rebuild it to get this
// information.
if crlConfig == nil {
atomic.CompareAndSwapUint32(&cb.forceRebuild, 0, 1)
return nil
}
// If the map is empty, assume we need to upgrade and schedule a
// rebuild.
if len(crlConfig.CRLExpirationMap) == 0 {
atomic.CompareAndSwapUint32(&cb.forceRebuild, 0, 1)
return nil
}
// Otherwise, check CRL's expirations and see if its zero or within
// the grace period and act accordingly.
now := time.Now()
period, err := time.ParseDuration(cfg.AutoRebuildGracePeriod)
if err != nil {
// This may occur if the duration is empty; in that case
// assume the default. The default should be valid and shouldn't
// error.
defaultPeriod, defaultErr := time.ParseDuration(defaultCrlConfig.AutoRebuildGracePeriod)
if defaultErr != nil {
return fmt.Errorf("error checking for auto-rebuild status: unable to parse duration from both config's grace period (%v) and default grace period (%v):\n- config: %v\n- default: %v\n", cfg.AutoRebuildGracePeriod, defaultCrlConfig.AutoRebuildGracePeriod, err, defaultErr)
}
period = defaultPeriod
}
for _, value := range crlConfig.CRLExpirationMap {
if value.IsZero() || now.After(value.Add(-1*period)) {
atomic.CompareAndSwapUint32(&cb.forceRebuild, 0, 1)
return nil
}
}
return nil
}
// rebuildIfForced is to be called by readers or periodic functions that might need to trigger
// a refresh of the CRL before the read occurs.
func (cb *crlBuilder) rebuildIfForced(ctx context.Context, b *backend, request *logical.Request) error {
if atomic.LoadUint32(&cb.forceRebuild) == 1 {
return cb._doRebuild(ctx, b, request, true, _enforceForceFlag)
}
return nil
}
// rebuild is to be called by various write apis that know the CRL is to be updated and can be now.
func (cb *crlBuilder) rebuild(ctx context.Context, b *backend, request *logical.Request, forceNew bool) error {
return cb._doRebuild(ctx, b, request, forceNew, _ignoreForceFlag)
}
// requestRebuildIfActiveNode will schedule a rebuild of the CRL from the next read or write api call assuming we are the active node of a cluster
func (cb *crlBuilder) requestRebuildIfActiveNode(b *backend) {
// Only schedule us on active nodes, as the active node is the only node that can rebuild/write the CRL.
// Note 1: The CRL is cluster specific, so this does need to run on the active node of a performance secondary cluster.
// Note 2: This is called by the storage invalidation function, so it should not block.
if b.System().ReplicationState().HasState(consts.ReplicationPerformanceStandby) ||
b.System().ReplicationState().HasState(consts.ReplicationDRSecondary) {
b.Logger().Debug("Ignoring request to schedule a CRL rebuild, not on active node.")
return
}
b.Logger().Info("Scheduling PKI CRL rebuild.")
// Set the flag to 1, we don't care if we aren't the ones that actually swap it to 1.
atomic.CompareAndSwapUint32(&cb.forceRebuild, 0, 1)
}
func (cb *crlBuilder) _doRebuild(ctx context.Context, b *backend, request *logical.Request, forceNew bool, ignoreForceFlag bool) error {
cb.m.Lock()
defer cb.m.Unlock()
// Re-read the lock in case someone beat us to the punch between the previous load op.
forceBuildFlag := atomic.LoadUint32(&cb.forceRebuild)
if forceBuildFlag == 1 || ignoreForceFlag {
// Reset our original flag back to 0 before we start the rebuilding. This may lead to another round of
// CRL building, but we want to avoid the race condition caused by clearing the flag after we completed (An
// update/revocation occurred attempting to set the flag, after we listed the certs but before we wrote
// the CRL, so we missed the update and cleared the flag.)
atomic.CompareAndSwapUint32(&cb.forceRebuild, 1, 0)
// if forceRebuild was requested, that should force a complete rebuild even if requested not too by forceNew
myForceNew := forceBuildFlag == 1 || forceNew
return buildCRLs(ctx, b, request, myForceNew)
}
return nil
}
// Helper function to fetch a map of issuerID->parsed cert for revocation
// usage. Unlike other paths, this needs to handle the legacy bundle
// more gracefully than rejecting it outright.
func fetchIssuerMapForRevocationChecking(sc *storageContext) (map[issuerID]*x509.Certificate, error) {
var err error
var issuers []issuerID
if !sc.Backend.useLegacyBundleCaStorage() {
issuers, err = sc.listIssuers()
if err != nil {
return nil, fmt.Errorf("could not fetch issuers list: %v", err)
}
} else {
// Hack: this isn't a real issuerID, but it works for fetchCAInfo
// since it resolves the reference.
issuers = []issuerID{legacyBundleShimID}
}
issuerIDCertMap := make(map[issuerID]*x509.Certificate, len(issuers))
for _, issuer := range issuers {
_, bundle, caErr := sc.fetchCertBundleByIssuerId(issuer, false)
if caErr != nil {
return nil, fmt.Errorf("error fetching CA certificate for issuer id %v: %s", issuer, caErr)
}
if bundle == nil {
return nil, fmt.Errorf("faulty reference: %v - CA info not found", issuer)
}
parsedBundle, err := parseCABundle(sc.Context, sc.Backend, bundle)
if err != nil {
return nil, errutil.InternalError{Err: err.Error()}
}
if parsedBundle.Certificate == nil {
return nil, errutil.InternalError{Err: "stored CA information not able to be parsed"}
}
issuerIDCertMap[issuer] = parsedBundle.Certificate
}
return issuerIDCertMap, nil
}
// Revokes a cert, and tries to be smart about error recovery
func revokeCert(ctx context.Context, b *backend, req *logical.Request, serial string, fromLease bool) (*logical.Response, error) {
// As this backend is self-contained and this function does not hook into
// third parties to manage users or resources, if the mount is tainted,
// revocation doesn't matter anyways -- the CRL that would be written will
// be immediately blown away by the view being cleared. So we can simply
// fast path a successful exit.
if b.System().Tainted() {
return nil, nil
}
// Validate that no issuers match the serial number to be revoked. We need
// to gracefully degrade to the legacy cert bundle when it is required, as
// secondary PR clusters might not have been upgraded, but still need to
// handle revoking certs.
sc := b.makeStorageContext(ctx, req.Storage)
issuerIDCertMap, err := fetchIssuerMapForRevocationChecking(sc)
if err != nil {
return nil, err
}
// Ensure we don't revoke an issuer via this API; use /issuer/:issuer_ref/revoke
// instead.
for issuer, certificate := range issuerIDCertMap {
colonSerial := strings.ReplaceAll(strings.ToLower(serial), "-", ":")
if colonSerial == serialFromCert(certificate) {
return logical.ErrorResponse(fmt.Sprintf("adding issuer (id: %v) to its own CRL is not allowed", issuer)), nil
}
}
alreadyRevoked := false
var revInfo revocationInfo
revEntry, err := fetchCertBySerial(ctx, b, req, revokedPath, serial)
if err != nil {
switch err.(type) {
case errutil.UserError:
return logical.ErrorResponse(err.Error()), nil
default:
return nil, err
}
}
if revEntry != nil {
// Set the revocation info to the existing values
alreadyRevoked = true
err = revEntry.DecodeJSON(&revInfo)
if err != nil {
return nil, fmt.Errorf("error decoding existing revocation info")
}
}
if !alreadyRevoked {
certEntry, err := fetchCertBySerial(ctx, b, req, "certs/", serial)
if err != nil {
switch err.(type) {
case errutil.UserError:
return logical.ErrorResponse(err.Error()), nil
default:
return nil, err
}
}
if certEntry == nil {
if fromLease {
// We can't write to revoked/ or update the CRL anyway because we don't have the cert,
// and there's no reason to expect this will work on a subsequent
// retry. Just give up and let the lease get deleted.
b.Logger().Warn("expired certificate revoke failed because not found in storage, treating as success", "serial", serial)
return nil, nil
}
return logical.ErrorResponse(fmt.Sprintf("certificate with serial %s not found", serial)), nil
}
cert, err := x509.ParseCertificate(certEntry.Value)
if err != nil {
return nil, fmt.Errorf("error parsing certificate: %w", err)
}
if cert == nil {
return nil, fmt.Errorf("got a nil certificate")
}
// Add a little wiggle room because leases are stored with a second
// granularity
if cert.NotAfter.Before(time.Now().Add(2 * time.Second)) {
response := &logical.Response{}
response.AddWarning(fmt.Sprintf("certificate with serial %s already expired; refusing to add to CRL", serial))
return response, nil
}
// Compatibility: Don't revoke CAs if they had leases. New CAs going
// forward aren't issued leases.
if cert.IsCA && fromLease {
return nil, nil
}
currTime := time.Now()
revInfo.CertificateBytes = certEntry.Value
revInfo.RevocationTime = currTime.Unix()
revInfo.RevocationTimeUTC = currTime.UTC()
// We may not find an issuer with this certificate; that's fine so
// ignore the return value.
associateRevokedCertWithIsssuer(&revInfo, cert, issuerIDCertMap)
revEntry, err = logical.StorageEntryJSON(revokedPath+normalizeSerial(serial), revInfo)
if err != nil {
return nil, fmt.Errorf("error creating revocation entry")
}
err = req.Storage.Put(ctx, revEntry)
if err != nil {
return nil, fmt.Errorf("error saving revoked certificate to new location")
}
}
// Fetch the config and see if we need to rebuild the CRL. If we have
// auto building enabled, we will wait for the next rebuild period to
// actually rebuild it.
config, err := b.crlBuilder.getConfigWithUpdate(sc)
if err != nil {
return nil, fmt.Errorf("error building CRL: while updating config: %v", err)
}
if !config.AutoRebuild {
crlErr := b.crlBuilder.rebuild(ctx, b, req, false)
if crlErr != nil {
switch crlErr.(type) {
case errutil.UserError:
return logical.ErrorResponse(fmt.Sprintf("Error during CRL building: %s", crlErr)), nil
default:
return nil, fmt.Errorf("error encountered during CRL building: %w", crlErr)
}
}
}
resp := &logical.Response{
Data: map[string]interface{}{
"revocation_time": revInfo.RevocationTime,
},
}
if !revInfo.RevocationTimeUTC.IsZero() {
resp.Data["revocation_time_rfc3339"] = revInfo.RevocationTimeUTC.Format(time.RFC3339Nano)
}
return resp, nil
}
func buildCRLs(ctx context.Context, b *backend, req *logical.Request, forceNew bool) error {
// In order to build all CRLs, we need knowledge of all issuers. Any two
// issuers with the same keys _and_ subject should have the same CRL since
// they're functionally equivalent.
//
// When building CRLs, there's two types of CRLs: an "internal" CRL for
// just certificates issued by this issuer, and a "default" CRL, which
// not only contains certificates by this issuer, but also ones issued
// by "unknown" or past issuers. This means we need knowledge of not
// only all issuers (to tell whether or not to include these orphaned
// certs) but whether the present issuer is the configured default.
//
// If a configured default is lacking, we won't provision these
// certificates on any CRL.
//
// In order to know which CRL a given cert belongs on, we have to read
// it into memory, identify the corresponding issuer, and update its
// map with the revoked cert instance. If no such issuer is found, we'll
// place it in the default issuer's CRL.
//
// By not relying on the _cert_'s storage, we allow issuers to come and
// go (either by direct deletion, having their keys deleted, or by usage
// restrictions) -- and when they return, we'll correctly place certs
// on their CRLs.
// See the message in revokedCert about rebuilding CRLs: we need to
// gracefully handle revoking entries with the legacy cert bundle.
var err error
var issuers []issuerID
var wasLegacy bool
sc := b.makeStorageContext(ctx, req.Storage)
// First, fetch an updated copy of the CRL config. We'll pass this into
// buildCRL.
globalCRLConfig, err := b.crlBuilder.getConfigWithUpdate(sc)
if err != nil {
return fmt.Errorf("error building CRL: while updating config: %v", err)
}
if globalCRLConfig.Disable && !forceNew {
// We build a single long-lived empty CRL in the event that we disable
// the CRL, but we don't keep updating it with newer, more-valid empty
// CRLs in the event that we later re-enable it. This is a historical
// behavior.
//
// So, since tidy can now associate issuers on revocation entries, we
// can skip the rest of this function and exit early without updating
// anything.
return nil
}
if !b.useLegacyBundleCaStorage() {
issuers, err = sc.listIssuers()
if err != nil {
return fmt.Errorf("error building CRL: while listing issuers: %v", err)
}
} else {
// Here, we hard-code the legacy issuer entry instead of using the
// default ref. This is because we need to hack some of the logic
// below for revocation to handle the legacy bundle.
issuers = []issuerID{legacyBundleShimID}
wasLegacy = true
}
config, err := sc.getIssuersConfig()
if err != nil {
return fmt.Errorf("error building CRLs: while getting the default config: %v", err)
}
// We map issuerID->entry for fast lookup and also issuerID->Cert for
// signature verification and correlation of revoked certs.
issuerIDEntryMap := make(map[issuerID]*issuerEntry, len(issuers))
issuerIDCertMap := make(map[issuerID]*x509.Certificate, len(issuers))
// We use a double map (keyID->subject->issuerID) to store whether or not this
// key+subject paring has been seen before. We can then iterate over each
// key/subject and choose any representative issuer for that combination.
keySubjectIssuersMap := make(map[keyID]map[string][]issuerID)
for _, issuer := range issuers {
// We don't strictly need this call, but by requesting the bundle, the
// legacy path is automatically ignored.
thisEntry, _, err := sc.fetchCertBundleByIssuerId(issuer, false)
if err != nil {
return fmt.Errorf("error building CRLs: unable to fetch specified issuer (%v): %v", issuer, err)
}
if len(thisEntry.KeyID) == 0 {
continue
}
// n.b.: issuer usage check has been delayed. This occurred because
// we want to ensure any issuer (representative of a larger set) can
// be used to associate revocation entries and we won't bother
// rewriting that entry (causing churn) if the particular selected
// issuer lacks CRL signing capabilities.
//
// The result is that this map (and the other maps) contain all the
// issuers we know about, and only later do we check crlSigning before
// choosing our representative.
//
// The other side effect (making this not compatible with Vault 1.11
// behavior) is that _identified_ certificates whose issuer set is
// not allowed for crlSigning will no longer appear on the default
// issuer's CRL.
issuerIDEntryMap[issuer] = thisEntry
thisCert, err := thisEntry.GetCertificate()
if err != nil {
return fmt.Errorf("error building CRLs: unable to parse issuer (%v)'s certificate: %v", issuer, err)
}
issuerIDCertMap[issuer] = thisCert
subject := string(thisCert.RawSubject)
if _, ok := keySubjectIssuersMap[thisEntry.KeyID]; !ok {
keySubjectIssuersMap[thisEntry.KeyID] = make(map[string][]issuerID)
}
keySubjectIssuersMap[thisEntry.KeyID][subject] = append(keySubjectIssuersMap[thisEntry.KeyID][subject], issuer)
}
// Fetch the cluster-local CRL mapping so we know where to write the
// CRLs.
crlConfig, err := sc.getLocalCRLConfig()
if err != nil {
return fmt.Errorf("error building CRLs: unable to fetch cluster-local CRL configuration: %v", err)
}
// Next, we load and parse all revoked certificates. We need to assign
// these certificates to an issuer. Some certificates will not be
// assignable (if they were issued by a since-deleted issuer), so we need
// a separate pool for those.
unassignedCerts, revokedCertsMap, err := getRevokedCertEntries(ctx, req, issuerIDCertMap)
if err != nil {
return fmt.Errorf("error building CRLs: unable to get revoked certificate entries: %v", err)
}
if err := augmentWithRevokedIssuers(issuerIDEntryMap, issuerIDCertMap, revokedCertsMap); err != nil {
return fmt.Errorf("error building CRLs: unable to parse revoked issuers: %v", err)
}
// Now we can call buildCRL once, on an arbitrary/representative issuer
// from each of these (keyID, subject) sets.
for _, subjectIssuersMap := range keySubjectIssuersMap {
for _, issuersSet := range subjectIssuersMap {
if len(issuersSet) == 0 {
continue
}
var revokedCerts []pkix.RevokedCertificate
representative := issuerID("")
var crlIdentifier crlID
var crlIdIssuer issuerID
for _, issuerId := range issuersSet {
// Skip entries which aren't enabled for CRL signing. We don't
// particularly care which issuer is ultimately chosen as the
// set representative for signing at this point, other than
// that it has crl-signing usage.
if err := issuerIDEntryMap[issuerId].EnsureUsage(CRLSigningUsage); err != nil {
continue
}
// Prefer to use the default as the representative of this
// set, if it is a member.
//
// If it is, we'll also pull in the unassigned certs to remain
// compatible with Vault's earlier, potentially questionable
// behavior.
if issuerId == config.DefaultIssuerId {
if len(unassignedCerts) > 0 {
revokedCerts = append(revokedCerts, unassignedCerts...)
}
representative = issuerId
}
// Otherwise, use any other random issuer if we've not yet
// chosen one.
if representative == issuerID("") {
representative = issuerId
}
// Pull in the revoked certs associated with this member.
if thisRevoked, ok := revokedCertsMap[issuerId]; ok && len(thisRevoked) > 0 {
revokedCerts = append(revokedCerts, thisRevoked...)
}
// Finally, check our crlIdentifier.
if thisCRLId, ok := crlConfig.IssuerIDCRLMap[issuerId]; ok && len(thisCRLId) > 0 {
if len(crlIdentifier) > 0 && crlIdentifier != thisCRLId {
return fmt.Errorf("error building CRLs: two issuers with same keys/subjects (%v vs %v) have different internal CRL IDs: %v vs %v", issuerId, crlIdIssuer, thisCRLId, crlIdentifier)
}
crlIdentifier = thisCRLId
crlIdIssuer = issuerId
}
}
if representative == "" {
// Skip this set for the time being; while we have valid
// issuers and associated keys, this occurred because we lack
// crl-signing usage on all issuers in this set.
continue
}
if len(crlIdentifier) == 0 {
// Create a new random UUID for this CRL if none exists.
crlIdentifier = genCRLId()
crlConfig.CRLNumberMap[crlIdentifier] = 1
}
// Update all issuers in this group to set the CRL Issuer
for _, issuerId := range issuersSet {
crlConfig.IssuerIDCRLMap[issuerId] = crlIdentifier
}
// We always update the CRL Number since we never want to
// duplicate numbers and missing numbers is fine.
crlNumber := crlConfig.CRLNumberMap[crlIdentifier]
crlConfig.CRLNumberMap[crlIdentifier] += 1
// Lastly, build the CRL.
nextUpdate, err := buildCRL(sc, globalCRLConfig, forceNew, representative, revokedCerts, crlIdentifier, crlNumber)
if err != nil {
return fmt.Errorf("error building CRLs: unable to build CRL for issuer (%v): %v", representative, err)
}
crlConfig.CRLExpirationMap[crlIdentifier] = *nextUpdate
}
}
// Before persisting our updated CRL config, check to see if we have
// any dangling references. If we have any issuers that don't exist,
// remove them, remembering their CRLs IDs. If we've completely removed
// all issuers pointing to that CRL number, we can remove it from the
// number map and from storage.
//
// Note that we persist the last generated CRL for a specified issuer
// if it is later disabled for CRL generation. This mirrors the old
// root deletion behavior, but using soft issuer deletes. If there is an
// alternate, equivalent issuer however, we'll keep updating the shared
// CRL; all equivalent issuers must have their CRLs disabled.
for mapIssuerId := range crlConfig.IssuerIDCRLMap {
stillHaveIssuer := false
for _, listedIssuerId := range issuers {
if mapIssuerId == listedIssuerId {
stillHaveIssuer = true
break
}
}
if !stillHaveIssuer {
delete(crlConfig.IssuerIDCRLMap, mapIssuerId)
}
}
for crlId := range crlConfig.CRLNumberMap {
stillHaveIssuerForID := false
for _, remainingCRL := range crlConfig.IssuerIDCRLMap {
if remainingCRL == crlId {
stillHaveIssuerForID = true
break
}
}
if !stillHaveIssuerForID {
if err := req.Storage.Delete(ctx, "crls/"+crlId.String()); err != nil {
return fmt.Errorf("error building CRLs: unable to clean up deleted issuers' CRL: %v", err)
}
}
}
// Finally, persist our potentially updated local CRL config. Only do this
// if we didn't have a legacy CRL bundle.
if !wasLegacy {
if err := sc.setLocalCRLConfig(crlConfig); err != nil {
return fmt.Errorf("error building CRLs: unable to persist updated cluster-local CRL config: %v", err)
}
}
// All good :-)
return nil
}
func isRevInfoIssuerValid(revInfo *revocationInfo, issuerIDCertMap map[issuerID]*x509.Certificate) bool {
if len(revInfo.CertificateIssuer) > 0 {
issuerId := revInfo.CertificateIssuer
if _, issuerExists := issuerIDCertMap[issuerId]; issuerExists {
return true
}
}
return false
}
func associateRevokedCertWithIsssuer(revInfo *revocationInfo, revokedCert *x509.Certificate, issuerIDCertMap map[issuerID]*x509.Certificate) bool {
for issuerId, issuerCert := range issuerIDCertMap {
if bytes.Equal(revokedCert.RawIssuer, issuerCert.RawSubject) {
if err := revokedCert.CheckSignatureFrom(issuerCert); err == nil {
// Valid mapping. Add it to the specified entry.
revInfo.CertificateIssuer = issuerId
return true
}
}
}
return false
}
func getRevokedCertEntries(ctx context.Context, req *logical.Request, issuerIDCertMap map[issuerID]*x509.Certificate) ([]pkix.RevokedCertificate, map[issuerID][]pkix.RevokedCertificate, error) {
var unassignedCerts []pkix.RevokedCertificate
revokedCertsMap := make(map[issuerID][]pkix.RevokedCertificate)
revokedSerials, err := req.Storage.List(ctx, revokedPath)
if err != nil {
return nil, nil, errutil.InternalError{Err: fmt.Sprintf("error fetching list of revoked certs: %s", err)}
}
// Build a mapping of issuer serial -> certificate.
issuerSerialCertMap := make(map[string][]*x509.Certificate, len(issuerIDCertMap))
for _, cert := range issuerIDCertMap {
serialStr := serialFromCert(cert)
issuerSerialCertMap[serialStr] = append(issuerSerialCertMap[serialStr], cert)
}
for _, serial := range revokedSerials {
var revInfo revocationInfo
revokedEntry, err := req.Storage.Get(ctx, revokedPath+serial)
if err != nil {
return nil, nil, errutil.InternalError{Err: fmt.Sprintf("unable to fetch revoked cert with serial %s: %s", serial, err)}
}
if revokedEntry == nil {
return nil, nil, errutil.InternalError{Err: fmt.Sprintf("revoked certificate entry for serial %s is nil", serial)}
}
if revokedEntry.Value == nil || len(revokedEntry.Value) == 0 {
// TODO: In this case, remove it and continue? How likely is this to
// happen? Alternately, could skip it entirely, or could implement a
// delete function so that there is a way to remove these
return nil, nil, errutil.InternalError{Err: fmt.Sprintf("found revoked serial but actual certificate is empty")}
}
err = revokedEntry.DecodeJSON(&revInfo)
if err != nil {
return nil, nil, errutil.InternalError{Err: fmt.Sprintf("error decoding revocation entry for serial %s: %s", serial, err)}
}
revokedCert, err := x509.ParseCertificate(revInfo.CertificateBytes)
if err != nil {
return nil, nil, errutil.InternalError{Err: fmt.Sprintf("unable to parse stored revoked certificate with serial %s: %s", serial, err)}
}
// We want to skip issuer certificate's revocationEntries for two
// reasons:
//
// 1. We canonically use augmentWithRevokedIssuers to handle this
// case and this entry is just a backup. This prevents the issue
// of duplicate serial numbers on the CRL from both paths.
// 2. We want to avoid a root's serial from appearing on its own
// CRL. If it is a cross-signed or re-issued variant, this is OK,
// but in the case we mark the root itself as "revoked", we want
// to avoid it appearing on the CRL as that is definitely
// undefined/little-supported behavior.
//
// This hash map lookup should be faster than byte comparison against
// each issuer proactively.
if candidates, present := issuerSerialCertMap[serialFromCert(revokedCert)]; present {
revokedCertIsIssuer := false
for _, candidate := range candidates {
if bytes.Equal(candidate.Raw, revokedCert.Raw) {
revokedCertIsIssuer = true
break
}
}
if revokedCertIsIssuer {
continue
}
}
// NOTE: We have to change this to UTC time because the CRL standard
// mandates it but Go will happily encode the CRL without this.
newRevCert := pkix.RevokedCertificate{
SerialNumber: revokedCert.SerialNumber,
}
if !revInfo.RevocationTimeUTC.IsZero() {
newRevCert.RevocationTime = revInfo.RevocationTimeUTC
} else {
newRevCert.RevocationTime = time.Unix(revInfo.RevocationTime, 0).UTC()
}
// If we have a CertificateIssuer field on the revocation entry,
// prefer it to manually checking each issuer signature, assuming it
// appears valid. It's highly unlikely for two different issuers
// to have the same id (after the first was deleted).
if isRevInfoIssuerValid(&revInfo, issuerIDCertMap) {
revokedCertsMap[revInfo.CertificateIssuer] = append(revokedCertsMap[revInfo.CertificateIssuer], newRevCert)
continue
// Otherwise, fall through and update the entry.
}
// Now we need to assign the revoked certificate to an issuer.
foundParent := associateRevokedCertWithIsssuer(&revInfo, revokedCert, issuerIDCertMap)
if !foundParent {
// If the parent isn't found, add it to the unassigned bucket.
unassignedCerts = append(unassignedCerts, newRevCert)
} else {
revokedCertsMap[revInfo.CertificateIssuer] = append(revokedCertsMap[revInfo.CertificateIssuer], newRevCert)
// When the CertificateIssuer field wasn't found on the existing
// entry (or was invalid), and we've found a new value for it,
// we should update the entry to make future CRL builds faster.
revokedEntry, err = logical.StorageEntryJSON(revokedPath+serial, revInfo)
if err != nil {
return nil, nil, fmt.Errorf("error creating revocation entry for existing cert: %v", serial)
}
err = req.Storage.Put(ctx, revokedEntry)
if err != nil {
return nil, nil, fmt.Errorf("error updating revoked certificate at existing location: %v", serial)
}
}
}
return unassignedCerts, revokedCertsMap, nil
}
func augmentWithRevokedIssuers(issuerIDEntryMap map[issuerID]*issuerEntry, issuerIDCertMap map[issuerID]*x509.Certificate, revokedCertsMap map[issuerID][]pkix.RevokedCertificate) error {
// When setup our maps with the legacy CA bundle, we only have a
// single entry here. This entry is never revoked, so the outer loop
// will exit quickly.
for ourIssuerID, ourIssuer := range issuerIDEntryMap {
if !ourIssuer.Revoked {
continue
}
ourCert := issuerIDCertMap[ourIssuerID]
ourRevCert := pkix.RevokedCertificate{
SerialNumber: ourCert.SerialNumber,
RevocationTime: ourIssuer.RevocationTimeUTC,
}
for otherIssuerID := range issuerIDEntryMap {
if otherIssuerID == ourIssuerID {
continue
}
// Find all _other_ certificates which verify this issuer,
// allowing us to add this revoked issuer to this issuer's
// CRL.
otherCert := issuerIDCertMap[otherIssuerID]
if err := ourCert.CheckSignatureFrom(otherCert); err == nil {
// Valid signature; add our result.
revokedCertsMap[otherIssuerID] = append(revokedCertsMap[otherIssuerID], ourRevCert)
}
}
}
return nil
}
// Builds a CRL by going through the list of revoked certificates and building
// a new CRL with the stored revocation times and serial numbers.
func buildCRL(sc *storageContext, crlInfo *crlConfig, forceNew bool, thisIssuerId issuerID, revoked []pkix.RevokedCertificate, identifier crlID, crlNumber int64) (*time.Time, error) {
var revokedCerts []pkix.RevokedCertificate
crlLifetime, err := time.ParseDuration(crlInfo.Expiry)
if err != nil {
return nil, errutil.InternalError{Err: fmt.Sprintf("error parsing CRL duration of %s", crlInfo.Expiry)}
}
if crlInfo.Disable {
if !forceNew {
// In the event of a disabled CRL, we'll have the next time set
// to the zero time as a sentinel in case we get re-enabled.
return &time.Time{}, nil
}
// NOTE: in this case, the passed argument (revoked) is not added
// to the revokedCerts list. This is because we want to sign an
// **empty** CRL (as the CRL was disabled but we've specified the
// forceNew option). In previous versions of Vault (1.10 series and
// earlier), we'd have queried the certs below, whereas we now have
// an assignment from a pre-queried list.
goto WRITE
}
revokedCerts = revoked
WRITE:
signingBundle, caErr := sc.fetchCAInfoByIssuerId(thisIssuerId, CRLSigningUsage)
if caErr != nil {
switch caErr.(type) {
case errutil.UserError:
return nil, errutil.UserError{Err: fmt.Sprintf("could not fetch the CA certificate: %s", caErr)}
default:
return nil, errutil.InternalError{Err: fmt.Sprintf("error fetching CA certificate: %s", caErr)}
}
}
now := time.Now()
nextUpdate := now.Add(crlLifetime)
revocationListTemplate := &x509.RevocationList{
RevokedCertificates: revokedCerts,
Number: big.NewInt(crlNumber),
ThisUpdate: now,
NextUpdate: nextUpdate,
SignatureAlgorithm: signingBundle.RevocationSigAlg,
}
crlBytes, err := x509.CreateRevocationList(rand.Reader, revocationListTemplate, signingBundle.Certificate, signingBundle.PrivateKey)
if err != nil {
return nil, errutil.InternalError{Err: fmt.Sprintf("error creating new CRL: %s", err)}
}
writePath := "crls/" + identifier.String()
if thisIssuerId == legacyBundleShimID {
// Ignore the CRL ID as it won't be persisted anyways; hard-code the
// old legacy path and allow it to be updated.
writePath = legacyCRLPath
}
err = sc.Storage.Put(sc.Context, &logical.StorageEntry{
Key: writePath,
Value: crlBytes,
})
if err != nil {
return nil, errutil.InternalError{Err: fmt.Sprintf("error storing CRL: %s", err)}
}
return &nextUpdate, nil
}