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
16 changes: 15 additions & 1 deletion cmd/neofs-node/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package main

import (
"context"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"io/fs"
Expand Down Expand Up @@ -33,6 +35,7 @@ import (
"github.com/nspcc-dev/neofs-node/pkg/morph/event"
"github.com/nspcc-dev/neofs-node/pkg/network"
"github.com/nspcc-dev/neofs-node/pkg/network/cache"
"github.com/nspcc-dev/neofs-node/pkg/network/peerauth"
"github.com/nspcc-dev/neofs-node/pkg/services/control"
controlSvc "github.com/nspcc-dev/neofs-node/pkg/services/control/server"
"github.com/nspcc-dev/neofs-node/pkg/services/meta"
Expand Down Expand Up @@ -158,6 +161,10 @@ type shared struct {
putClientCache *cache.Clients
localAddr network.AddressGroup

// tlsCert is the node's self-signed certificate built from its identity
// private key for inter-node mTLS handshakes.
tlsCert tls.Certificate

ownerIDFromKey user.ID // user ID calculated from key

// current network map
Expand Down Expand Up @@ -416,13 +423,19 @@ func initCfg(appCfg *config.Config) *cfg {
fatalOnErr(err)

basicSharedConfig := initBasics(c, key, persistate)

tlsCert, err := peerauth.GenerateSelfSignedCert(&key.PrivateKey)
fatalOnErr(err)
c.log.Info("mTLS: generated self-signed certificate from node identity key",
zap.String("pubkey", hex.EncodeToString(key.PublicKey().Bytes())))

streamTimeout := appCfg.APIClient.StreamTimeout
minConnTimeout := appCfg.APIClient.MinConnectionTime
pingInterval := appCfg.APIClient.PingInterval
pingTimeout := appCfg.APIClient.PingTimeout
newClientCache := func(scope string) *cache.Clients {
return cache.NewClients(c.log.With(zap.String("scope", scope)), &buffers, streamTimeout,
minConnTimeout, pingInterval, pingTimeout, neofsecdsa.Signer(key.PrivateKey))
minConnTimeout, pingInterval, pingTimeout, neofsecdsa.Signer(key.PrivateKey), tlsCert)
}
c.shared = shared{
basics: basicSharedConfig,
Expand All @@ -432,6 +445,7 @@ func initCfg(appCfg *config.Config) *cfg {
putClientCache: newClientCache("put"),
persistate: persistate,
privateTokenStore: persistate,
tlsCert: tlsCert,
}
c.cfgBalance = cfgBalance{
parsers: make(map[event.Type]event.NotificationParser),
Expand Down
28 changes: 7 additions & 21 deletions cmd/neofs-node/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import (
"go.uber.org/zap"
"golang.org/x/net/netutil"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/resolver"
)
Expand Down Expand Up @@ -185,30 +184,17 @@ func buildSingleGRPCServer(c *cfg, sc grpcconfig.GRPC, maxRecvMsgSizeOpt grpc.Se
serverOpts = append(serverOpts, maxRecvMsgSizeOpt)
}

tlsCfg := sc.TLS

if tlsCfg.Key != "" {
certFile, keyFile := tlsCfg.Certificate, tlsCfg.Key

if _, err := tls.LoadX509KeyPair(certFile, keyFile); err != nil {
if sc.TLS.Key != "" {
if _, err := tls.LoadX509KeyPair(sc.TLS.Certificate, sc.TLS.Key); err != nil {
c.log.Error("could not read certificate from file", zap.Error(err))
return nil, nil, err
}

// read certificate from disk on each handshake to pick up renewals automatically.
creds := credentials.NewTLS(&tls.Config{
GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("reload TLS certificate: %w", err)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
}, nil
},
})

serverOpts = append(serverOpts, grpc.Creds(creds))
serverOpts = append(serverOpts, grpc.Creds(newNodeAuthCreds(c, sc.TLS.Certificate, sc.TLS.Key)))
c.log.Info("gRPC endpoint serves clients (server-side TLS) and inter-node mTLS", zap.String("endpoint", sc.Endpoint))
} else {
serverOpts = append(serverOpts, grpc.Creds(newNodeAuthCreds(c, "", "")))
c.log.Info("gRPC endpoint serves clients (plain) and inter-node mTLS", zap.String("endpoint", sc.Endpoint))
}

lis, err := net.Listen("tcp", sc.Endpoint)
Expand Down
174 changes: 174 additions & 0 deletions cmd/neofs-node/mtls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package main

import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt"
"io"
"net"

"github.com/nspcc-dev/neofs-node/pkg/network/peerauth"
"github.com/nspcc-dev/neofs-sdk-go/netmap"
"go.uber.org/zap"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)

// tlsRecordTypeHandshake is the first byte (ContentType) of a TLS handshake
// record.
const tlsRecordTypeHandshake = 0x16

// nodeAuthCreds are gRPC server transport credentials that serve regular clients
// and inter-node mTLS connections on the same port.
type nodeAuthCreds struct {
tls credentials.TransportCredentials
plain credentials.TransportCredentials
}

// newNodeAuthCreds builds the shared-port credentials. adminCertFile and
// adminKeyFile are the optional server-side TLS certificate served to regular
// clients; when empty, only plain clients and inter-node mTLS are expected.
func newNodeAuthCreds(c *cfg, adminCertFile, adminKeyFile string) credentials.TransportCredentials {
return &nodeAuthCreds{
tls: credentials.NewTLS(serverTLSConfig(c, adminCertFile, adminKeyFile)),
plain: insecure.NewCredentials(),
}
}

func (a *nodeAuthCreds) ServerHandshake(raw net.Conn) (net.Conn, credentials.AuthInfo, error) {
first := make([]byte, 1)
if _, err := io.ReadFull(raw, first); err != nil {
return nil, nil, fmt.Errorf("peek first connection byte: %w", err)
}
conn := &prefixConn{Conn: raw, prefix: first}
if first[0] == tlsRecordTypeHandshake {
return a.tls.ServerHandshake(conn)
}
return a.plain.ServerHandshake(conn)
}

func (a *nodeAuthCreds) ClientHandshake(ctx context.Context, authority string, raw net.Conn) (net.Conn, credentials.AuthInfo, error) {
return a.plain.ClientHandshake(ctx, authority, raw)
}

func (a *nodeAuthCreds) Info() credentials.ProtocolInfo { return a.tls.Info() }

func (a *nodeAuthCreds) Clone() credentials.TransportCredentials {
return &nodeAuthCreds{tls: a.tls.Clone(), plain: a.plain.Clone()}
}

func (a *nodeAuthCreds) OverrideServerName(string) error { return nil }

// prefixConn is a net.Conn that replays a number of already-read bytes before
// continuing with the underlying connection. It lets the handshake sniffer put
// the peeked byte back so the chosen handshaker sees the full stream.
type prefixConn struct {
net.Conn
prefix []byte
}

func (c *prefixConn) Read(p []byte) (int, error) {
if len(c.prefix) > 0 {
n := copy(p, c.prefix)
c.prefix = c.prefix[n:]
return n, nil
}
return c.Conn.Read(p)
}

func serverTLSConfig(c *cfg, adminCertFile, adminKeyFile string) *tls.Config {
node := nodeServerTLSConfig(c)
cli := clientServerTLSConfig(c)
return &tls.Config{
MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2"},
GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
if hello.ServerName == peerauth.TLSServerName {
return node, nil
}
if hello.ServerName == peerauth.ClientTLSServerName {
return cli, nil
}
if adminKeyFile == "" {
return nil, fmt.Errorf("no server TLS certificate configured for client %q", hello.ServerName)
}
cert, err := tls.LoadX509KeyPair(adminCertFile, adminKeyFile)
if err != nil {
return nil, fmt.Errorf("reload TLS certificate: %w", err)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2"},
}, nil
},
}
}

// nodeServerTLSConfig builds the TLS sub-configuration used for inter-node mTLS
// connections. Only fellow storage nodes are expected here: the verifier
// rejects any peer whose key is not in the current network map.
func nodeServerTLSConfig(c *cfg) *tls.Config {
return &tls.Config{
Certificates: []tls.Certificate{c.tlsCert},
ClientAuth: tls.RequireAnyClientCert,
VerifyPeerCertificate: makeNetmapVerifier(c),
MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2"},
}
}

// clientServerTLSConfig is the client-mTLS sub-config: the node serves its
// identity cert and requires a client cert but does not check it against netmap.
func clientServerTLSConfig(c *cfg) *tls.Config {
return &tls.Config{
Certificates: []tls.Certificate{c.tlsCert},
ClientAuth: tls.RequireAnyClientCert,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2"},
}
}

// makeNetmapVerifier returns a TLS VerifyPeerCertificate hook that accepts a
// client certificate only if its public key belongs to a node in the current
// network map.
func makeNetmapVerifier(c *cfg) func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
return func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
pub, err := peerauth.PeerPubKey(rawCerts)
if err != nil {
return fmt.Errorf("extract peer pubkey: %w", err)
}

if !isNetmapNode(c, pub) {
c.log.Warn("mTLS: rejecting inter-node peer absent from network map",
zap.String("pubkey", hex.EncodeToString(pub)))
return fmt.Errorf("peer key %s is not a network map node", hex.EncodeToString(pub))
}

c.log.Info("mTLS: peer authenticated as known SN",
zap.String("pubkey", hex.EncodeToString(pub)))
return nil
}
}

// isNetmapNode reports whether pub is the public key of some node in the
// current network map snapshot.
func isNetmapNode(c *cfg, pub []byte) bool {
val := c.netMap.Load()
if val == nil {
return false
}
nm, ok := val.(netmap.NetMap)
if !ok {
return false
}
for _, n := range nm.Nodes() {
if bytes.Equal(n.PublicKey(), pub) {
return true
}
}
return false
}
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.119.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.19
github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.19.0.20260609152257-28eff55331d9
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.19 h1:GHMk5bO+gL2uh7tY8r1FTyjtZ+izHTF2WbiNO4pkelo=
github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.19/go.mod h1:KtAAolqjEdTNZ8T57FvpWsE/i9/Bpj6wuRPeUs/U85I=
github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.19.0.20260609152257-28eff55331d9 h1:gv90Jk9SUcINRElpd+W2ROOJNaV7AQ+F7JfL/ZwYbdI=
github.com/nspcc-dev/neofs-sdk-go v1.0.0-rc.19.0.20260609152257-28eff55331d9/go.mod h1:KtAAolqjEdTNZ8T57FvpWsE/i9/Bpj6wuRPeUs/U85I=
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
Loading