diff --git a/cmd/neofs-node/config.go b/cmd/neofs-node/config.go index fc815bace0..e76133ee4e 100644 --- a/cmd/neofs-node/config.go +++ b/cmd/neofs-node/config.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/tls" + "encoding/hex" "errors" "fmt" "io/fs" @@ -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" @@ -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 @@ -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, @@ -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), diff --git a/cmd/neofs-node/grpc.go b/cmd/neofs-node/grpc.go index 20008e933c..d7fa0fb958 100644 --- a/cmd/neofs-node/grpc.go +++ b/cmd/neofs-node/grpc.go @@ -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" ) @@ -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) diff --git a/cmd/neofs-node/mtls.go b/cmd/neofs-node/mtls.go new file mode 100644 index 0000000000..709a66ff63 --- /dev/null +++ b/cmd/neofs-node/mtls.go @@ -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 +} diff --git a/go.mod b/go.mod index 5eddd7c285..f4f753a273 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 6c6c2cba3c..1a073da803 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/network/cache/clients.go b/pkg/network/cache/clients.go index 06e961cd28..7a63a2eece 100644 --- a/pkg/network/cache/clients.go +++ b/pkg/network/cache/clients.go @@ -3,11 +3,12 @@ package cache import ( "bytes" "context" + "crypto/tls" + "crypto/x509" "encoding/hex" "errors" "fmt" "io" - "iter" "maps" "slices" "sync" @@ -16,6 +17,7 @@ import ( "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" @@ -29,7 +31,6 @@ import ( "google.golang.org/grpc/backoff" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/keepalive" "google.golang.org/grpc/status" ) @@ -49,12 +50,13 @@ type Clients struct { mtx sync.RWMutex conns map[string]*connections // keys are public key bytes - signer neofscrypto.Signer + signer neofscrypto.Signer + tlsCert tls.Certificate } // NewClients constructs Clients initializing connection to any endpoint with // given parameters. -func NewClients(l *zap.Logger, signBufPool *sync.Pool, streamTimeout, minConnTimeout, pingInterval, pingTimeout time.Duration, signer neofscrypto.Signer) *Clients { +func NewClients(l *zap.Logger, signBufPool *sync.Pool, streamTimeout, minConnTimeout, pingInterval, pingTimeout time.Duration, signer neofscrypto.Signer, tlsCert tls.Certificate) *Clients { return &Clients{ log: l, streamMsgTimeout: streamTimeout, @@ -64,6 +66,7 @@ func NewClients(l *zap.Logger, signBufPool *sync.Pool, streamTimeout, minConnTim pingTimeout: pingTimeout, conns: make(map[string]*connections), signer: signer, + tlsCert: tlsCert, } } @@ -97,7 +100,7 @@ func (x *Clients) Get(ctx context.Context, info netmap.NodeInfo) (clientcore.Mul return c, nil } - c, err := x.initConnections(ctx, info.PublicKey(), info.NetworkEndpoints()) + c, err := x.initConnections(ctx, info.PublicKey(), slices.Collect(info.NetworkEndpoints())) if err != nil { return nil, fmt.Errorf("init connections: %w", err) } @@ -110,10 +113,14 @@ func (x *Clients) SyncWithNewNetmap(ctx context.Context, sns []netmap.NodeInfo, x.mtx.Lock() defer x.mtx.Unlock() + localPub, _ := peerauth.CompressedPubKey(x.tlsCert.Leaf) for i := range sns { if i == local { continue } + if localPub != nil && bytes.Equal(sns[i].PublicKey(), localPub) { + continue + } if err := x.syncWithNetmapSN(ctx, sns[i]); err != nil { x.log.Warn("failed to sync connection cache with SN from the new network map, skip", zap.String("pub", hex.EncodeToString(sns[i].PublicKey())), zap.Error(err)) @@ -136,6 +143,11 @@ func (x *Clients) syncWithNetmapSN(ctx context.Context, sn netmap.NodeInfo) erro pub := sn.PublicKey() conns, ok := x.conns[snCacheKey(pub)] if !ok { + c, err := x.initConnections(ctx, pub, slices.Collect(sn.NetworkEndpoints())) + if err != nil { + return fmt.Errorf("eager init: %w", err) + } + x.conns[snCacheKey(pub)] = c return nil } @@ -176,10 +188,10 @@ func (x *Clients) syncWithNetmapSN(ctx context.Context, sn netmap.NodeInfo) erro return nil } -func (x *Clients) initConnections(ctx context.Context, pub []byte, addrs iter.Seq[string]) (*connections, error) { +func (x *Clients) initConnections(ctx context.Context, pub []byte, addrs []string) (*connections, error) { m := make(map[string]*client.Client) l := x.log.With(zap.String("public key", hex.EncodeToString(pub))) - for s := range addrs { + for _, s := range addrs { l.Info("initializing connection to the SN...", zap.String("address", s)) c, err := x.initConnection(ctx, pub, s) if err != nil { @@ -207,16 +219,32 @@ func (x *Clients) initConnection(ctx context.Context, pub []byte, uri string) (* return nil, fmt.Errorf("parse network address %q: %w", uri, err) } - target, withTLS, err := uriutil.Parse(a.URIAddr()) + target, _, err := uriutil.Parse(a.URIAddr()) if err != nil { return nil, fmt.Errorf("parse URI: %w", err) } - var transportCreds credentials.TransportCredentials - if withTLS { - transportCreds = credentials.NewTLS(nil) - } else { - transportCreds = insecure.NewCredentials() - } + transportCreds := credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{x.tlsCert}, + ServerName: peerauth.TLSServerName, // SNI marks this as an inter-node connection + InsecureSkipVerify: true, // CA chain is irrelevant; we pin by pubkey below + MinVersion: tls.VersionTLS12, + VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + got, err := peerauth.PeerPubKey(rawCerts) + if err != nil { + return fmt.Errorf("extract peer pubkey: %w", err) + } + if !bytes.Equal(got, pub) { + x.log.Warn("mTLS: server pubkey mismatch", + zap.String("expected", hex.EncodeToString(pub)), + zap.String("got", hex.EncodeToString(got))) + return clientcore.ErrWrongPublicKey + } + x.log.Info("mTLS: server verified by pubkey", + zap.String("pubkey", hex.EncodeToString(got)), + zap.String("target", target)) + return nil + }, + }) grpcConn, err := grpc.NewClient(target, grpc.WithTransportCredentials(transportCreds), grpc.WithConnectParams(grpc.ConnectParams{ diff --git a/pkg/network/peerauth/peerauth.go b/pkg/network/peerauth/peerauth.go new file mode 100644 index 0000000000..1800a9639a --- /dev/null +++ b/pkg/network/peerauth/peerauth.go @@ -0,0 +1,71 @@ +package peerauth + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "math/big" + "time" + + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" +) + +// TLSServerName is the TLS SNI a storage node sends when dialing a peer over the +// shared public port. The server uses it to distinguish inter-node mTLS +// connections from regular client TLS connections. +const TLSServerName = "neofs.internode.mtls" + +// ClientTLSServerName is the SNI a client sends to opt into client-side mTLS, +// telling it apart from inter-node ([TLSServerName]) and plain TLS connections. +const ClientTLSServerName = "neofs.client.mtls" + +// GenerateSelfSignedCert builds a self-signed TLS certificate that uses the +// node's identity ECDSA key as both subject key and signing key. +func GenerateSelfSignedCert(priv *ecdsa.PrivateKey) (tls.Certificate, error) { + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(100 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + if err != nil { + return tls.Certificate{}, fmt.Errorf("create x509 certificate: %w", err) + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + return tls.Certificate{}, fmt.Errorf("parse generated certificate: %w", err) + } + return tls.Certificate{ + Certificate: [][]byte{der}, + PrivateKey: priv, + Leaf: leaf, + }, nil +} + +// CompressedPubKey returns the compressed-form public key bytes of an ECDSA +// certificate, matching the format used in NeoFS network maps. +func CompressedPubKey(cert *x509.Certificate) ([]byte, error) { + pub, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok { + return nil, errors.New("peer certificate public key is not ECDSA") + } + return (*keys.PublicKey)(pub).Bytes(), nil +} + +// PeerPubKey parses the first certificate from a TLS handshake's raw chain and +// returns the peer's compressed public key bytes. +func PeerPubKey(rawCerts [][]byte) ([]byte, error) { + if len(rawCerts) == 0 { + return nil, errors.New("no peer certificate") + } + cert, err := x509.ParseCertificate(rawCerts[0]) + if err != nil { + return nil, fmt.Errorf("parse peer certificate: %w", err) + } + return CompressedPubKey(cert) +} diff --git a/pkg/services/object/server.go b/pkg/services/object/server.go index 4cc7820f38..b3dbac3d5d 100644 --- a/pkg/services/object/server.go +++ b/pkg/services/object/server.go @@ -6,6 +6,7 @@ import ( "crypto/ecdsa" "encoding/base64" "encoding/binary" + "encoding/hex" "errors" "fmt" "hash" @@ -23,6 +24,7 @@ import ( containercore "github.com/nspcc-dev/neofs-node/pkg/core/container" "github.com/nspcc-dev/neofs-node/pkg/core/netmap" objectcore "github.com/nspcc-dev/neofs-node/pkg/core/object" + "github.com/nspcc-dev/neofs-node/pkg/network/peerauth" metasvc "github.com/nspcc-dev/neofs-node/pkg/services/meta" aclsvc "github.com/nspcc-dev/neofs-node/pkg/services/object/acl/v2" deletesvc "github.com/nspcc-dev/neofs-node/pkg/services/object/delete" @@ -51,7 +53,9 @@ import ( "go.uber.org/zap" "google.golang.org/grpc" grpccodes "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" "google.golang.org/grpc/mem" + "google.golang.org/grpc/peer" grpcstatus "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" ) @@ -329,10 +333,12 @@ func (x *putStream) resignRequest(req *protoobject.PutRequest) (*protoobject.Put Ttl: meta.GetTtl() - 1, Origin: meta, } - var err error - req.VerifyHeader, err = neofscrypto.SignRequestWithBuffer(neofsecdsa.Signer(x.signer), req, nil) - if err != nil { - return nil, err + if req.MetaHeader.Ttl > 1 { + var err error + req.VerifyHeader, err = neofscrypto.SignRequestWithBuffer(neofsecdsa.Signer(x.signer), req, nil) + if err != nil { + return nil, err + } } return req, nil } @@ -456,9 +462,11 @@ func (s *Server) Put(gStream protoobject.ObjectService_PutServer) error { s.metrics.AddPutPayload(len(c)) } - if err = icrypto.VerifyRequestSignaturesN3(req, s.fsChain); err != nil { - err = s.sendStatusPutResponse(gStream, err, reqFirst) // assign for defer - return err + if s.needVerifyRequestSignature(gStream.Context(), req) { + if err = icrypto.VerifyRequestSignaturesN3(req, s.fsChain); err != nil { + err = s.sendStatusPutResponse(gStream, err, reqFirst) // assign for defer + return err + } } if s.fsChain.LocalNodeUnderMaintenance() { @@ -525,8 +533,10 @@ func (s *Server) Delete(ctx context.Context, req *protoobject.DeleteRequest) (*p ) defer func() { s.pushOpExecResult(stat.MethodObjectDelete, err, t) }() - if err = icrypto.VerifyRequestSignaturesN3(req, s.fsChain); err != nil { - return s.makeStatusDeleteResponse(err, req), nil + if s.needVerifyRequestSignature(ctx, req) { + if err = icrypto.VerifyRequestSignaturesN3(req, s.fsChain); err != nil { + return s.makeStatusDeleteResponse(err, req), nil + } } if s.fsChain.LocalNodeUnderMaintenance() { @@ -631,10 +641,12 @@ func (s *Server) HeadBuffered(ctx context.Context, req *protoobject.HeadRequest) ) defer func() { s.pushOpExecResult(stat.MethodObjectHead, err, t) }() - needSignResp := needSignGetResponse(req) + needSignResp := needSignGetResponse(ctx, req) - if err := icrypto.VerifyRequestSignaturesN3(req, s.fsChain); err != nil { - return s.makeStatusHeadResponse(err, needSignResp) + if s.needVerifyRequestSignature(ctx, req) { + if err := icrypto.VerifyRequestSignaturesN3(req, s.fsChain); err != nil { + return s.makeStatusHeadResponse(err, needSignResp) + } } if s.fsChain.LocalNodeUnderMaintenance() { @@ -811,7 +823,10 @@ func convertHeadPrm(signer ecdsa.PrivateKey, cnr container.Container, req *proto Ttl: meta.GetTtl() - 1, Origin: meta, } - req.VerifyHeader, err = neofscrypto.SignRequestWithBuffer(neofsecdsa.Signer(signer), req, nil) + // skip signing direct 1:1 hops; identity is from mTLS. + if req.MetaHeader.Ttl > 1 { + req.VerifyHeader, err = neofscrypto.SignRequestWithBuffer(neofsecdsa.Signer(signer), req, nil) + } }) if err != nil { return nil, iprotobuf.BuffersSlice{}, err @@ -935,10 +950,12 @@ func (s *Server) Get(req *protoobject.GetRequest, gStream protoobject.ObjectServ ) defer func() { s.pushOpExecResult(stat.MethodObjectGet, err, t) }() - needSignResp := needSignGetResponse(req) + needSignResp := needSignGetResponse(gStream.Context(), req) - if err = icrypto.VerifyRequestSignatures(req); err != nil { - return s.sendStatusGetResponse(gStream, err, needSignResp) + if s.needVerifyRequestSignature(gStream.Context(), req) { + if err = icrypto.VerifyRequestSignatures(req); err != nil { + return s.sendStatusGetResponse(gStream, err, needSignResp) + } } if s.fsChain.LocalNodeUnderMaintenance() { @@ -1228,7 +1245,10 @@ func convertGetPrm(signer ecdsa.PrivateKey, cnr container.Container, req *protoo Ttl: meta.GetTtl() - 1, Origin: meta, } - req.VerifyHeader, err = neofscrypto.SignRequestWithBuffer(neofsecdsa.Signer(signer), req, nil) + // skip signing direct 1:1 hops; identity is from mTLS. + if req.MetaHeader.Ttl > 1 { + req.VerifyHeader, err = neofscrypto.SignRequestWithBuffer(neofsecdsa.Signer(signer), req, nil) + } }) if err != nil { return err @@ -1309,8 +1329,10 @@ func (s *Server) GetRange(req *protoobject.GetRangeRequest, gStream protoobject. t = time.Now() ) defer func() { s.pushOpExecResult(stat.MethodObjectRange, err, t) }() - if err = icrypto.VerifyRequestSignaturesN3(req, s.fsChain); err != nil { - return s.sendStatusRangeResponse(gStream, err, req) + if s.needVerifyRequestSignature(gStream.Context(), req) { + if err = icrypto.VerifyRequestSignaturesN3(req, s.fsChain); err != nil { + return s.sendStatusRangeResponse(gStream, err, req) + } } if s.fsChain.LocalNodeUnderMaintenance() { @@ -1336,7 +1358,7 @@ func (s *Server) GetRange(req *protoobject.GetRangeRequest, gStream protoobject. return s.sendStatusRangeResponse(gStream, err, req) } - needSignResponse := needSignGetResponse(req) + needSignResponse := needSignGetResponse(gStream.Context(), req) p, err := convertRangePrm(s.signer, reqInfo.Container, req, &rangeStream{ base: gStream, @@ -1485,7 +1507,10 @@ func convertRangePrm(signer ecdsa.PrivateKey, cnr container.Container, req *prot Ttl: meta.GetTtl() - 1, Origin: meta, } - req.VerifyHeader, err = neofscrypto.SignRequestWithBuffer(neofsecdsa.Signer(signer), req, nil) + // skip signing direct 1:1 hops; identity is from mTLS. + if req.MetaHeader.Ttl > 1 { + req.VerifyHeader, err = neofscrypto.SignRequestWithBuffer(neofsecdsa.Signer(signer), req, nil) + } }) if err != nil { return err @@ -1692,8 +1717,10 @@ func (s *Server) SearchV2(ctx context.Context, req *protoobject.SearchV2Request) t = time.Now() ) defer s.pushOpExecResult(stat.MethodObjectSearchV2, err, t) - if err = icrypto.VerifyRequestSignaturesN3(req, s.fsChain); err != nil { - return s.signSearchResponse(nil, err, req), nil + if s.needVerifyRequestSignature(ctx, req) { + if err = icrypto.VerifyRequestSignaturesN3(req, s.fsChain); err != nil { + return s.signSearchResponse(nil, err, req), nil + } } if s.fsChain.LocalNodeUnderMaintenance() { @@ -1881,9 +1908,7 @@ func (s *Server) ProcessSearch(ctx context.Context, req *protoobject.SearchV2Req ) req.MetaHeader = &protosession.RequestMetaHeader{Ttl: 1, Origin: req.MetaHeader} - if req.VerifyHeader, err = neofscrypto.SignRequestWithBuffer[*protoobject.SearchV2Request_Body](neofsecdsa.Signer(s.signer), req, nil); err != nil { - return nil, nil, fmt.Errorf("sign request: %w", err) - } + // hardcoded TTL=1 1:1 hop, signature is replaced by mTLS identity. var optimizedNodes = (len(body.Filters) != 0) && slices.ContainsFunc(body.Filters, func(filt *protoobject.SearchFilter) bool { @@ -2131,10 +2156,74 @@ func chunkBoundsToSend(global, local, chunkLen int) (int, int) { return global - local, chunkLen } -func needSignGetResponse(req util.Request) bool { +func needSignGetResponse(ctx context.Context, req util.Request) bool { + if req.GetMetaHeader().GetTtl() <= 1 && peerAuthenticatedAsNode(ctx) { + return false + } return util.VersionLE(req, 2, 17) } +// peerAuthenticatedAsNode reports whether the request came over an inter-node +// mTLS connection (SNI peerauth.TLSServerName), as opposed to client-mTLS. +func peerAuthenticatedAsNode(ctx context.Context) bool { + p, ok := peer.FromContext(ctx) + if !ok { + return false + } + tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok { + return false + } + return tlsInfo.State.ServerName == peerauth.TLSServerName && len(tlsInfo.State.PeerCertificates) > 0 +} + +// peerClientPubKey returns the public key of a client authenticated via +// client-mTLS (SNI peerauth.ClientTLSServerName), or nil otherwise. +func peerClientPubKey(ctx context.Context) []byte { + p, ok := peer.FromContext(ctx) + if !ok { + return nil + } + tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok { + return nil + } + if tlsInfo.State.ServerName != peerauth.ClientTLSServerName || len(tlsInfo.State.PeerCertificates) == 0 { + return nil + } + pub, err := peerauth.CompressedPubKey(tlsInfo.State.PeerCertificates[0]) + if err != nil { + return nil + } + return pub +} + +type verifiableRequest interface { + GetMetaHeader() *protosession.RequestMetaHeader + GetVerifyHeader() *protosession.RequestVerificationHeader +} + +// needVerifyRequestSignature reports whether req's signature must be verified; +// skipped only when mTLS authenticated the exact signer (inter-node 1:1 hop, or +// client cert key == author key - the equality guards against forged authorship). +func (s *Server) needVerifyRequestSignature(ctx context.Context, req verifiableRequest) bool { + if req.GetMetaHeader().GetTtl() <= 1 && peerAuthenticatedAsNode(ctx) { + return false + } + if clientPub := peerClientPubKey(ctx); clientPub != nil { + _, authorPub, err := icrypto.GetRequestAuthor(req.GetVerifyHeader()) + if err == nil && bytes.Equal(authorPub, clientPub) { + return false + } + s.log.Debug("client mTLS: signature verification not skipped", + zap.String("certKey", hex.EncodeToString(clientPub)), + zap.String("authorKey", hex.EncodeToString(authorPub)), + zap.Error(err)) + return true + } + return true +} + func checkHeaderProtobufAgainstID(buffers iprotobuf.BuffersSlice, id oid.ID, ordered bool) error { b := buffers.ReadOnlyData() if !ordered {