Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Changelog for NeoFS Node

### Changed
- SNs exchange TLS certificates on inter-node connections (#4097)
- SNs no longer sign TTL=1 requests over mutually authenticated inter-node connections (#4100)

### Removed

Expand Down
11 changes: 9 additions & 2 deletions cmd/neofs-node/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/nspcc-dev/neofs-node/cmd/neofs-node/config"
grpcconfig "github.com/nspcc-dev/neofs-node/cmd/neofs-node/config/grpc"
"github.com/nspcc-dev/neofs-node/pkg/network/peerauth"
"github.com/nspcc-dev/neofs-sdk-go/object"
iprotobuf "github.com/nspcc-dev/neofs-sdk-go/proto/protobuf"
"go.uber.org/zap"
Expand Down Expand Up @@ -197,14 +198,20 @@ func buildSingleGRPCServer(c *cfg, sc grpcconfig.GRPC, maxRecvMsgSizeOpt grpc.Se

// read certificate from disk on each handshake to pick up renewals automatically.
creds := credentials.NewTLS(&tls.Config{
GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) {
GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("reload TLS certificate: %w", err)
}
if hello.ServerName == peerauth.TLSServerName {
return &tls.Config{
Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequireAnyClientCert,
VerifyPeerCertificate: verifyInterNodePeer(c),
}, nil
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequestClientCert,
}, nil
},
})
Expand Down
36 changes: 36 additions & 0 deletions cmd/neofs-node/mtls.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,48 @@
package main

import (
"bytes"
"crypto/tls"
"crypto/x509"
"fmt"

grpcconfig "github.com/nspcc-dev/neofs-node/cmd/neofs-node/config/grpc"
"github.com/nspcc-dev/neofs-node/pkg/network/peerauth"
"github.com/nspcc-dev/neofs-sdk-go/netmap"
"go.uber.org/zap"
)

func verifyInterNodePeer(c *cfg) func([][]byte, [][]*x509.Certificate) error {
return func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
pub, err := peerauth.CertificatePublicKeyFromRaw(rawCerts)
if err != nil {
return err
}
if isNetmapNode(c, pub) {
return nil
}
c.log.Warn("reject inter-node TLS peer absent from network map", zap.Binary("public key", pub))
return fmt.Errorf("TLS peer is not a network map node")
}
}

func isNetmapNode(c *cfg, pub []byte) bool {
v := c.netMap.Load()
if v == nil {
return false
}
nm, ok := v.(netmap.NetMap)
if !ok {
return false
}
for _, node := range nm.Nodes() {
if bytes.Equal(node.PublicKey(), pub) {
return true
}
}
return false
}

func clientCertificateProvider(cfgs []grpcconfig.GRPC) func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
for i := range cfgs {
if !cfgs[i].TLS.Enabled {
Expand Down
44 changes: 44 additions & 0 deletions cmd/neofs-node/mtls_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
package main

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"math/big"
"testing"
"time"

grpcconfig "github.com/nspcc-dev/neofs-node/cmd/neofs-node/config/grpc"
"github.com/nspcc-dev/neofs-node/pkg/network/peerauth"
"github.com/nspcc-dev/neofs-sdk-go/netmap"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)

func TestClientCertificateProvider(t *testing.T) {
Expand All @@ -28,3 +37,38 @@ func TestClientCertificateProvider(t *testing.T) {
_, err = provider(nil)
require.ErrorContains(t, err, "client-certificate")
}

func TestVerifyInterNodePeer(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)

var node netmap.NodeInfo
node.SetPublicKey(pub.Bytes())
var nm netmap.NetMap
nm.SetNodes([]netmap.NodeInfo{node})
c := &cfg{internals: internals{log: zap.NewNop()}}
c.netMap.Store(nm)

require.NoError(t, verifyInterNodePeer(c)([][]byte{cert.Raw}, nil))

otherKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
otherCert := testTLSCertificate(t, otherKey)
require.ErrorContains(t, verifyInterNodePeer(c)([][]byte{otherCert.Raw}, nil), "not a network map node")
}

func testTLSCertificate(t *testing.T, key *ecdsa.PrivateKey) *x509.Certificate {
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
require.NoError(t, err)
cert, err := x509.ParseCertificate(der)
require.NoError(t, err)
return cert
}
4 changes: 4 additions & 0 deletions cmd/neofs-node/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,10 @@ type reputationClient struct {
cons *reputationClientConstructor
}

func (c *reputationClient) IsMutuallyAuthenticated() bool {
return clientcore.IsMutuallyAuthenticated(c.MultiAddressClient)
}

func (c *reputationClient) submitResult(err error) {
currEpoch := c.cons.netState.CurrentEpoch()
sat := err == nil
Expand Down
1 change: 1 addition & 0 deletions config/example/node.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ grpc:
conn_limit: 1 # connection limits; exceeding connection will not be declined, just blocked before active number decreases or client timeouts
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

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ require (
github.com/nspcc-dev/neo-go v0.121.0
github.com/nspcc-dev/neofs-api-go/v2 v2.14.1-0.20240827150555-5ce597aa14ea
github.com/nspcc-dev/neofs-contract v0.26.1
github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.21
github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.21.0.20260730184513-bfe4935dbbd5
github.com/nspcc-dev/tzhash v1.8.4
github.com/panjf2000/ants/v2 v2.11.5
github.com/prometheus/client_golang v1.23.2
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ github.com/nspcc-dev/neofs-api-go/v2 v2.14.1-0.20240827150555-5ce597aa14ea h1:mK
github.com/nspcc-dev/neofs-api-go/v2 v2.14.1-0.20240827150555-5ce597aa14ea/go.mod h1:YzhD4EZmC9Z/PNyd7ysC7WXgIgURc9uCG1UWDeV027Y=
github.com/nspcc-dev/neofs-contract v0.26.1 h1:7Ii7Q4L3au408LOsIWKiSgfnT1g8G9jo3W7381d41T8=
github.com/nspcc-dev/neofs-contract v0.26.1/go.mod h1:pevVF9OWdEN5bweKxOu6ryZv9muCEtS1ppzYM4RfBIo=
github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.21 h1:6LPpzMvEn8Y3mdOFHFtcrWC5GU1QGr5I8A701YufMt0=
github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.21/go.mod h1:cdLGU2E3f13UVE9nkHus171POaf2O4YyeXenUxQnGyQ=
github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.21.0.20260730184513-bfe4935dbbd5 h1:VVdNinqcXM0Om2U69dZnpEY3WKMjCROG0hJOViC4N5Q=
github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.21.0.20260730184513-bfe4935dbbd5/go.mod h1:cdLGU2E3f13UVE9nkHus171POaf2O4YyeXenUxQnGyQ=
github.com/nspcc-dev/rfc6979 v0.2.4 h1:NBgsdCjhLpEPJZqmC9rciMZDcSY297po2smeaRjw57k=
github.com/nspcc-dev/rfc6979 v0.2.4/go.mod h1:86ylDw6Kss+P6v4QAJqo1Sp3mC0/Zr9G97xSjQ9TuFg=
github.com/nspcc-dev/tzhash v1.8.4 h1:lvuPGWsqEo9dVEvo/kdNLKv/Cy0yxRs9z5hJp8VcBuo=
Expand Down
7 changes: 7 additions & 0 deletions pkg/core/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,10 @@ type MultiAddressClient interface {
// this client (min(server, client) effectively).
APIVersion() *protorefs.Version
}

// IsMutuallyAuthenticated reports whether all connections of c use mutual TLS.
// Clients that do not expose this property are treated as unauthenticated.
func IsMutuallyAuthenticated(c any) bool {
x, ok := c.(interface{ IsMutuallyAuthenticated() bool })
return ok && x.IsMutuallyAuthenticated()
}
79 changes: 68 additions & 11 deletions pkg/network/cache/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import (
"maps"
"slices"
"sync"
"sync/atomic"
"time"

"github.com/nspcc-dev/neo-go/pkg/crypto/keys"
igrpc "github.com/nspcc-dev/neofs-node/internal/grpc"
"github.com/nspcc-dev/neofs-node/internal/uriutil"
clientcore "github.com/nspcc-dev/neofs-node/pkg/core/client"
"github.com/nspcc-dev/neofs-node/pkg/network"
"github.com/nspcc-dev/neofs-node/pkg/network/peerauth"
"github.com/nspcc-dev/neofs-sdk-go/client"
cid "github.com/nspcc-dev/neofs-sdk-go/container/id"
neofscrypto "github.com/nspcc-dev/neofs-sdk-go/crypto"
Expand Down Expand Up @@ -157,6 +159,7 @@ func (x *Clients) syncWithNetmapSN(ctx context.Context, sn netmap.NodeInfo) erro
if slices.Contains(as, ma) {
return false
}
delete(conns.mtls, ma)
if err := c.Close(); err != nil {
x.log.Info("failed to close connection to the SN address no longer present in the new network map",
zap.String("address", ma), zap.Error(err))
Expand All @@ -172,13 +175,15 @@ func (x *Clients) syncWithNetmapSN(ctx context.Context, sn netmap.NodeInfo) erro
continue
}
x.log.Info("initializing connection to new SN address in the new network map...", zap.String("address", ma))
c, _, err := x.initConnection(ctx, pub, ma)
mtls := new(atomic.Bool)
c, _, err := x.initConnection(ctx, pub, ma, mtls)
if err != nil {
x.log.Info("failed to init connection to new SN address in the new network map",
zap.String("address", ma), zap.Error(err))
continue
}
conns.m[ma] = c
conns.mtls[ma] = mtls
x.log.Info("connection to new SN address in the new network map successfully initialized", zap.String("address", ma))
}

Expand All @@ -187,12 +192,14 @@ func (x *Clients) syncWithNetmapSN(ctx context.Context, sn netmap.NodeInfo) erro

func (x *Clients) initConnections(ctx context.Context, pub []byte, addrs iter.Seq[string]) (*connections, error) {
m := make(map[string]*client.Client)
mtls := make(map[string]*atomic.Bool)
l := x.log.With(zap.String("public key", hex.EncodeToString(pub)))
var ver *protorefs.Version

for s := range addrs {
l.Info("initializing connection to the SN...", zap.String("address", s))
c, v, err := x.initConnection(ctx, pub, s)
endpointMTLS := new(atomic.Bool)
c, v, err := x.initConnection(ctx, pub, s, endpointMTLS)
if err != nil {
// TODO: if at least one address is OK, SN can be operational
for cl := range maps.Values(m) {
Expand All @@ -205,17 +212,20 @@ func (x *Clients) initConnections(ctx context.Context, pub []byte, addrs iter.Se
}
l.Info("connection to the SN successfully initialized", zap.String("address", s))
m[s] = c
mtls[s] = endpointMTLS
}
var hexKey = hex.EncodeToString(pub)
return &connections{
log: x.log.With(zap.String("SN public key", hexKey)),
nodeID: hexKey,
m: m,
apiVersion: ver,
log: x.log.With(zap.String("SN public key", hexKey)),
nodeID: hexKey,
hasClientCertificate: x.getClientCertificate != nil,
m: m,
mtls: mtls,
apiVersion: ver,
}, nil
}

func (x *Clients) initConnection(ctx context.Context, pub []byte, uri string) (*client.Client, *protorefs.Version, error) {
func (x *Clients) initConnection(ctx context.Context, pub []byte, uri string, mutuallyAuthenticated *atomic.Bool) (*client.Client, *protorefs.Version, error) {
// FIXME: pending removal in #3982.
var a network.Address
if err := a.FromString(uri); err != nil {
Expand All @@ -232,7 +242,17 @@ func (x *Clients) initConnection(ctx context.Context, pub []byte, uri string) (*
if err != nil {
return nil, nil, fmt.Errorf("parse node public key: %w", err)
}
transportCreds = credentials.NewTLS(newNodeTLSConfig((*ecdsa.PublicKey)(expectedKey), x.getClientCertificate))
getClientCertificate := x.getClientCertificate
if getClientCertificate != nil && mutuallyAuthenticated != nil {
getClientCertificate = func(info *tls.CertificateRequestInfo) (*tls.Certificate, error) {
cert, err := x.getClientCertificate(info)
if err == nil && cert != nil {
mutuallyAuthenticated.Store(true)
}
return cert, err
}
}
transportCreds = credentials.NewTLS(newNodeTLSConfig((*ecdsa.PublicKey)(expectedKey), getClientCertificate))
} else {
transportCreds = insecure.NewCredentials()
}
Expand All @@ -256,6 +276,11 @@ func (x *Clients) initConnection(ctx context.Context, pub []byte, uri string) (*
_ = grpcConn.Close()
return res, nil, fmt.Errorf("init NeoFS API client from gRPC client conn: %w", err)
}
if mutuallyAuthenticated != nil {
res.SetLocalRequestSigningStatus(func() bool {
return !mutuallyAuthenticated.Load()
})
}

ctx, cancel := context.WithTimeout(ctx, x.streamMsgTimeout)
defer cancel()
Expand Down Expand Up @@ -284,7 +309,7 @@ func (x *Clients) initConnection(ctx context.Context, pub []byte, uri string) (*
}

func newNodeTLSConfig(expectedKey *ecdsa.PublicKey, getClientCertificate func(*tls.CertificateRequestInfo) (*tls.Certificate, error)) *tls.Config {
return &tls.Config{
cfg := &tls.Config{
InsecureSkipVerify: true,
GetClientCertificate: getClientCertificate,
VerifyConnection: func(state tls.ConnectionState) error {
Expand All @@ -306,17 +331,49 @@ func newNodeTLSConfig(expectedKey *ecdsa.PublicKey, getClientCertificate func(*t
return nil
},
}
if getClientCertificate != nil {
cfg.ServerName = peerauth.TLSServerName
}
return cfg
}

type connections struct {
log *zap.Logger
nodeID string
log *zap.Logger
nodeID string
hasClientCertificate bool

mtx sync.RWMutex
m map[string]*client.Client // keys are multiaddrs
mtls map[string]*atomic.Bool
apiVersion *protorefs.Version
}

func (x *connections) IsMutuallyAuthenticated() bool {
if !x.hasClientCertificate {
return false
}

x.mtx.RLock()
defer x.mtx.RUnlock()
if len(x.m) == 0 {
return false
}
for uri := range x.m {
if authenticated, ok := x.mtls[uri]; !ok || !authenticated.Load() {
return false
}
var a network.Address
if err := a.FromString(uri); err != nil {
return false
}
_, withTLS, err := uriutil.Parse(a.URIAddr())
if err != nil || !withTLS {
return false
}
}
return true
}

func (x *connections) closeAll() {
for ma, c := range x.all {
if err := c.Close(); err != nil {
Expand Down
Loading
Loading