Skip to content

Commit

Permalink
Add CAs/CRLs to the PKI engine at runtime (#3606)
Browse files Browse the repository at this point in the history
  • Loading branch information
gerardsn authored Dec 16, 2024
1 parent 098da9b commit 187d5fa
Show file tree
Hide file tree
Showing 16 changed files with 151 additions and 168 deletions.
1 change: 0 additions & 1 deletion auth/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ func testInstance(t *testing.T, cfg Config) *Auth {
vcrInstance := vcr.NewTestVCRInstance(t)
ctrl := gomock.NewController(t)
pkiMock := pki.NewMockProvider(ctrl)
pkiMock.EXPECT().AddTruststore(gomock.Any()).AnyTimes()
pkiMock.EXPECT().CreateTLSConfig(gomock.Any()).AnyTimes()
vdrInstance := vdr.NewMockVDR(ctrl)
vdrInstance.EXPECT().Resolver().AnyTimes()
Expand Down
7 changes: 1 addition & 6 deletions docs/pages/deployment/certificates.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,6 @@ In ``did:x509`` the certificates are also used in the cryptographic proofs to ob
This means the certificate chain now provides the root of trust and has stricter requirements than connection certificates.

Trust in specific certificate CAs is configured per use-case in a :ref:`Discovery <discovery>` and :ref:`Policy <policy>` definition file.
In addition, all trusted CA chains must also be added to the ``tls.truststorefile``.
CRLs from trusted chains (per the above definition files) are consulted when evaluating ``did:x509`` Verifiable Credentials.
For certificate chains used in ``did:x509`` the Nuts-node always uses a hard-fail strategy, i.e., the ``pki.softfail`` config value is ignored during certificate validation for ``did:x509``.
This means that the Nuts-node will not be able to verify a ``did:x509`` DID or Verifiable Credential signed by this DID Method if the CRL cannot be downloaded and the CRL in the cache is older than ``pki.maxupdatefailhours``.

.. note::

Since the configured truststore file is now used for multiple purposes, it is no longer possible for the Nuts-node to determine what certificate chain is accepted/trusted for what purpose.
This means that all incoming TLS connections (including gRPC) must be offloaded in a proxy and validated against the expected certificate chain.
2 changes: 0 additions & 2 deletions network/network_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,6 @@ func TestNetwork_Configure(t *testing.T) {
ctrl := gomock.NewController(t)
ctx := createNetwork(t, ctrl)
ctx.protocol.EXPECT().Configure(gomock.Any())
ctx.pkiValidator.EXPECT().AddTruststore(gomock.Any())
ctx.pkiValidator.EXPECT().SetVerifyPeerCertificateFunc(gomock.Any()).Times(2) // tls.Configs: client, selfTestDialer
ctx.pkiValidator.EXPECT().SubscribeDenied(gomock.Any())
ctx.network.connectionManager = nil
Expand Down Expand Up @@ -277,7 +276,6 @@ func TestNetwork_Configure(t *testing.T) {
ctrl := gomock.NewController(t)
ctx := createNetwork(t, ctrl)
ctx.protocol.EXPECT().Configure(gomock.Any())
ctx.pkiValidator.EXPECT().AddTruststore(gomock.Any())
ctx.pkiValidator.EXPECT().SetVerifyPeerCertificateFunc(gomock.Any()) // selftestDialer tls.Config
ctx.network.connectionManager = nil

Expand Down
3 changes: 0 additions & 3 deletions network/transport/grpc/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,6 @@ func WithTLS(clientCertificate tls.Certificate, trustStore *core.TrustStore, pki
config.clientCert = &clientCertificate
config.trustStore = trustStore.CertPool
config.pkiValidator = pkiValidator
if err := pkiValidator.AddTruststore(trustStore.Certificates()); err != nil {
return err
}
// Load TLS server certificate if the gRPC server should be started.
if config.listenAddress != "" {
config.serverCert = config.clientCert
Expand Down
2 changes: 0 additions & 2 deletions network/transport/grpc/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ func TestNewConfig(t *testing.T) {
ts := &core.TrustStore{
CertPool: x509.NewCertPool(),
}
pkiMock.EXPECT().AddTruststore(gomock.Any())
cfg, err := NewConfig(":1234", "foo", WithTLS(tlsCert, ts, pkiMock))
require.NoError(t, err)
assert.Equal(t, &tlsCert, cfg.clientCert)
Expand All @@ -64,7 +63,6 @@ func TestNewConfig(t *testing.T) {
ts := &core.TrustStore{
CertPool: core.NewCertPool(x509Cert),
}
pkiMock.EXPECT().AddTruststore(gomock.Any())
cfg, err := NewConfig(":1234", "foo", WithTLS(tlsCert, ts, pkiMock))
require.NoError(t, err)
assert.Equal(t, &tlsCert, cfg.clientCert)
Expand Down
2 changes: 0 additions & 2 deletions network/transport/grpc/connection_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ func Test_grpcConnectionManager_Connect(t *testing.T) {
clientCert := testPKI.Certificate()
ctrl := gomock.NewController(t)
pkiMock := pki.NewMockValidator(ctrl)
pkiMock.EXPECT().AddTruststore(ts.Certificates())
pkiMock.EXPECT().SetVerifyPeerCertificateFunc(gomock.Any())
pkiMock.EXPECT().SubscribeDenied(gomock.Any())

Expand Down Expand Up @@ -557,7 +556,6 @@ func Test_grpcConnectionManager_Start(t *testing.T) {
serverCert := testPKI.Certificate()
ctrl := gomock.NewController(t)
pkiMock := pki.NewMockValidator(ctrl)
pkiMock.EXPECT().AddTruststore(gomock.Any()).AnyTimes()

t.Run("ok - gRPC server not bound", func(t *testing.T) {
cm, err := NewGRPCConnectionManager(Config{}, nil, *nodeDID, nil)
Expand Down
20 changes: 11 additions & 9 deletions pki/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ var (
ErrCRLMissing = errors.New("crl is missing")
ErrCRLExpired = errors.New("crl has expired")
ErrCertRevoked = errors.New("certificate is revoked")
ErrCertUntrusted = errors.New("certificate's issuer is not trusted")
ErrUnknownIssuer = errors.New("unknown certificate issuer")
// ErrDenylistMissing occurs when the denylist cannot be downloaded
ErrDenylistMissing = errors.New("denylist cannot be retrieved")

Expand All @@ -57,14 +57,20 @@ type Denylist interface {
Subscribe(f func())
}

// Validator is used to check the revocation status of certificates on the issuer controlled CRL and the user controlled Denylist.
// It does NOT manage trust and assumes all presented certificates belong to a trusted certificate tree.
type Validator interface {
// CheckCRL returns an error if any of the certificates in the chain has been revoked, or if the request cannot be processed.
// ErrCertRevoked and ErrCertUntrusted indicate that at least one of the certificates is revoked, or signed by a CA that is not in the truststore.
// All certificates in the chain are considered trusted, which means that the caller has verified the integrity of the chain and appropriateness for the use-case.
// Any new CA / CRL in the chain will be added to the internal watchlist and updated periodically, so it MUST NOT be called on untrusted/invalid chains.
// The certificate chain MUST be sorted leaf to root.
//
// ErrCertRevoked and ErrUnknownIssuer indicate that at least one of the certificates is revoked, or signed by an unknown CA (so we have no key to verify the CRL).
// ErrCRLMissing and ErrCRLExpired signal that at least one of the certificates cannot be validated reliably.
// If the certificate was revoked on an expired CRL, it wil return ErrCertRevoked.
//
// CheckCRL uses the configured soft-/hard-fail strategy
// If set to soft-fail it ignores ErrCRLMissing and ErrCRLExpired errors.
// The certificate chain is expected to be sorted leaf to root.
CheckCRL(chain []*x509.Certificate) error

// CheckCRLStrict does the same as CheckCRL, except it always uses the hard-fail strategy.
Expand All @@ -73,11 +79,6 @@ type Validator interface {
// SetVerifyPeerCertificateFunc sets config.ValidatePeerCertificate to use CheckCRL.
SetVerifyPeerCertificateFunc(config *tls.Config) error

// AddTruststore adds all CAs to the truststore for validation of CRL signatures. It also adds all CRL Distribution Endpoints found in the chain.
// CRL Distribution Points encountered at runtime, such as on end user certificates when calling CheckCRL, are only added to the monitored CRLs if their issuer is in the truststore.
// This fails if any of the issuers mentioned in the chain is not also in the chain or already in the truststore
AddTruststore(chain []*x509.Certificate) error

// SubscribeDenied registers a callback that is triggered everytime the denylist is updated.
// This can be used to revalidate all certificates on long-lasting connections by calling CheckCRL on them again.
SubscribeDenied(f func())
Expand All @@ -86,6 +87,7 @@ type Validator interface {
// Provider is an interface for providing PKI services (e.g. TLS configuration, certificate validation).
type Provider interface {
Validator
// CreateTLSConfig creates a tls.Config for outbound connections. It returns nil (and no error) if TLS is disabled.
// CreateTLSConfig creates a tls.Config from the core.TLSConfig for outbound connections.
// It returns (nil, nil) if core.TLSConfig.Enabled() == false.
CreateTLSConfig(cfg core.TLSConfig) (*tls.Config, error)
}
28 changes: 0 additions & 28 deletions pki/mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 29 additions & 8 deletions pki/pki.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"crypto/tls"
"fmt"
"github.com/nuts-foundation/nuts-node/core"
"os"
"time"
)

Expand Down Expand Up @@ -56,15 +57,39 @@ func (p *PKI) Config() any {
return &p.config
}

func (p *PKI) Configure(_ core.ServerConfig) error {
func (p *PKI) Configure(config core.ServerConfig) error {
var err error
p.validator, err = newValidator(p.config)
if err != nil {
return err
}
trustStore, err := loadTrustStore(config.TLS.TrustStoreFile)
if err != nil {
return err
}
if trustStore != nil {
err = p.addCAs(trustStore.Certificates())
if err != nil {
return err
}
}
return nil
}

func loadTrustStore(file string) (*core.TrustStore, error) {
if file == "" {
return nil, nil
}
if _, err := os.Stat(file); err != nil {
if os.IsNotExist(err) && file == core.NewServerConfig().TLS.TrustStoreFile {
// assume this is the default config value and ignore it
return nil, nil
}
return nil, fmt.Errorf("failed to load truststore: %w", err)
}
return core.LoadTrustStore(file)
}

func (p *PKI) Start() error {
p.ctx, p.shutdown = context.WithCancel(context.Background())
p.validator.start(p.ctx)
Expand All @@ -77,22 +102,18 @@ func (p *PKI) Shutdown() error {
}

// CreateTLSConfig creates a tls.Config based on the given core.TLSConfig for outbound connections to other Nuts nodes.
// It registers the CA certificates in the trust store in the validator which will start fetching their CRLs.
// It finally registers a VerifyPeerCertificateFunc in the tls.Config which will validate the peer certificate against the validator.
// It registers a VerifyPeerCertificateFunc in the tls.Config which will validate the peer certificate against the CRLs.
// If TLS is not enabled, it returns nil (and no error).
func (p *PKI) CreateTLSConfig(cfg core.TLSConfig) (*tls.Config, error) {
tlsConfig, trustStore, err := cfg.Load()
// This uses the provided truststore (truststore from config), NOT the CA list from the CRL Validator.
tlsConfig, _, err := cfg.Load()
if err != nil {
return nil, err
}
if tlsConfig == nil {
// TLS is not enabled
return nil, nil
}
err = p.AddTruststore(trustStore.Certificates())
if err != nil {
return nil, err
}
_ = p.SetVerifyPeerCertificateFunc(tlsConfig) // no error can occur
return tlsConfig, nil
}
Expand Down
43 changes: 37 additions & 6 deletions pki/pki_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,53 @@ func TestPKI_Configure(t *testing.T) {
t.Run("ok", func(t *testing.T) {
e := New()

err := e.Configure(core.ServerConfig{})
err := e.Configure(*core.NewServerConfig())

assert.NoError(t, err)
assert.NotNil(t, e.validator)
})
t.Run("loads truststore", func(t *testing.T) {
e := New()
cfg := core.NewServerConfig().TLS
cfg.TrustStoreFile = "test/truststore.pem"
cfg.CertFile = "test/A-valid.pem"
cfg.CertKeyFile = "test/A-valid.pem"

err := e.Configure(core.ServerConfig{TLS: cfg})

assert.NoError(t, err)
assert.NotNil(t, e.validator)
// Assert the certificate in truststore.pem was loaded into the truststore
_, ok := e.cas.Load("CN=Intermediate A CA")
assert.True(t, ok)
})
t.Run("no truststore", func(t *testing.T) {
e := New()
cfg := core.NewServerConfig()
cfg.TLS.TrustStoreFile = "" // remove default

err := e.Configure(*cfg)

assert.NoError(t, err)
assert.NotNil(t, e.validator)
})
t.Run("invalid truststore file", func(t *testing.T) {
e := New()
cfg := core.NewServerConfig()
cfg.TLS.TrustStoreFile = "./not-the-default"

err := e.Configure(*cfg)

assert.ErrorContains(t, err, "failed to load truststore: stat ./not-the-default:")
})
t.Run("invalid config", func(t *testing.T) {
e := New()
e.config.Denylist = DenylistConfig{
URL: "example.com",
TrustedSigner: "definitely not valid",
}

err := e.Configure(core.ServerConfig{})
err := e.Configure(*core.NewServerConfig())

assert.Error(t, err)
})
Expand Down Expand Up @@ -100,7 +134,7 @@ func TestPKI_CheckHealth(t *testing.T) {
// Add truststore
store, err := core.LoadTrustStore(truststore) // contains 1 CRL distribution point
require.NoError(t, err)
require.NoError(t, e.validator.AddTruststore(store.Certificates()))
require.NoError(t, e.validator.addCAs(store.Certificates()))

// Add Denylist
testServer := denylistTestServer("")
Expand Down Expand Up @@ -171,9 +205,6 @@ func TestPKI_CreateTLSConfig(t *testing.T) {
assert.Equal(t, core.MinTLSVersion, tlsConfig.MinVersion)
assert.NotEmpty(t, tlsConfig.Certificates)
assert.NotNil(t, tlsConfig.RootCAs)
// Assert the certificate in truststore.pem was loaded into the truststore
_, ok := e.truststore.Load("CN=Intermediate A CA")
assert.True(t, ok)
})
t.Run("TLS disabled", func(t *testing.T) {
e := New()
Expand Down
Loading

0 comments on commit 187d5fa

Please sign in to comment.