Skip to content
Open
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions core/server_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.")
Expand Down
12 changes: 12 additions & 0 deletions core/server_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions docs/pages/deployment/docker.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 </path/to/host/data-dir>``)

* **/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 </path/to/host/ca-dir>``).

.. note::

- Nodes running the :ref:`recommended deployment <nuts-node-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.
Expand Down
3 changes: 3 additions & 0 deletions docs/pages/deployment/server_options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
* - 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**
-
-
Expand Down
102 changes: 102 additions & 0 deletions http/client/trustbundle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* 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
* 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 client

import (
"crypto/x509"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/nuts-foundation/nuts-node/v6/core"
"github.com/nuts-foundation/nuts-node/v6/crypto/hash"
"github.com/nuts-foundation/nuts-node/v6/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()).
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"
}
Loading
Loading