Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt

# Conflicts:
#	shared/management/proto/management.pb.go
This commit is contained in:
riccardom
2026-10-09 17:12:38 +02:00
140 changed files with 12817 additions and 1661 deletions
@@ -0,0 +1,88 @@
package grpc
import (
"context"
"crypto/sha256"
"time"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
const certChallengeKeyDomain = "netbird-cert-challenge-key"
// newCertChallenger derives the nonce secret from the data store encryption key:
// generated once, written back to the configuration, and read by every instance, so a
// nonce stays verifiable across a restart and between instances.
//
// Where none is configured it falls back to the server's WireGuard key, which is
// regenerated per process. Unpredictable is the property that has to hold either way: a
// peer that could guess the secret would mint future windows' nonces, sign them while
// its key is present, and keep passing after it is gone.
func newCertChallenger(encryptionKey string, serverKey wgtypes.Key) *certposture.Challenger {
secret := []byte(encryptionKey)
if len(secret) == 0 {
log.Warnf("no data store encryption key, deriving certificate challenges from the ephemeral server key: peers will be rejected once per restart and across instances")
secret = serverKey[:]
}
h := sha256.New()
h.Write([]byte(certChallengeKeyDomain))
h.Write(secret)
return certposture.NewChallenger(h.Sum(nil))
}
// stampCertificateChallenges fills the per-peer nonce into every certificate challenge
// right before the response is encrypted for that peer, reporting whether it issued one.
//
// On a sync stream the answer registers the peer for renewal. A nonce is stateless, but
// the renewal that keeps it fresh is local: only the instance holding a peer's stream
// can push to it, so an instance renews exactly the peers it streams nonces to.
func stampCertificateChallenges(checks []*proto.Checks, challenger *certposture.Challenger, peerKey wgtypes.Key) bool {
var nonce []byte
for _, check := range checks {
challenge := check.GetCertificateChallenge()
if challenge == nil {
continue
}
if nonce == nil {
nonce = challenger.Nonce(peerKey[:], time.Now())
}
challenge.Nonce = nonce
}
return nonce != nil
}
// verifiedCertificates turns the peer's proofs into PEM chains for its meta. Possession
// (nonce + signature) is verified here; trust against a check's CAs is evaluated by the
// posture check itself. Each proof stands for its own key, so an invalid one is dropped
// without the others: a user store answering with an unusable key must not cost the
// device the machine certificate it proved.
func (s *Server) verifiedCertificates(ctx context.Context, peerKey wgtypes.Key, proofs []*proto.CertificateProof) []string {
if len(proofs) == 0 {
return nil
}
now := time.Now()
chains := make([]string, 0, len(proofs))
for _, p := range proofs {
chain, err := s.challenger.Verify(certposture.Proof{
Nonce: p.GetNonce(),
Chain: p.GetChain(),
SigAlg: p.GetSigAlg(),
Signature: p.GetSignature(),
}, peerKey[:], now)
if err != nil {
log.WithContext(ctx).Warnf("dropping a certificate proof of peer %s: %v", peerKey, err)
continue
}
chains = append(chains, certposture.EncodeChainPEM(chain))
}
if len(chains) == 0 {
return nil
}
return chains
}
@@ -0,0 +1,91 @@
package grpc
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestCertificateChallenge_StampAndVerifyRoundTrip(t *testing.T) {
const encryptionKey = "data-store-encryption-key"
challenger := newCertChallenger(encryptionKey, generateKey(t))
peerKey := generateKey(t).PublicKey()
ca := certtest.NewCA(t, "corp-root")
ctx := context.Background()
checks := toProtocolChecks(ctx, []*nmdata.PostureChecks{{
ID: "cert-check",
Checks: nmdata.ChecksDefinition{CertificateCheck: &nmdata.CertificateCheck{CACertificates: []string{ca.PEM}}},
}})
require.Len(t, checks, 1)
require.Equal(t, []string{ca.PEM}, checks[0].GetCertificateChallenge().GetCaCertificates())
require.Empty(t, checks[0].GetCertificateChallenge().GetNonce())
stampCertificateChallenges(checks, challenger, peerKey)
nonce := checks[0].GetCertificateChallenge().GetNonce()
require.NotEmpty(t, nonce)
deviceKey := certtest.ECDSAKey(t)
leaf := ca.Issue(t, deviceKey, "device")
sigAlg, sig, err := certposture.Sign(deviceKey, nonce, peerKey[:])
require.NoError(t, err)
proofs := []*proto.CertificateProof{{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: sig}}
s := &Server{challenger: challenger}
chains := s.verifiedCertificates(ctx, peerKey, proofs)
require.Len(t, chains, 1)
assert.True(t, certposture.ChainMatchesCAs(chains[0], []string{ca.PEM}, time.Now()))
t.Run("proof replayed by another peer is rejected", func(t *testing.T) {
assert.Nil(t, s.verifiedCertificates(ctx, generateKey(t).PublicKey(), proofs))
})
t.Run("nonce from another deployment's secret is rejected", func(t *testing.T) {
other := &Server{challenger: newCertChallenger("a-different-encryption-key", generateKey(t))}
assert.Nil(t, other.verifiedCertificates(ctx, peerKey, proofs))
})
t.Run("a second instance with the same encryption key accepts it", func(t *testing.T) {
// The point of deriving from the stored key: another instance, with its own
// freshly generated WireGuard key, verifies what this one issued. This is also
// what a restart looks like, and it used to reject.
peer := &Server{challenger: newCertChallenger(encryptionKey, generateKey(t))}
assert.Len(t, peer.verifiedCertificates(ctx, peerKey, proofs), 1)
})
t.Run("an invalid proof is dropped without the valid ones", func(t *testing.T) {
// A device proving its machine certificate while a user store answers with a key
// it cannot use must keep the machine certificate.
bad := &proto.CertificateProof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: []byte("junk")}
chains := s.verifiedCertificates(ctx, peerKey, append([]*proto.CertificateProof{bad}, proofs...))
require.Len(t, chains, 1, "the valid proof survives the invalid one")
assert.True(t, certposture.ChainMatchesCAs(chains[0], []string{ca.PEM}, time.Now()))
})
t.Run("only invalid proofs yield no certificates", func(t *testing.T) {
bad := &proto.CertificateProof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: []byte("junk")}
assert.Empty(t, s.verifiedCertificates(ctx, peerKey, []*proto.CertificateProof{bad}))
})
t.Run("no proofs yields no certificates", func(t *testing.T) {
assert.Nil(t, s.verifiedCertificates(ctx, peerKey, nil))
})
}
func TestStampCertificateChallenges_SkipsFileOnlyChecks(t *testing.T) {
checks := []*proto.Checks{{Files: []string{"/bin/agent"}}}
stampCertificateChallenges(checks, newCertChallenger("key", generateKey(t)), generateKey(t).PublicKey())
assert.Nil(t, checks[0].GetCertificateChallenge())
}
func generateKey(t *testing.T) wgtypes.Key {
t.Helper()
key, err := wgtypes.GeneratePrivateKey()
require.NoError(t, err)
return key
}
@@ -7,6 +7,7 @@ import (
"github.com/netbirdio/netbird/encryption"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
@@ -18,16 +19,20 @@ func PeerUpdateHandlerFactory(
peerKey wgtypes.Key,
updates chan *network_map.UpdateMessage,
secretsManager SecretsManager,
challenger *certposture.Challenger,
onChallengeStamped func(),
srv proto.ManagementService_SyncServer,
cleanupfunc func()) *PeerUpdateHandler {
return &PeerUpdateHandler{
peerKey: peerKey,
updates: updates,
secretsManager: secretsManager,
srv: srv,
encrypter: encryption.DefaultEncrypter{},
debouncer: NewUpdateDebouncer(1000 * time.Millisecond),
cleanupFunc: cleanupfunc,
peerKey: peerKey,
updates: updates,
secretsManager: secretsManager,
challenger: challenger,
onChallengeStamped: onChallengeStamped,
srv: srv,
encrypter: encryption.DefaultEncrypter{},
debouncer: NewUpdateDebouncer(1000 * time.Millisecond),
cleanupFunc: cleanupfunc,
}
}
@@ -40,10 +45,15 @@ type PeerUpdateHandler struct {
updates chan *network_map.UpdateMessage
appMetrics telemetry.AppMetrics
secretsManager SecretsManager
srv syncSender
encrypter encryption.Encrypter
debouncer Debouncer
cleanupFunc func()
challenger *certposture.Challenger
// onChallengeStamped registers the account for renewal. Every push reaches this
// path, including the ones a REST update on another instance broadcasts, which is
// what lets an instance pick up an account whose peers never reconnect.
onChallengeStamped func()
srv syncSender
encrypter encryption.Encrypter
debouncer Debouncer
cleanupFunc func()
}
func (pu *PeerUpdateHandler) WithMetrics(appMetrics telemetry.AppMetrics) *PeerUpdateHandler {
@@ -117,6 +127,9 @@ func (pu *PeerUpdateHandler) SendUpdate(ctx context.Context, update *network_map
return status.Errorf(codes.Internal, "failed processing update message")
}
if stampCertificateChallenges(update.Update.GetChecks(), pu.challenger, pu.peerKey) && pu.onChallengeStamped != nil {
pu.onChallengeStamped()
}
encryptedResp, err := pu.encrypter.EncryptMessage(pu.peerKey, key, update.Update)
if err != nil {
pu.cleanupFunc()
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
"go.uber.org/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"google.golang.org/grpc/codes"
grpcstatus "google.golang.org/grpc/status"
@@ -6,9 +6,9 @@ import (
"testing"
"time"
"go.uber.org/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
+30 -5
View File
@@ -45,6 +45,7 @@ import (
"github.com/netbirdio/netbird/management/server/settings"
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
internalStatus "github.com/netbirdio/netbird/shared/management/status"
@@ -70,6 +71,7 @@ type Server struct {
peerLocks sync.Map
authManager auth.Manager
sessionStore *auth.SessionStore
challenger *certposture.Challenger
logBlockedPeers bool
blockPeersWithSameConfig bool
@@ -131,7 +133,13 @@ func NewServer(
}
}
serverKey, err := secretsManager.GetWGKey()
if err != nil {
return nil, fmt.Errorf("get server WireGuard key: %w", err)
}
return &Server{
challenger: newCertChallenger(config.DataStoreEncryptionKey, serverKey),
jobManager: jobManager,
accountManager: accountManager,
settingsManager: settingsManager,
@@ -246,6 +254,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
realIP := getRealIP(ctx)
sRealIP := realIP.String()
peerMeta := extractPeerMeta(ctx, syncReq.GetMeta())
peerMeta.Certificates = s.verifiedCertificates(ctx, peerKey, syncReq.GetMeta().GetCertificateProofs())
metahashed := metaHash(peerMeta)
if !s.loginFilter.allowLogin(peerKey.String(), metahashed) {
@@ -309,7 +318,8 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
return mapError(ctx, err)
}
err = s.sendInitialSync(ctx, peerKey, peer, netMap, postureChecks, srv, dnsFwdPort)
trackChallenge := func() { s.accountManager.TrackCertificateChallenges(ctx, accountID, peer.ID, syncStart) }
err = s.sendInitialSync(ctx, peerKey, peer, netMap, postureChecks, srv, dnsFwdPort, trackChallenge)
if err != nil {
log.WithContext(ctx).Debugf("error while sending initial sync for %s: %v", peerKey.String(), err)
s.syncSem.Add(-1)
@@ -337,7 +347,9 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
s.syncSem.Add(-1)
return PeerUpdateHandlerFactory(peerKey, updates, s.secretsManager, srv, func() { s.cancelPeerRoutines(ctx, accountID, peer, syncStart, updates) }).
return PeerUpdateHandlerFactory(peerKey, updates, s.secretsManager, s.challenger,
trackChallenge,
srv, func() { s.cancelPeerRoutines(ctx, accountID, peer, syncStart, updates) }).
WithMetrics(s.appMetrics).HandleUpdates(ctx)
}
@@ -451,6 +463,7 @@ func (s *Server) cancelPeerRoutinesWithoutLock(ctx context.Context, accountID st
return
}
s.secretsManager.CancelRefresh(peer.ID)
s.accountManager.UntrackCertificateChallenges(accountID, peer.ID, streamStartTime)
log.WithContext(ctx).Debugf("peer %s has been disconnected", peer.Key)
}
@@ -649,6 +662,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
}
peerMeta := extractPeerMeta(ctx, loginReq.GetMeta())
peerMeta.Certificates = s.verifiedCertificates(ctx, peerKey, loginReq.GetMeta().GetCertificateProofs())
metahashed := metaHash(peerMeta)
if !s.loginFilter.allowLogin(peerKey.String(), metahashed) {
if s.logBlockedPeers {
@@ -725,6 +739,8 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
return nil, status.Errorf(codes.Internal, "failed logging in peer")
}
// Renewal is tracked by the sync stream that follows, on whichever instance it lands.
stampCertificateChallenges(loginResp.Checks, s.challenger, peerKey)
encryptedResp, err := encryption.EncryptMessage(peerKey, key, loginResp)
if err != nil {
log.WithContext(ctx).Warnf("failed encrypting peer %s message", peer.ID)
@@ -904,7 +920,7 @@ func (s *Server) IsHealthy(ctx context.Context, req *proto.Empty) (*proto.Empty,
}
// sendInitialSync sends initial proto.SyncResponse to the peer requesting synchronization
func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer *nbpeer.Peer, networkMap *types.NetworkMap, postureChecks []*nmdata.PostureChecks, srv proto.ManagementService_SyncServer, dnsFwdPort int64) error {
func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer *nbpeer.Peer, networkMap *types.NetworkMap, postureChecks []*nmdata.PostureChecks, srv proto.ManagementService_SyncServer, dnsFwdPort int64, onChallengeStamped func()) error {
var err error
var turnToken *Token
@@ -976,6 +992,9 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer
return status.Errorf(codes.Internal, "failed getting server key")
}
if stampCertificateChallenges(plainResp.Checks, s.challenger, peerKey) {
onChallengeStamped()
}
encryptedResp, err := encryption.EncryptMessage(peerKey, key, plainResp)
if err != nil {
return status.Errorf(codes.Internal, "error handling request")
@@ -1205,7 +1224,9 @@ func (s *Server) SyncMeta(ctx context.Context, req *proto.EncryptedMessage) (*pr
return nil, msg
}
err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), extractPeerMeta(ctx, syncMetaReq.GetMeta()), realIP)
peerMeta := extractPeerMeta(ctx, syncMetaReq.GetMeta())
peerMeta.Certificates = s.verifiedCertificates(ctx, peerKey, syncMetaReq.GetMeta().GetCertificateProofs())
err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), peerMeta, realIP)
if err != nil {
return nil, mapError(ctx, err)
}
@@ -1281,7 +1302,11 @@ func toProtocolCheck(postureCheck *nmdata.PostureChecks) *proto.Checks {
}
}
if len(protoCheck.Files) == 0 {
if check := postureCheck.Checks.CertificateCheck; check != nil {
protoCheck.CertificateChallenge = &proto.CertificateChallenge{CaCertificates: check.CACertificates}
}
if len(protoCheck.Files) == 0 && protoCheck.CertificateChallenge == nil {
return nil
}
@@ -40,6 +40,9 @@ func TestCancelPeerRoutines_SessionOwnership(t *testing.T) {
controller.EXPECT().OnPeerDisconnected(gomock.Any(), "account-1", peer.ID, tt.session).Return(tt.ownsPeer)
if tt.cancelRefresh {
secretsManager.EXPECT().CancelRefresh(peer.ID)
// The stream's challenge renewal ends with it; a stale session leaves the
// newer one's alone.
accountManager.EXPECT().UntrackCertificateChallenges("account-1", peer.ID, streamStart)
}
s.cancelPeerRoutines(context.Background(), "account-1", peer, streamStart, tt.session)
@@ -7,9 +7,9 @@ import (
"testing"
"time"
"go.uber.org/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
@@ -10,8 +10,8 @@ import (
"testing"
"time"
"go.uber.org/mock/gomock"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"