diff --git a/CHANGELOG.md b/CHANGELOG.md index 113485aee9..31e8de389e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Changelog for NeoFS Node - SN responds with server's API version only if it is older than client (#4110) - SNs no longer sign TTL=1 requests over mutually authenticated inter-node connections (#4100) - Optimized GRPC read/write bufferring (#4130) +- TLS key of SN is now always read from the node wallet instead of configuration (#4131) ### Removed - Session token storage migration (#4124) @@ -37,6 +38,8 @@ Metabase migrations for versions 8 and 9 introduced in NeoFS node 0.49.0 and 0.51.0 were removed from this release, upgrade to 0.55.0 first if using earlier versions or resynchronize metabases. +Remove `grpc.tls.key` from the SN configuration: TLS keys are now always read from the node wallet. + ## [0.55.0] - 2026-07-31 - Moido ### Added diff --git a/cmd/neofs-adm/internal/modules/storagecfg/config.go b/cmd/neofs-adm/internal/modules/storagecfg/config.go index 4a0a32c3a3..8f84944921 100644 --- a/cmd/neofs-adm/internal/modules/storagecfg/config.go +++ b/cmd/neofs-adm/internal/modules/storagecfg/config.go @@ -20,7 +20,6 @@ grpc: tls:{{if .TLSCert}} enabled: true # enable TLS for a gRPC connection (min version is TLS 1.2) certificate: {{ .TLSCert }} # path to TLS certificate - key: {{ .TLSKey }} # path to TLS key {{- else }} enabled: false # disable TLS for a gRPC connection {{- end}} diff --git a/cmd/neofs-adm/internal/modules/storagecfg/root.go b/cmd/neofs-adm/internal/modules/storagecfg/root.go index 923af2bf10..129828f7e6 100644 --- a/cmd/neofs-adm/internal/modules/storagecfg/root.go +++ b/cmd/neofs-adm/internal/modules/storagecfg/root.go @@ -63,7 +63,6 @@ type config struct { ControlEndpoint string Endpoint string TLSCert string - TLSKey string MorphRPC []string Attribute struct { Locode string @@ -233,13 +232,6 @@ func storageConfig(cmd *cobra.Command, args []string) error { if err != nil { return err } - if c.TLSCert != "" { - c.TLSKey, err = getPath("TLS Key: ") - if err != nil { - return err - } - } - c.Relay, err = getConfirmation(false, "Use node as a relay? yes/[no]: ") if err != nil { return err diff --git a/cmd/neofs-node/config.go b/cmd/neofs-node/config.go index 9f3288a61b..869cf2e2e3 100644 --- a/cmd/neofs-node/config.go +++ b/cmd/neofs-node/config.go @@ -406,7 +406,7 @@ func initCfg(appCfg *config.Config) *cfg { minConnTimeout := appCfg.APIClient.MinConnectionTime pingInterval := appCfg.APIClient.PingInterval pingTimeout := appCfg.APIClient.PingTimeout - getClientCertificate := clientCertificateProvider(appCfg.GRPC, key.PublicKey().Bytes()) + getClientCertificate := clientCertificateProvider(appCfg.GRPC, &key.PrivateKey) // Validate the certificate before serving requests so that it is trusted by peer SNs. if getClientCertificate != nil { _, err = getClientCertificate(nil) diff --git a/cmd/neofs-node/config/grpc/config_test.go b/cmd/neofs-node/config/grpc/config_test.go index 374d7fdab8..ab303028af 100644 --- a/cmd/neofs-node/config/grpc/config_test.go +++ b/cmd/neofs-node/config/grpc/config_test.go @@ -32,7 +32,6 @@ func TestGRPCSection(t *testing.T) { require.NotNil(t, tls) require.Equal(t, "/path/to/cert", tls.Certificate) - require.Equal(t, "/path/to/key", tls.Key) case 1: require.Equal(t, "s02.neofs.devenv:8080", sc.Endpoint) require.Equal(t, 0, sc.ConnLimit) diff --git a/cmd/neofs-node/config/grpc/grpc.go b/cmd/neofs-node/config/grpc/grpc.go index 276b48fd9e..484ebedebd 100644 --- a/cmd/neofs-node/config/grpc/grpc.go +++ b/cmd/neofs-node/config/grpc/grpc.go @@ -12,7 +12,6 @@ type GRPC struct { type TLS struct { Enabled bool `mapstructure:"enabled"` Certificate string `mapstructure:"certificate"` - Key string `mapstructure:"key"` } // Normalize sets default values for GRPC configuration. diff --git a/cmd/neofs-node/config/validate_test.go b/cmd/neofs-node/config/validate_test.go index f2866788ae..dec7e8187c 100644 --- a/cmd/neofs-node/config/validate_test.go +++ b/cmd/neofs-node/config/validate_test.go @@ -108,7 +108,6 @@ grpc: tls: enabled: true certificate: /path/to/cert - key: /path/to/key - endpoint: s02.neofs.devenv:8080 conn_limit: -1 @@ -118,6 +117,25 @@ grpc: `, wantErr: false, }, + { + name: "unknown field grpc.tls.key", + config: ` +grpc: + - endpoint: s01.neofs.devenv:8080 + conn_limit: 1 + tls: + enabled: true + certificate: /path/to/cert + key: /path/to/key + + - endpoint: s02.neofs.devenv:8080 + conn_limit: -1 + tls: + enabled: false + - endpoint: s03.neofs.devenv:8080 +`, + wantErr: true, + }, { name: "unknown field grpc.key", config: ` @@ -146,7 +164,6 @@ grpc: tls: enabled: true certificate: /path/to/cert - key: /path/to/key - endpoint: s02.neofs.devenv:8080 conn_limit: -1 diff --git a/cmd/neofs-node/grpc.go b/cmd/neofs-node/grpc.go index f50ace305f..15c39b8c7e 100644 --- a/cmd/neofs-node/grpc.go +++ b/cmd/neofs-node/grpc.go @@ -34,7 +34,6 @@ func (s grpcServerSnapshot) unchanged(other grpcServerSnapshot) bool { return s.ConnLimit == other.ConnLimit && s.TLS.Enabled == other.TLS.Enabled && s.TLS.Certificate == other.TLS.Certificate && - s.TLS.Key == other.TLS.Key && s.certFingerprint == other.certFingerprint } @@ -45,25 +44,19 @@ func writeGRPCConfig(c *config.Config) grpcConfigSnapshot { for i, sc := range c.GRPC { snap[i] = grpcServerSnapshot{ GRPC: sc, - certFingerprint: tlsCertFingerprint(sc.TLS.Certificate, sc.TLS.Key), + certFingerprint: tlsCertFingerprint(sc.TLS.Certificate), } } return snap } -func tlsCertFingerprint(certFile, keyFile string) string { - if keyFile == "" { +func tlsCertFingerprint(certFile string) string { + if certFile == "" { return "" } h := sha256.New() - err := hashFileLimited(h, certFile, maxTLSFingerprintFileBytes) - if err != nil { - return "" - } - _, _ = h.Write([]byte{0}) - err = hashFileLimited(h, keyFile, maxTLSFingerprintFileBytes) - if err != nil { + if err := hashFileLimited(h, certFile, maxTLSFingerprintFileBytes); err != nil { return "" } @@ -188,10 +181,10 @@ func buildSingleGRPCServer(c *cfg, sc grpcconfig.GRPC, maxRecvMsgSizeOpt grpc.Se tlsCfg := sc.TLS - if tlsCfg.Key != "" { - certFile, keyFile := tlsCfg.Certificate, tlsCfg.Key + if tlsCfg.Enabled { + certFile := tlsCfg.Certificate - if _, err := tls.LoadX509KeyPair(certFile, keyFile); err != nil { + if _, err := loadTLSCertificate(certFile, &c.key.PrivateKey); err != nil { c.log.Error("could not read certificate from file", zap.Error(err)) return nil, nil, err } @@ -199,7 +192,7 @@ func buildSingleGRPCServer(c *cfg, sc grpcconfig.GRPC, maxRecvMsgSizeOpt grpc.Se // read certificate from disk on each handshake to pick up renewals automatically. creds := trustedPeerTLSCredentials(&tls.Config{ GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) { - cert, err := tls.LoadX509KeyPair(certFile, keyFile) + cert, err := loadTLSCertificate(certFile, &c.key.PrivateKey) if err != nil { return nil, fmt.Errorf("reload TLS certificate: %w", err) } diff --git a/cmd/neofs-node/mtls.go b/cmd/neofs-node/mtls.go index 5a101ddc84..0096f27e2f 100644 --- a/cmd/neofs-node/mtls.go +++ b/cmd/neofs-node/mtls.go @@ -1,11 +1,14 @@ package main import ( - "bytes" + "crypto/ecdsa" "crypto/tls" - "errors" + "crypto/x509" + "encoding/pem" "fmt" + "io" "net" + "os" grpcconfig "github.com/nspcc-dev/neofs-node/cmd/neofs-node/config/grpc" "github.com/nspcc-dev/neofs-node/pkg/network/peerauth" @@ -41,21 +44,18 @@ func (x trustedPeerCredentials) ServerHandshake(conn net.Conn) (net.Conn, creden return conn, trustedInfo, nil } -func clientCertificateProvider(cfgs []grpcconfig.GRPC, expectedPublicKey []byte) func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { +func clientCertificateProvider(cfgs []grpcconfig.GRPC, key *ecdsa.PrivateKey) func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { for i := range cfgs { if !cfgs[i].TLS.Enabled { continue } - certFile, keyFile := cfgs[i].TLS.Certificate, cfgs[i].TLS.Key + certFile := cfgs[i].TLS.Certificate return func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { - cert, err := tls.LoadX509KeyPair(certFile, keyFile) + cert, err := loadTLSCertificate(certFile, key) if err != nil { return nil, fmt.Errorf("reload TLS client certificate: %w", err) } - if err := verifyTLSCertificatePublicKey(&cert, expectedPublicKey); err != nil { - return nil, err - } return &cert, nil } } @@ -63,13 +63,36 @@ func clientCertificateProvider(cfgs []grpcconfig.GRPC, expectedPublicKey []byte) return nil } -func verifyTLSCertificatePublicKey(cert *tls.Certificate, expected []byte) error { - pub, err := peerauth.CertificatePublicKeyFromRaw(cert.Certificate) +const maxTLSCertificateFileBytes = 16 << 10 // 16 KB + +// loadTLSCertificate reads a PEM-encoded certificate chain and pairs it with +// the node's private key. The leaf certificate must be issued for the node key. +func loadTLSCertificate(certFile string, key *ecdsa.PrivateKey) (tls.Certificate, error) { + certPEM, err := readTLSCertificateFile(certFile) if err != nil { - return fmt.Errorf("parse TLS client certificate public key: %w", err) + return tls.Certificate{}, err } - if !bytes.Equal(pub, expected) { - return errors.New("TLS client certificate public key differs from node public key") + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return tls.Certificate{}, fmt.Errorf("marshal node TLS private key: %w", err) } - return nil + + return tls.X509KeyPair(certPEM, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) +} + +func readTLSCertificateFile(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + data, err := io.ReadAll(io.LimitReader(f, maxTLSCertificateFileBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxTLSCertificateFileBytes { + return nil, fmt.Errorf("TLS certificate file exceeds %d bytes", maxTLSCertificateFileBytes) + } + return data, nil } diff --git a/cmd/neofs-node/mtls_test.go b/cmd/neofs-node/mtls_test.go index c98fe88367..4fc6e57711 100644 --- a/cmd/neofs-node/mtls_test.go +++ b/cmd/neofs-node/mtls_test.go @@ -1,13 +1,18 @@ package main import ( + "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "math/big" "net" + "os" + "path/filepath" "testing" "time" @@ -19,33 +24,122 @@ import ( ) func TestClientCertificateProvider(t *testing.T) { - require.Nil(t, clientCertificateProvider(nil, nil)) + key := newTLSKey(t) + require.Nil(t, clientCertificateProvider(nil, key)) provider := clientCertificateProvider([]grpcconfig.GRPC{{ TLS: grpcconfig.TLS{ Enabled: true, Certificate: "missing-certificate", - Key: "missing-key", }, - }}, nil) + }}, key) require.NotNil(t, provider) _, err := provider(nil) require.ErrorContains(t, err, "reload TLS client certificate") provider = clientCertificateProvider([]grpcconfig.GRPC{ - {TLS: grpcconfig.TLS{Enabled: false, Certificate: "ignored-certificate", Key: "ignored-key"}}, - {TLS: grpcconfig.TLS{Enabled: true, Certificate: "client-certificate", Key: "client-key"}}, - }, nil) + {TLS: grpcconfig.TLS{Enabled: false, Certificate: "ignored-certificate"}}, + {TLS: grpcconfig.TLS{Enabled: true, Certificate: "client-certificate"}}, + }, key) _, err = provider(nil) require.ErrorContains(t, err, "client-certificate") + + certFile := writeTLSCertificate(t, testCertificate(t, key)) + provider = clientCertificateProvider([]grpcconfig.GRPC{{ + TLS: grpcconfig.TLS{Enabled: true, Certificate: certFile}, + }}, key) + cert, err := provider(nil) + require.NoError(t, err) + require.Equal(t, key, cert.PrivateKey) } -func TestTrustedPeerTLSCredentials(t *testing.T) { - serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) +func TestLoadTLSCertificate(t *testing.T) { + key := newTLSKey(t) + + t.Run("certificate chain", func(t *testing.T) { + caKey := newTLSKey(t) + ca := testCertificate(t, caKey) + leaf := testCertificateSignedBy(t, key, ca, caKey) + + cert, err := loadTLSCertificate(writeTLSCertificate(t, leaf, ca), key) + require.NoError(t, err) + require.Len(t, cert.Certificate, 2) + require.Equal(t, key, cert.PrivateKey) + }) + + t.Run("different node key", func(t *testing.T) { + cert, err := loadTLSCertificate(writeTLSCertificate(t, testCertificate(t, newTLSKey(t))), key) + require.ErrorContains(t, err, "private key does not match public key") + require.Empty(t, cert) + }) + + t.Run("invalid certificate", func(t *testing.T) { + certFile := filepath.Join(t.TempDir(), "cert.pem") + require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("invalid")}), 0o600)) + cert, err := loadTLSCertificate(certFile, key) + require.ErrorContains(t, err, "malformed certificate") + require.Empty(t, cert) + }) + + t.Run("no certificate", func(t *testing.T) { + certFile := filepath.Join(t.TempDir(), "cert.pem") + require.NoError(t, os.WriteFile(certFile, []byte("not a PEM certificate"), 0o600)) + cert, err := loadTLSCertificate(certFile, key) + require.EqualError(t, err, "tls: failed to find any PEM data in certificate input") + require.Empty(t, cert) + }) + + t.Run("too large", func(t *testing.T) { + certFile := filepath.Join(t.TempDir(), "cert.pem") + require.NoError(t, os.WriteFile(certFile, make([]byte, maxTLSCertificateFileBytes+1), 0o600)) + cert, err := loadTLSCertificate(certFile, key) + require.EqualError(t, err, "TLS certificate file exceeds 16384 bytes") + require.Empty(t, cert) + }) +} + +func newTLSKey(t *testing.T) *ecdsa.PrivateKey { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - serverCert := testTLSCertificate(t, serverKey) - clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + return key +} + +func testCertificate(t *testing.T, key crypto.Signer) *x509.Certificate { + return testCertificateSignedBy(t, key, nil, key) +} + +func testCertificateSignedBy(t *testing.T, key crypto.Signer, parent *x509.Certificate, signer crypto.Signer) *x509.Certificate { + now := time.Now() + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Minute), + } + if parent == nil { + parent = tmpl + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, parent, key.Public(), signer) require.NoError(t, err) + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +func writeTLSCertificate(t *testing.T, certs ...*x509.Certificate) string { + var data []byte + for _, cert := range certs { + data = append(data, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})...) + } + path := filepath.Join(t.TempDir(), "cert.pem") + require.NoError(t, os.WriteFile(path, data, 0o600)) + return path +} + +func TestTrustedPeerTLSCredentials(t *testing.T) { + serverKey := newTLSKey(t) + serverCert := testTLSCertificate(t, serverKey) + clientKey := newTLSKey(t) clientCert := testTLSCertificate(t, clientKey) serverConn, clientConn := net.Pipe() @@ -78,19 +172,6 @@ func TestTrustedPeerTLSCredentials(t *testing.T) { require.Equal(t, (*keys.PublicKey)(&clientKey.PublicKey), authInfo.PublicKey) } -func TestVerifyTLSCertificatePublicKey(t *testing.T) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - cert := testTLSCertificate(t, key) - pub, err := peerauth.CertificatePublicKey(cert) - require.NoError(t, err) - - tlsCert := &tls.Certificate{Certificate: [][]byte{cert.Raw}} - require.NoError(t, verifyTLSCertificatePublicKey(tlsCert, pub.Bytes())) - require.ErrorContains(t, verifyTLSCertificatePublicKey(tlsCert, []byte("other key")), "differs from node public key") - require.ErrorContains(t, verifyTLSCertificatePublicKey(new(tls.Certificate), pub.Bytes()), "parse TLS client certificate public key") -} - func testTLSCertificate(t *testing.T, key *ecdsa.PrivateKey) *x509.Certificate { tmpl := &x509.Certificate{ SerialNumber: big.NewInt(1), diff --git a/cmd/neofs-node/validate.go b/cmd/neofs-node/validate.go index 54ed69f18a..19e9f93927 100644 --- a/cmd/neofs-node/validate.go +++ b/cmd/neofs-node/validate.go @@ -14,7 +14,6 @@ import ( var ( errEndpointNotSet = errors.New("empty/not set endpoint, see `grpc.endpoint` section") - errTLSKeyNotSet = errors.New("empty/not set TLS key file path, see `grpc.tls.key` section") errTLSCertNotSet = errors.New("empty/not set TLS certificate file path, see `grpc.tls.certificate` section") ) @@ -47,13 +46,8 @@ func validateConfig(c *config.Config) error { if c.GRPC[i].Endpoint == "" { return errEndpointNotSet } - if c.GRPC[i].TLS.Enabled { - if c.GRPC[i].TLS.Certificate == "" { - return errTLSCertNotSet - } - if c.GRPC[i].TLS.Key == "" { - return errTLSKeyNotSet - } + if c.GRPC[i].TLS.Enabled && c.GRPC[i].TLS.Certificate == "" { + return errTLSCertNotSet } } diff --git a/config/example/node.env b/config/example/node.env index fa4e059e94..7da3a63a12 100644 --- a/config/example/node.env +++ b/config/example/node.env @@ -37,7 +37,6 @@ NEOFS_GRPC_0_CONN_LIMIT=1 ### TLS config NEOFS_GRPC_0_TLS_ENABLED=true NEOFS_GRPC_0_TLS_CERTIFICATE=/path/to/cert -NEOFS_GRPC_0_TLS_KEY=/path/to/key ## 1 server NEOFS_GRPC_1_ENDPOINT=s02.neofs.devenv:8080 diff --git a/config/example/node.json b/config/example/node.json index 089760a696..c30189568a 100644 --- a/config/example/node.json +++ b/config/example/node.json @@ -58,8 +58,7 @@ "conn_limit": 1, "tls": { "enabled": true, - "certificate": "/path/to/cert", - "key": "/path/to/key" + "certificate": "/path/to/cert" } }, { diff --git a/config/example/node.yaml b/config/example/node.yaml index d81c5dc711..5f04c337b3 100644 --- a/config/example/node.yaml +++ b/config/example/node.yaml @@ -47,8 +47,7 @@ grpc: tls: enabled: true # use TLS for a gRPC connection (min version is TLS 1.2) # For inter-node mTLS, certificate public key must match the node key announced in the network map. - certificate: /path/to/cert # path to TLS certificate - key: /path/to/key # path to TLS key + certificate: /path/to/cert # path to TLS certificate issued for the node key - endpoint: s02.neofs.devenv:8080 # endpoint for gRPC server conn_limit: -1 # connection limits; exceeding connection will not be declined, just blocked before active number decreases or client timeouts diff --git a/config/mainnet/README.md b/config/mainnet/README.md index 77a0a4df9a..a6fae6bb16 100644 --- a/config/mainnet/README.md +++ b/config/mainnet/README.md @@ -24,5 +24,4 @@ grpc: tls: enabled: true certificate: /path/to/cert - key: /path/to/key ``` diff --git a/docs/sighup.md b/docs/sighup.md index 9348faa1ac..9e564f95ae 100644 --- a/docs/sighup.md +++ b/docs/sighup.md @@ -105,7 +105,6 @@ grpc: tls: enabled: certificate: - key: ``` During the restart there is a short period of unavailability. diff --git a/docs/storage-node-configuration.md b/docs/storage-node-configuration.md index 5caa9ea170..820412da92 100644 --- a/docs/storage-node-configuration.md +++ b/docs/storage-node-configuration.md @@ -50,7 +50,6 @@ grpc: tls: enabled: true certificate: /path/to/cert.pem - key: /path/to/key.pem - endpoint: internal.ip:8080 - endpoint: external.ip:8080 tls: @@ -67,16 +66,16 @@ element. ## `tls` subsection -The certificate and key from the first gRPC endpoint with TLS enabled are also -used as the client certificate for outgoing inter-node TLS connections. They -are reloaded for every TLS handshake, so certificate rotation does not require -restarting the node. +The certificate from the first gRPC endpoint with TLS enabled is also used as +client certificate for outgoing inter-node TLS connections. Its public key must +match the node key from the wallet; this key is used for all TLS handshakes. +Certificates are reloaded for every TLS handshake, so certificate rotation does +not require restarting the node. -| Parameter | Type | Default value | Description | -|-----------------------|----------|---------------|---------------------------------------------------------------------------| -| `enabled` | `bool` | `false` | Address that control service listener binds to. | -| `certificate` | `string` | | Path to the TLS certificate. | -| `key` | `string` | | Path to the key. | +| Parameter | Type | Default value | Description | +|---------------|----------|---------------|------------------------------------------------------------------------------| +| `enabled` | `bool` | `false` | Address that control service listener binds to. | +| `certificate` | `string` | | Path to the TLS certificate issued for the node key from the wallet. | # `pprof` section