From 1fcb798ec5cbcb5bdf8c3188ca827bd286c7156d Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 18 Jun 2026 08:40:57 +0200 Subject: [PATCH 1/7] feat(httpclient): trust additional CA certs from a directory Add the httpclient.tls.extracertsdir config option. When set, all *.pem and *.crt files in the directory are loaded and added to the HTTP client trust bundle, on top of the OS CA bundle. Subject and SHA-256 fingerprint of each certificate are logged. A configured-but-missing directory or an invalid certificate file is a hard error at startup. The Docker image creates /etc/nuts/http-trust.d and sets NUTS_HTTPCLIENT_TLS_EXTRACERTSDIR by default, so CAs can be dropped in via a volume mount without rebuilding the image. Closes #4285 Assisted by AI --- Dockerfile | 5 + core/server_config.go | 10 ++ core/server_config_test.go | 12 ++ docs/pages/deployment/server_options.rst | 1 + http/client/trustbundle.go | 86 ++++++++++++++ http/client/trustbundle_test.go | 138 +++++++++++++++++++++++ http/engine.go | 11 +- 7 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 http/client/trustbundle.go create mode 100644 http/client/trustbundle_test.go diff --git a/Dockerfile b/Dockerfile index a751897160..ee4c29b14d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,11 @@ HEALTHCHECK --start-period=30s --timeout=5s --interval=10s \ CMD curl -f http://localhost:8081/status || exit 1 RUN adduser -D -H -u 18081 nuts-usr + +# Mountable directory for additional CA certificates (*.pem, *.crt) that HTTP clients trust, on top of the OS CA bundle. +RUN mkdir -p /etc/nuts/http-trust.d && chown 18081:18081 /etc/nuts/http-trust.d +ENV NUTS_HTTPCLIENT_TLS_EXTRACERTSDIR=/etc/nuts/http-trust.d + USER 18081:18081 WORKDIR /nuts diff --git a/core/server_config.go b/core/server_config.go index 434300e72b..5e87f8db65 100644 --- a/core/server_config.go +++ b/core/server_config.go @@ -86,6 +86,15 @@ type HTTPConfig struct { type HTTPClientConfig struct { // Timeout specifies the timeout for HTTP requests. Timeout time.Duration `koanf:"timeout"` + // TLS contains TLS settings for HTTP clients. + TLS HTTPClientTLSConfig `koanf:"tls"` +} + +// HTTPClientTLSConfig contains TLS settings for HTTP clients. +type HTTPClientTLSConfig struct { + // ExtraCertsDir specifies a directory with additional CA certificates (*.pem, *.crt) that HTTP clients trust, + // on top of the OS CA bundle. + ExtraCertsDir string `koanf:"extracertsdir"` } // TLSConfig specifies how TLS should be configured for connections. @@ -269,6 +278,7 @@ func FlagSet() *pflag.FlagSet { flagSet.StringSlice("didmethods", defaultCfg.DIDMethods, "Comma-separated list of enabled DID methods (without did: prefix). "+ "It also controls the order in which DIDs are returned by APIs, and which DID is used for signing if the verifying party does not impose restrictions on the DID method used.") flagSet.Duration("httpclient.timeout", defaultCfg.HTTPClient.Timeout, "Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax.") + flagSet.String("httpclient.tls.extracertsdir", defaultCfg.HTTPClient.TLS.ExtraCertsDir, "Directory containing additional CA certificates (*.pem, *.crt) that HTTP clients trust, on top of the OS CA bundle. When set, the directory must exist.") flagSet.String("tls.certfile", defaultCfg.TLS.CertFile, "PEM file containing the certificate for the gRPC server (also used as client certificate). Required in strict mode.") flagSet.String("tls.certkeyfile", defaultCfg.TLS.CertKeyFile, "PEM file containing the private key of the gRPC server certificate. Required in strict mode.") flagSet.String("tls.truststorefile", defaultCfg.TLS.TrustStoreFile, "PEM file containing the trusted CA certificates for authenticating remote gRPC servers. Required in strict mode.") diff --git a/core/server_config_test.go b/core/server_config_test.go index ab00a13339..ec7f4c9105 100644 --- a/core/server_config_test.go +++ b/core/server_config_test.go @@ -150,6 +150,18 @@ func TestNewNutsConfig_Load(t *testing.T) { assert.Equal(t, "warn", cfg.Verbosity) }) + + t.Run("ok - httpclient.tls.extracertsdir is parsed", func(t *testing.T) { + defer reset() + os.Args = []string{"command", "--httpclient.tls.extracertsdir", "/etc/nuts/http-trust.d"} + cfg := NewServerConfig() + cmd := testCommand() + + err := cfg.Load(cmd.Flags()) + + require.NoError(t, err) + assert.Equal(t, "/etc/nuts/http-trust.d", cfg.HTTPClient.TLS.ExtraCertsDir) + }) } func TestNewNutsConfig_PrintConfig(t *testing.T) { diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index e6c5f3d304..e63b21eb3a 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -15,6 +15,7 @@ url Public facing URL of the server (required). Must be HTTPS when strictmode is set. verbosity info Log level (trace, debug, info, warn, error) httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. + httpclient.tls.extracertsdir Directory containing additional CA certificates (*.pem, *.crt) that HTTP clients trust, on top of the OS CA bundle. When set, the directory must exist. **Auth** auth.authorizationendpoint.enabled false enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature. auth.experimental.jwtbearerclient false enables the experimental RFC 7523 jwt-bearer two-VP token request flow. While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice. diff --git a/http/client/trustbundle.go b/http/client/trustbundle.go new file mode 100644 index 0000000000..c530e741dd --- /dev/null +++ b/http/client/trustbundle.go @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2025 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 . + * + */ + +package client + +import ( + "crypto/x509" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/crypto/hash" + "github.com/nuts-foundation/nuts-node/http/log" +) + +// ConfigureTrustBundle extends the trust bundle of the shared SafeHttpTransport with additional CA certificates +// loaded from the given directory, on top of the OS CA bundle. It loads all *.pem and *.crt files and logs the +// subject and SHA-256 fingerprint of each certificate. +// If dir is empty the feature is disabled and nothing is loaded. A configured directory that can't be read +// (including a non-existent directory) or that contains an invalid certificate file results in an error. +func ConfigureTrustBundle(dir string) error { + if dir == "" { + return nil + } + + pool, err := x509.SystemCertPool() + if err != nil { + // SystemCertPool may fail on some platforms; fall back to an empty pool so the configured CAs are still trusted. + log.Logger().WithError(err).Warn("Unable to load OS CA bundle for HTTP clients, only the additional CA certificates will be trusted") + pool = x509.NewCertPool() + } + + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("unable to read HTTP client trust bundle directory (dir=%s): %w", dir, err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + switch strings.ToLower(filepath.Ext(entry.Name())) { + case ".pem", ".crt": + // load it + default: + continue + } + filePath := filepath.Join(dir, entry.Name()) + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("unable to read HTTP client CA certificate (file=%s): %w", filePath, err) + } + certificates, err := core.ParseCertificates(data) + if err != nil { + return fmt.Errorf("unable to parse HTTP client CA certificate (file=%s): %w", filePath, err) + } + for _, certificate := range certificates { + pool.AddCert(certificate) + log.Logger(). + WithField("file", filePath). + WithField("subject", certificate.Subject.String()). + WithField("fingerprint", hash.SHA256Sum(certificate.Raw).String()). + Info("Trusting additional CA certificate for HTTP clients") + } + } + + SafeHttpTransport.TLSClientConfig.RootCAs = pool + return nil +} diff --git a/http/client/trustbundle_test.go b/http/client/trustbundle_test.go new file mode 100644 index 0000000000..aba57b6b99 --- /dev/null +++ b/http/client/trustbundle_test.go @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2025 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 . + * + */ + +package client + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigureTrustBundle(t *testing.T) { + // ConfigureTrustBundle mutates the global SafeHttpTransport, so reset it after each subtest. + resetTransport := func(t *testing.T) { + original := SafeHttpTransport.TLSClientConfig.RootCAs + t.Cleanup(func() { + SafeHttpTransport.TLSClientConfig.RootCAs = original + }) + SafeHttpTransport.TLSClientConfig.RootCAs = nil + } + + t.Run("empty directory argument is a no-op", func(t *testing.T) { + resetTransport(t) + + err := ConfigureTrustBundle("") + + require.NoError(t, err) + assert.Nil(t, SafeHttpTransport.TLSClientConfig.RootCAs) + }) + t.Run("non-existent directory returns an error", func(t *testing.T) { + resetTransport(t) + + err := ConfigureTrustBundle(filepath.Join(t.TempDir(), "does-not-exist")) + + require.Error(t, err) + assert.Nil(t, SafeHttpTransport.TLSClientConfig.RootCAs) + }) + t.Run("loads .pem and .crt files", func(t *testing.T) { + resetTransport(t) + dir := t.TempDir() + caCert, caKey := newTestCA(t) + writePEM(t, filepath.Join(dir, "ca.pem"), caCert.Raw) + otherCA, _ := newTestCA(t) + writePEM(t, filepath.Join(dir, "other.crt"), otherCA.Raw) + // files with other extensions are ignored + require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("not a certificate"), 0644)) + + err := ConfigureTrustBundle(dir) + + require.NoError(t, err) + pool := SafeHttpTransport.TLSClientConfig.RootCAs + require.NotNil(t, pool) + // A leaf signed by the loaded CA must verify against the configured pool, proving the CA was added. + leaf := newTestLeaf(t, caCert, caKey) + _, err = leaf.Verify(x509.VerifyOptions{Roots: pool}) + assert.NoError(t, err) + }) + t.Run("invalid certificate file returns an error", func(t *testing.T) { + resetTransport(t) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "broken.pem"), []byte("-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----"), 0644)) + + err := ConfigureTrustBundle(dir) + + assert.Error(t, err) + }) +} + +// newTestCA creates a self-signed CA certificate for use in tests. +func newTestCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert, key +} + +// newTestLeaf creates a leaf certificate signed by the given CA. +func newTestLeaf(t *testing.T, ca *x509.Certificate, caKey *ecdsa.PrivateKey) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "leaf.example.com"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, template, ca, &key.PublicKey, caKey) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +func writePEM(t *testing.T, path string, der []byte) { + t.Helper() + data := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + require.NoError(t, os.WriteFile(path, data, 0644)) +} diff --git a/http/engine.go b/http/engine.go index d2c1533056..a09d95966f 100644 --- a/http/engine.go +++ b/http/engine.go @@ -66,7 +66,9 @@ func (h Engine) Router() core.EchoRouter { // Configure loads the configuration for the HTTP engine. func (h *Engine) Configure(serverConfig core.ServerConfig) error { - h.configureClient(serverConfig) + if err := h.configureClient(serverConfig); err != nil { + return err + } // We have 2 HTTP interfaces: internal and public // The following paths (and their subpaths) are bound to the internal interface: @@ -98,12 +100,17 @@ func (h *Engine) Configure(serverConfig core.ServerConfig) error { return h.applyAuthMiddleware(h.server, InternalPath, h.config.Internal.Auth) } -func (h *Engine) configureClient(serverConfig core.ServerConfig) { +func (h *Engine) configureClient(serverConfig core.ServerConfig) error { client.StrictMode = serverConfig.Strictmode + // Extend the HTTP client trust bundle with additional CA certificates, if configured. + if err := client.ConfigureTrustBundle(serverConfig.HTTPClient.TLS.ExtraCertsDir); err != nil { + return err + } // Configure the HTTP caching client, if enabled. Set it to http.DefaultTransport so it can be used by any subsystem. if h.config.ResponseCacheSize > 0 { client.DefaultCachingTransport = client.NewCachingTransport(client.SafeHttpTransport, h.config.ResponseCacheSize) } + return nil } func (h *Engine) applyTracingMiddleware(echoServer core.EchoRouter) { From c622d5e47e4fefcb55bdd9cd558741b6194bee55 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 18 Jun 2026 08:48:24 +0200 Subject: [PATCH 2/7] test(httpclient): end-to-end trust bundle verification Add a test that starts an HTTPS server with a certificate signed by a custom CA, confirms the client rejects it, then trusts it after loading the CA via ConfigureTrustBundle. Assisted by AI --- http/client/trustbundle_test.go | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/http/client/trustbundle_test.go b/http/client/trustbundle_test.go index aba57b6b99..61bade8fe2 100644 --- a/http/client/trustbundle_test.go +++ b/http/client/trustbundle_test.go @@ -22,10 +22,14 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "math/big" + "net" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -92,6 +96,64 @@ func TestConfigureTrustBundle(t *testing.T) { }) } +// TestConfigureTrustBundle_endToEnd verifies that a client backed by SafeHttpTransport can only reach an HTTPS server +// whose certificate is signed by a custom CA after that CA is loaded via ConfigureTrustBundle. +func TestConfigureTrustBundle_endToEnd(t *testing.T) { + original := SafeHttpTransport.TLSClientConfig.RootCAs + t.Cleanup(func() { SafeHttpTransport.TLSClientConfig.RootCAs = original }) + SafeHttpTransport.TLSClientConfig.RootCAs = nil + + caCert, caKey := newTestCA(t) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + server.TLS = &tls.Config{Certificates: []tls.Certificate{newTestServerCert(t, caCert, caKey)}} + server.StartTLS() + defer server.Close() + + // Before loading the CA, the server's certificate is signed by an unknown authority. + _, err := New(time.Second).Do(mustGet(t, server.URL)) + require.Error(t, err) + // Exact wording is platform-dependent (Go's verifier vs. the OS verifier), so match the common prefix. + assert.Contains(t, err.Error(), "failed to verify certificate") + + // Load the CA into the trust bundle. + dir := t.TempDir() + writePEM(t, filepath.Join(dir, "ca.pem"), caCert.Raw) + require.NoError(t, ConfigureTrustBundle(dir)) + + // Now the server is trusted. + response, err := New(time.Second).Do(mustGet(t, server.URL)) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, response.StatusCode) +} + +func mustGet(t *testing.T, url string) *http.Request { + t.Helper() + request, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + return request +} + +// newTestServerCert creates a TLS server certificate for 127.0.0.1, signed by the given CA. +func newTestServerCert(t *testing.T, ca *x509.Certificate, caKey *ecdsa.PrivateKey) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{CommonName: "127.0.0.1"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + } + der, err := x509.CreateCertificate(rand.Reader, template, ca, &key.PublicKey, caKey) + require.NoError(t, err) + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} +} + // newTestCA creates a self-signed CA certificate for use in tests. func newTestCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) { t.Helper() From 3d4e246fc228ce8ce7aeceb0e5953f9d9cef232c Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 18 Jun 2026 09:05:17 +0200 Subject: [PATCH 3/7] test: fix flaky TestModule_GetServiceActivation/activated, no VP The background refresh loop started by setupModule's Start() calls ListDIDs on the subject when its registration is due, racing with the subtest's own GetServiceActivation call and exceeding the Times(1) expectation. Disable the loop in this subtest, matching the sibling 'activated, with VP' subtest. Assisted by AI --- discovery/module_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/discovery/module_test.go b/discovery/module_test.go index bfa83817b8..9078611bf6 100644 --- a/discovery/module_test.go +++ b/discovery/module_test.go @@ -708,7 +708,10 @@ func TestModule_GetServiceActivation(t *testing.T) { assert.Nil(t, presentation) }) t.Run("activated, no VP", func(t *testing.T) { - m, ctx := setupModule(t, storageEngine) + // disable the background refresh loop, otherwise it races with this test's ListDIDs expectation + m, ctx := setupModule(t, storageEngine, func(module *Module) { + module.config.Client.RefreshInterval = 0 + }) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) next := time.Now() _ = m.store.updatePresentationRefreshTime(testServiceID, aliceSubject, nil, &next) From 2ec6e52f4c7b34bafd38029dd414a1101b03cbed Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 18 Jun 2026 09:06:04 +0200 Subject: [PATCH 4/7] docs(docker): document HTTP client trust bundle volume mount Note the default /etc/nuts/http-trust.d directory and the NUTS_HTTPCLIENT_TLS_EXTRACERTSDIR env var in the Docker volume mounts section. Assisted by AI --- docs/pages/deployment/docker.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/pages/deployment/docker.rst b/docs/pages/deployment/docker.rst index 3c853c1cdc..3fc4d281c8 100644 --- a/docs/pages/deployment/docker.rst +++ b/docs/pages/deployment/docker.rst @@ -73,6 +73,10 @@ The default working directory within the container is ``/nuts`` that provides de The container user (``18081``) has insufficient privileges by default to write to mounted directories. The required permissions can be granted by making the container user the owner of the ``data`` directory on the host. (``chown -R 18081:18081 ``) +* **/etc/nuts/http-trust.d/**: Directory with additional CA certificates (``*.pem``, ``*.crt``) that HTTP clients trust, on top of the OS CA bundle. + The image sets ``NUTS_HTTPCLIENT_TLS_EXTRACERTSDIR`` to this path by default, so mounting CA certificates here trusts them without rebuilding the image. + Files must be readable by the container user (``18081``); read-only is sufficient (``chmod -R o+r ``). + .. note:: - Nodes running the :ref:`recommended deployment ` (external storage configured for ``crypto.storage`` and ``storage.sql.connection``) that do not use did:nuts / gRPC network don't need to mount a ``data`` dir. From a4cec271350b9d20b7a6e042c86236e30d2245b6 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 18 Jun 2026 09:09:10 +0200 Subject: [PATCH 5/7] feat(httpclient): classify trusted CAs as root/intermediate in logs Log whether each loaded certificate is a root CA, intermediate CA or a non-CA certificate, mirroring core.BuildTrustStore's classification. They all share the single client RootCAs pool (a TLS client has no separate intermediates pool); the distinction is informational for audit. Assisted by AI --- http/client/trustbundle.go | 18 ++++++++++++++++- http/client/trustbundle_test.go | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/http/client/trustbundle.go b/http/client/trustbundle.go index c530e741dd..d08d401187 100644 --- a/http/client/trustbundle.go +++ b/http/client/trustbundle.go @@ -77,10 +77,26 @@ func ConfigureTrustBundle(dir string) error { WithField("file", filePath). WithField("subject", certificate.Subject.String()). WithField("fingerprint", hash.SHA256Sum(certificate.Raw).String()). - Info("Trusting additional CA certificate for HTTP clients") + WithField("type", certificateKind(certificate)). + Info("Trusting additional certificate for HTTP clients") } } SafeHttpTransport.TLSClientConfig.RootCAs = pool return nil } + +// certificateKind classifies a certificate as a root CA, intermediate CA or (non-CA) certificate for logging. +// They all end up in the same RootCAs pool: a TLS client only has a single trust anchor pool, so every loaded +// certificate is a trust anchor (intermediates presented by the server are used for chain building during the +// handshake). The classification mirrors core.BuildTrustStore and is purely informational. +func certificateKind(certificate *x509.Certificate) string { + // Version 1 certificates lack basicConstraints.IsCA, so fall back to issuer == subject for those. + if certificate.IsCA || certificate.Version == 1 { + if certificate.Subject.String() == certificate.Issuer.String() { + return "root CA" + } + return "intermediate CA" + } + return "certificate" +} diff --git a/http/client/trustbundle_test.go b/http/client/trustbundle_test.go index 61bade8fe2..abd7e4f0da 100644 --- a/http/client/trustbundle_test.go +++ b/http/client/trustbundle_test.go @@ -96,6 +96,20 @@ func TestConfigureTrustBundle(t *testing.T) { }) } +func TestCertificateKind(t *testing.T) { + rootCA, rootKey := newTestCA(t) + + t.Run("root CA", func(t *testing.T) { + assert.Equal(t, "root CA", certificateKind(rootCA)) + }) + t.Run("intermediate CA", func(t *testing.T) { + assert.Equal(t, "intermediate CA", certificateKind(newTestIntermediate(t, rootCA, rootKey))) + }) + t.Run("non-CA certificate", func(t *testing.T) { + assert.Equal(t, "certificate", certificateKind(newTestLeaf(t, rootCA, rootKey))) + }) +} + // TestConfigureTrustBundle_endToEnd verifies that a client backed by SafeHttpTransport can only reach an HTTPS server // whose certificate is signed by a custom CA after that CA is loaded via ConfigureTrustBundle. func TestConfigureTrustBundle_endToEnd(t *testing.T) { @@ -175,6 +189,27 @@ func newTestCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) { return cert, key } +// newTestIntermediate creates an intermediate CA certificate signed by the given parent CA. +func newTestIntermediate(t *testing.T, parent *x509.Certificate, parentKey *ecdsa.PrivateKey) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := &x509.Certificate{ + SerialNumber: big.NewInt(4), + Subject: pkix.Name{CommonName: "Test Intermediate CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, parent, &key.PublicKey, parentKey) + require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + // newTestLeaf creates a leaf certificate signed by the given CA. func newTestLeaf(t *testing.T, ca *x509.Certificate, caKey *ecdsa.PrivateKey) *x509.Certificate { t.Helper() From df12c34abbab8e48771c11ffac680529343d11be Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 18 Jun 2026 09:13:19 +0200 Subject: [PATCH 6/7] chore(httpclient): fix copyright year to 2026 Assisted by AI --- http/client/trustbundle.go | 2 +- http/client/trustbundle_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/http/client/trustbundle.go b/http/client/trustbundle.go index d08d401187..3e40b8344b 100644 --- a/http/client/trustbundle.go +++ b/http/client/trustbundle.go @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Nuts community + * Copyright (C) 2026 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 diff --git a/http/client/trustbundle_test.go b/http/client/trustbundle_test.go index abd7e4f0da..1fbe586c2d 100644 --- a/http/client/trustbundle_test.go +++ b/http/client/trustbundle_test.go @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Nuts community + * Copyright (C) 2026 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 From 5a03d2f8fc3a7a3f0ce5800229b5e8a50d1ce693 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 18 Jun 2026 09:14:34 +0200 Subject: [PATCH 7/7] docs: regenerate config docs (make cli-docs) Assisted by AI --- README.rst | 3 +++ docs/pages/deployment/server_options.rst | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 31d1448529..1343b9080f 100644 --- a/README.rst +++ b/README.rst @@ -186,8 +186,10 @@ The following options can be configured on the server: url Public facing URL of the server (required). Must be HTTPS when strictmode is set. verbosity info Log level (trace, debug, info, warn, error) httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. + httpclient.tls.extracertsdir Directory containing additional CA certificates (*.pem, *.crt) that HTTP clients trust, on top of the OS CA bundle. When set, the directory must exist. **Auth** auth.authorizationendpoint.enabled false enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature. + auth.experimental.jwtbearerclient false enables the experimental RFC 7523 jwt-bearer two-VP token request flow. While disabled (the default), requests carrying a service-provider subject identifier are rejected. Subject to change without notice. **Crypto** crypto.storage Storage to use, 'fs' for file system (for development purposes), 'vaultkv' for HashiCorp Vault KV store, 'azure-keyvault' for Azure Key Vault, 'external' for an external backend (deprecated). crypto.azurekv.hsm false Whether to store the key in a hardware security module (HSM). If true, the Azure Key Vault must be configured for HSM usage. Default: false @@ -240,6 +242,7 @@ The following options can be configured on the server: tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'. **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. + policy.authzen.endpoint Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'; the node refuses to start if such a profile is configured but this flag is empty. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================ Options specific for ``did:nuts``/gRPC diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index e63b21eb3a..d7830e855d 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -71,5 +71,5 @@ tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'. **policy** policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping. - policy.authzen.endpoint Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'. + policy.authzen.endpoint Base URL of the AuthZen PDP endpoint. Required when any credential profile uses scope_policy 'dynamic'; the node refuses to start if such a profile is configured but this flag is empty. ======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================