[client, management] implement certificate posture check (#7535)

Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
This commit is contained in:
Pascal Fischer
2026-10-09 14:57:00 +02:00
committed by GitHub
co-authored by mlsmaycon
parent a5834fdaab
commit 53a14551c8
78 changed files with 7028 additions and 1382 deletions
@@ -0,0 +1,72 @@
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"
// certChallenger derives the nonce secret from the management WireGuard key so every
// instance sharing that key issues and verifies the same nonces without extra state.
func certChallenger(serverKey wgtypes.Key) *certposture.Challenger {
h := sha256.New()
h.Write([]byte(certChallengeKeyDomain))
h.Write(serverKey[:])
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.
func stampCertificateChallenges(checks []*proto.Checks, peerKey, serverKey wgtypes.Key) {
var nonce []byte
for _, check := range checks {
challenge := check.GetCertificateChallenge()
if challenge == nil {
continue
}
if nonce == nil {
nonce = certChallenger(serverKey).Nonce(peerKey[:], time.Now())
}
challenge.Nonce = nonce
}
}
// 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. Any invalid proof rejects the whole set.
func (s *Server) verifiedCertificates(ctx context.Context, peerKey wgtypes.Key, proofs []*proto.CertificateProof) []string {
if len(proofs) == 0 {
return nil
}
serverKey, err := s.secretsManager.GetWGKey()
if err != nil {
log.WithContext(ctx).Warnf("skipping certificate proofs of peer %s: %v", peerKey, err)
return nil
}
challenger := certChallenger(serverKey)
now := time.Now()
chains := make([]string, 0, len(proofs))
for _, p := range proofs {
chain, err := 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("rejecting certificate proofs of peer %s: %v", peerKey, err)
return nil
}
chains = append(chains, certposture.EncodeChainPEM(chain))
}
return chains
}
@@ -0,0 +1,75 @@
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) {
serverKey := 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, peerKey, serverKey)
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{secretsManager: &TimeBasedAuthSecretsManager{wgKey: serverKey}}
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 management key is rejected", func(t *testing.T) {
other := &Server{secretsManager: &TimeBasedAuthSecretsManager{wgKey: generateKey(t)}}
assert.Nil(t, other.verifiedCertificates(ctx, peerKey, proofs))
})
t.Run("one invalid proof rejects the whole set", func(t *testing.T) {
bad := &proto.CertificateProof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: []byte("junk")}
assert.Nil(t, s.verifiedCertificates(ctx, peerKey, append(proofs, 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, generateKey(t).PublicKey(), generateKey(t))
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
}
@@ -117,6 +117,7 @@ func (pu *PeerUpdateHandler) SendUpdate(ctx context.Context, update *network_map
return status.Errorf(codes.Internal, "failed processing update message")
}
stampCertificateChallenges(update.Update.GetChecks(), pu.peerKey, key)
encryptedResp, err := pu.encrypter.EncryptMessage(pu.peerKey, key, update.Update)
if err != nil {
pu.cleanupFunc()
+12 -2
View File
@@ -246,6 +246,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) {
@@ -649,6 +650,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 +727,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
return nil, status.Errorf(codes.Internal, "failed logging in peer")
}
stampCertificateChallenges(loginResp.Checks, peerKey, key)
encryptedResp, err := encryption.EncryptMessage(peerKey, key, loginResp)
if err != nil {
log.WithContext(ctx).Warnf("failed encrypting peer %s message", peer.ID)
@@ -976,6 +979,7 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer
return status.Errorf(codes.Internal, "failed getting server key")
}
stampCertificateChallenges(plainResp.Checks, peerKey, key)
encryptedResp, err := encryption.EncryptMessage(peerKey, key, plainResp)
if err != nil {
return status.Errorf(codes.Internal, "error handling request")
@@ -1205,7 +1209,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 +1287,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
}