nuts-foundation/nuts-node

View on GitHub
pki/validator.go

Summary

Maintainability
A
2 hrs
Test Coverage
B
84%
/*
 * Copyright (C) 2023 Nuts community
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package pki

import (
    "context"
    "crypto/tls"
    "crypto/x509"
    "errors"
    "fmt"
    "io"
    "net/http"
    "strings"
    "sync"
    "time"
)

var _ Validator = (*validator)(nil)

var nowFunc = time.Now

// RFC008 ยง3.3: "... A node MUST at least update all CRLs every hour."
// We try to update every hour, no guarantee that it succeeds.
const syncInterval = time.Hour

// After how long a CRL download attempt is stopped.
// Must be short-ish since the update can happen during a validation request.
const syncTimeout = 10 * time.Second

type validator struct {
    // httpClient downloads the CRLs
    httpClient *http.Client

    // truststore maps Certificate.Subject.String() to their certificate.
    // Used for CRL signature checking. Immutable once Start() has been called.
    truststore sync.Map

    // crls maps CRL endpoints to their x509.RevocationList
    crls sync.Map

    // denylist implements blocking of certificates with tuples of issuer/serial number
    denylist Denylist

    // maxUpdateFailHours is the maximum number of hours that a CRL or denylist can fail to update without causing errors
    maxUpdateFailHours int

    // softfail only rejects certificates that have been revoked or denied
    softfail bool
}

type revocationList struct {
    // list is the actual revocationList
    list *x509.RevocationList

    // revoked contains all revoked certificates index by their serial number (pkix.RevokedCertificate.SerialNumber.String())
    revoked map[string]bool

    // issuer of the CRL found at this endpoint. Multiple issuers could indicate MITM attack, or re-use of an endpoint.
    issuer *x509.Certificate

    // lastUpdated is the timestamp that list was last updated. (When this instance was of revocationList was created)
    lastUpdated time.Time
}

func newRevocationList(cert *x509.Certificate) *revocationList {
    return &revocationList{
        list:    new(x509.RevocationList),
        revoked: make(map[string]bool, 0),
        issuer:  cert,
    }
}

// newValidator returns a new PKI (crl/denylist) validator.
func newValidator(config Config) (*validator, error) {
    return newValidatorWithHTTPClient(config, &http.Client{Timeout: syncTimeout})
}

// NewValidatorWithHTTPClient returns a new instance with a pre-configured HTTP client
func newValidatorWithHTTPClient(config Config, client *http.Client) (*validator, error) {
    // Create the new denylist with the config
    denylist, err := NewDenylist(config.Denylist)
    if err != nil {
        return nil, fmt.Errorf("failed to init denylist: %w", err)
    }

    // Create the validator
    return &validator{
        httpClient:         client,
        denylist:           denylist,
        maxUpdateFailHours: config.MaxUpdateFailHours,
        softfail:           config.Softfail,
    }, nil
}

func (v *validator) start(ctx context.Context) {
    go v.syncLoop(ctx)
}

func (v *validator) syncLoop(ctx context.Context) {
    ticker := time.NewTicker(syncInterval)
    defer ticker.Stop()
    v.sync() // first tick is after the interval
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            v.sync()
        }
    }
}

func (v *validator) Validate(chain []*x509.Certificate) error {
    var cert *x509.Certificate
    var err error
    for i := range chain {
        cert = chain[len(chain)-1-i]
        // check in reverse order to prevent CRL expiration errors due to revoked CAs no longer issuing CRLs
        if err = v.validateCert(cert); err != nil {
            errOut := fmt.Errorf("%w: subject=%s, S/N=%s, issuer=%s", err, cert.Subject.String(), cert.SerialNumber.String(), cert.Issuer.String())
            if v.softfail && !(errors.Is(err, ErrCertRevoked) || errors.Is(err, ErrCertBanned)) {
                // Accept the certificate even if it cannot be properly validated
                logger().WithError(errOut).Error("Certificate CRL check softfail bypass. Might be unsafe, find cause of failure!")
                continue
            }
            return errOut
        }
    }
    return nil
}

func (v *validator) SetVerifyPeerCertificateFunc(config *tls.Config) error {
    config.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
        // rawCerts contain all certificates provided by the peer, in our case only the leaf cert, while verifiedChains is guaranteed to include the CA's.
        // rawCerts are ignored since we would only be checking revocation status on a cert whose issuer is not in the truststore. failure mode is then determined by v.softfail.
        for _, chain := range verifiedChains {
            if err := v.Validate(chain); err != nil {
                return &tls.CertificateVerificationError{
                    UnverifiedCertificates: chain,
                    Err:                    err,
                }
            }
        }
        return nil
    }
    return nil
}

func (v *validator) validateCert(cert *x509.Certificate) error {
    // Check if a denylist is in use
    if v.denylist != nil {
        // Validate the cert against the denylist
        if err := v.denylist.ValidateCert(cert); err != nil {
            // Return any denylist error, blocking the certificate
            return err
        }
    }

    // validate the cert against the CRLs
    for _, endpoint := range cert.CRLDistributionPoints {
        crl, ok := v.getCRL(endpoint)

        // add distribution endpoint if unknown
        if !ok {
            var issuer *x509.Certificate
            issuer, ok = v.getCert(cert.Issuer.String())
            if !ok {
                return ErrCertUntrusted
            }
            err := v.addEndpoints(issuer, []string{endpoint})
            if err != nil {
                logger().WithError(err).
                    WithField("Subject", cert.Subject.String()).
                    WithField("S/N", cert.SerialNumber.String()).
                    Warn("cert validation failed because CRL cannot be added")
                return ErrCRLMissing
            }
            // update loop params
            crl, _ = v.getCRL(endpoint) // must be present now
        }

        // initial download if missing
        if crl.lastUpdated.IsZero() {
            // Pause validate to download CRL. Client requests run in their own go routine, so we can afford to wait.
            err := v.updateCRL(endpoint, crl)
            if err != nil {
                logger().WithError(err).
                    WithField("Subject", cert.Subject.String()).
                    WithField("S/N", cert.SerialNumber.String()).
                    WithField("endpoint", endpoint).
                    Warn("certificate validation failed because CRL cannot be updated")
                return ErrCRLMissing
            }
            // update loop params
            crl, _ = v.getCRL(endpoint) // must be present
        }

        // check certificate revocation
        if crl.revoked[cert.SerialNumber.String()] {
            // revocation takes precedence over expired CRL.
            return ErrCertRevoked
        }

        // check CRL status.
        // This check comes last for softfail purposes. This way a revoked certificate on an outdated CRL is still treated as revoked.
        if !nowFunc().Before(crl.list.NextUpdate) {
            // CA expiration is checked earlier in the chain
            return ErrCRLExpired
        }
    }
    return nil
}

func (v *validator) AddTruststore(chain []*x509.Certificate) error {
    // Add all CAs
    // TODO: cert.Subject.String() is not guaranteed to be unique
    var certificate *x509.Certificate
    var err error
    for _, certificate = range chain {
        v.truststore.Store(certificate.Subject.String(), certificate)
    }

    // Add CRL distribution points, issuers should all be available now
    for _, certificate = range chain {
        issuer, ok := v.getCert(certificate.Issuer.String())
        if !ok {
            err = fmt.Errorf("certificate's issuer is not in the trust store: subject=%s, issuer=%s", certificate.Subject.String(), certificate.Issuer.String())
            if !v.softfail {
                return fmt.Errorf("pki: %w", err)
            }
            // Can happen if the intermediate CA issuing end entity (EE) certificates is added, but not its issuer. EE wil be checked for revocation, CA revocation is not.
            logger().WithError(err).Warn("Did not add CRL Distribution Points")
            continue
        }
        err = v.addEndpoints(issuer, certificate.CRLDistributionPoints)
        if err != nil {
            // should never happen for certificates issued by real CAs
            return err
        }
    }

    return nil
}

func (v *validator) SubscribeDenied(f func()) {
    v.denylist.Subscribe(f)
}

func (v *validator) getCert(subject string) (*x509.Certificate, bool) {
    issuer, ok := v.truststore.Load(subject)
    if !ok {
        return nil, false
    }
    return issuer.(*x509.Certificate), true
}

// addEndpoint adds the CRL endpoint if it does not exist. Returns an error if the CRL issuer does not match the expected issuer.
func (v *validator) addEndpoints(certIssuer *x509.Certificate, endpoints []string) error {
    for _, endpoint := range endpoints {
        if crl, ok := v.getCRL(endpoint); ok {
            if strings.Compare(crl.issuer.Subject.String(), certIssuer.Subject.String()) != 0 {
                // We assume that an endpoint can only issue CRLs from a single CA because:
                // If an endpoint hosts multiple CRLs, how would the server know what CRL to present?
                // Endpoint reuse by CAs is not an issue. CAs host the CRL for some time after the CA has expired, immediate reuse result in the previous point.
                return fmt.Errorf("multiple issuers known for CRL distribution endpoint=%s, issuers=%s,%s", endpoints, crl.issuer.Subject.String(), certIssuer.Subject.String())
            }
            // already exists
            continue
        }
        // TODO: Optimize by starting Go routine per endpoint to update the CRL. A Go routine per CRL prevents all CRLs being updated simultaneously.
        v.crls.Store(endpoint, newRevocationList(certIssuer))
    }
    return nil
}

// getCRL returns the requested crls in a save way, or returns false if it does not exist
func (v *validator) getCRL(endpoint string) (*revocationList, bool) {
    value, ok := v.crls.Load(endpoint)
    if !ok {
        return nil, false
    }
    return value.(*revocationList), true
}

// sync tries to update all crls
func (v *validator) sync() {
    // Use a WaitGroup to track when background goroutines are complete
    wg := &sync.WaitGroup{}

    // maximum time between updates
    maxDelay := time.Duration(v.maxUpdateFailHours) * time.Hour

    // Check if a denylist is in use
    if v.denylist != nil {
        // Track that a goroutine is being started
        wg.Add(1)

        // Update the denylist in a background routine
        go func() {
            // Ensure that the WaitGroup is updated when this goroutine ends
            defer wg.Done()

            // Update the denylist
            if err := v.denylist.Update(); err != nil {
                // If the denylist is more than X hours out of date then there is a serious issue
                if isOutdated(v.denylist.LastUpdated(), maxDelay) {
                    // Log a message about the failed denylist update
                    logger().
                        WithError(err).
                        WithField("URL", v.denylist.URL()).
                        Error("Failed to update denylist")
                } else {
                    // Log a message about the failed denylist update
                    logger().
                        WithError(err).
                        WithField("URL", v.denylist.URL()).
                        Warn("Failed to update denylist")
                }
            }
        }()
    }

    // Update in parallel if at least one of the issuers is still valid
    v.crls.Range(func(endpointAny, currentAny any) bool {
        // Convert the untyped variables
        endpoint, isString := endpointAny.(string)
        current, isCRL := currentAny.(*revocationList)

        // Ensure the type converions succeeded
        if !isString || !isCRL {
            // This should never happen. If it does, it indicates a programming error in which
            // the v.crls sync.Map has been incorrectly populated.
            logger().
                WithField("endpoint", fmt.Sprintf("%v", endpointAny)).
                WithField("CRL", fmt.Sprintf("%v", currentAny)).
                Error("CRL validator is invalid")

            // Return true in order to continue the range operation
            return true
        }

        // Enforce the certificate NotBefore/NotAfter fields
        if invalidByTime(current.issuer) {
            // Log the failure, noting the certificate details in the log message
            logger().
                WithField("subject", current.issuer.Subject.String()).
                WithField("S/N", current.issuer.SerialNumber.String()).
                Warn("Trust store contains expired certificate")

            // Return true in order to continue the range operation
            return true
        }

        // Track that a go routine is being started
        wg.Add(1)
        go func(endpoint string, crl *revocationList, wg *sync.WaitGroup) {
            // Ensure that the waitgroup is updated when this goroutine ends
            defer wg.Done()

            // Retrieve and process the CRL for this endpoint
            err := v.updateCRL(endpoint, crl)
            if err != nil {
                // Connections containing a certificate pointing to this CRL will be accepted until its current.list.NextUpdate.
                if crl != nil || isOutdated(crl.lastUpdated, maxDelay) {
                    // Escalate to error logging if the CRL is missing or fails to update for several hours.
                    logger().WithError(err).WithField("CRLDistributionPoint", endpoint).Error("Update CRL")
                } else {
                    logger().WithError(err).WithField("CRLDistributionPoint", endpoint).Debug("Update CRL")
                }
            }
        }(endpoint, current, wg)

        return true
    })
    wg.Wait()
}

// updateCRL downloads the CRL from endpoint, and updates the revocationList in crls if it is newer than the current CRL
func (v *validator) updateCRL(endpoint string, current *revocationList) error {
    // download CRL
    crl, err := v.downloadCRL(endpoint)
    if err != nil {
        return err
    }

    // verify signature
    if err = v.verifyCRL(crl, current.issuer); err != nil {
        return err
    }

    // parse revocations
    revoked := make(map[string]bool, len(crl.RevokedCertificateEntries))
    for _, rev := range crl.RevokedCertificateEntries {
        revoked[rev.SerialNumber.String()] = true
    }
    // set the new CRL
    v.crls.Store(endpoint, &revocationList{
        list:        crl,
        issuer:      current.issuer,
        revoked:     revoked,
        lastUpdated: nowFunc(),
    })

    return nil
}

// downloadCRL downloads and parses the CRL
func (v *validator) downloadCRL(endpoint string) (*x509.RevocationList, error) {
    response, err := v.httpClient.Get(endpoint)
    if err != nil {
        return nil, fmt.Errorf("downloading CRL: %w", err)
    }
    defer func() {
        if err = response.Body.Close(); err != nil {
            logger().Warn(err)
        }
    }()

    data, err := io.ReadAll(response.Body)
    if err != nil {
        return nil, fmt.Errorf("reading CRL response: %w", err)
    }

    crl, err := x509.ParseRevocationList(data)
    if err != nil {
        return nil, fmt.Errorf("parse downloaded CRL: %w", err)
    }

    return crl, nil
}

// verifyCRL checks the signature on the CRL with the issuer. Returns an error if the issuers is unknown.
func (v *validator) verifyCRL(crl *x509.RevocationList, expectedIssuer *x509.Certificate) error {
    // update issuers of CRL
    if strings.Compare(expectedIssuer.Subject.String(), crl.Issuer.String()) != 0 {
        return fmt.Errorf("crl signed by unexpected issuer: expected=%s, got=%s", expectedIssuer.Subject.String(), crl.Issuer.String())
    }
    err := crl.CheckSignatureFrom(expectedIssuer)
    if err != nil {
        return fmt.Errorf("crl signature could not be verified: %w", err)
    }
    return nil
}

// invalidByTime returns true if nowFunc() is outside interval [NotBefore, NotAfter]
func invalidByTime(cert *x509.Certificate) bool {
    return nowFunc().Before(cert.NotBefore) || nowFunc().After(cert.NotAfter)
}

// isOutdated returns true if nowFunc() - lastUpdate > maxDelay, where maxDelay is the maximum allowed interval for updates
func isOutdated(lastUpdate time.Time, maxDelay time.Duration) bool {
    return nowFunc().Sub(lastUpdate) > maxDelay
}