mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-28 15:29:09 +01:00
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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user