implement certificate posture check

This commit is contained in:
pascal
2026-08-31 13:47:55 +02:00
parent 086d8ba507
commit 84d83fa05e
34 changed files with 2825 additions and 1236 deletions
+79
View File
@@ -0,0 +1,79 @@
package certproof
import (
"context"
"crypto/sha256"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto"
)
// Collect answers the certificate challenges in checks: for each challenge it picks a
// stored certificate that chains to the challenge's CAs and signs the nonce with its
// key. The same certificate is proven once even if several checks accept it.
func Collect(ctx context.Context, store Store, checks []*proto.Checks, peerKey []byte) []certposture.Proof {
challenges := certificateChallenges(checks)
if len(challenges) == 0 {
return nil
}
candidates, err := store.Candidates(ctx)
if err != nil {
log.Warnf("failed loading certificates for posture checks: %v", err)
return nil
}
now := time.Now()
proven := make(map[[sha256.Size]byte]struct{})
var proofs []certposture.Proof
for _, challenge := range challenges {
roots, err := certposture.ParseCAs(challenge.GetCaCertificates())
if err != nil {
log.Warnf("skipping certificate challenge with invalid CA certificates: %v", err)
continue
}
for _, candidate := range candidates {
if certposture.VerifyChain(candidate.Chain, roots, now) != nil {
continue
}
fingerprint := sha256.Sum256(candidate.Chain[0].Raw)
if _, done := proven[fingerprint]; done {
break
}
proof, err := prove(candidate, challenge.GetNonce(), peerKey)
if err != nil {
log.Warnf("failed signing certificate proof for %s: %v", candidate.Chain[0].Subject, err)
continue
}
proven[fingerprint] = struct{}{}
proofs = append(proofs, proof)
break
}
}
return proofs
}
func certificateChallenges(checks []*proto.Checks) []*proto.CertificateChallenge {
var challenges []*proto.CertificateChallenge
for _, check := range checks {
if challenge := check.GetCertificateChallenge(); challenge != nil && len(challenge.GetNonce()) > 0 {
challenges = append(challenges, challenge)
}
}
return challenges
}
func prove(candidate Candidate, nonce, peerKey []byte) (certposture.Proof, error) {
sigAlg, sig, err := certposture.Sign(candidate.Signer, nonce, peerKey)
if err != nil {
return certposture.Proof{}, err
}
chain := make([][]byte, 0, len(candidate.Chain))
for _, cert := range candidate.Chain {
chain = append(chain, cert.Raw)
}
return certposture.Proof{Nonce: nonce, Chain: chain, SigAlg: sigAlg, Signature: sig}, nil
}
+108
View File
@@ -0,0 +1,108 @@
package certproof
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
"github.com/netbirdio/netbird/shared/management/proto"
)
var peerKey = []byte("peer-public-key-aaaaaaaaaaaaaaaa")
func TestCollect_ProvesOneMatchingCertificatePerChallenge(t *testing.T) {
corpCA := certtest.NewCA(t, "corp-root")
otherCA := certtest.NewCA(t, "other-root")
unrelatedCA := certtest.NewCA(t, "unrelated-root")
dir := t.TempDir()
deviceKey := certtest.ECDSAKey(t)
device := corpCA.Issue(t, deviceKey, "device")
writeFile(t, dir, "device.pem", certtest.CertPEM(device)+certtest.KeyPEM(t, deviceKey))
otherKey := certtest.RSAKey(t)
writeFile(t, dir, "other.crt", certtest.CertPEM(otherCA.Issue(t, otherKey, "other")))
writeFile(t, dir, "other.key", certtest.KeyPEM(t, otherKey))
writeFile(t, dir, "keyless.crt", certtest.CertPEM(corpCA.Issue(t, certtest.ECDSAKey(t), "keyless")))
writeFile(t, dir, "notes.txt", "ignored")
challenger := certposture.NewChallenger([]byte("secret"))
nonce := challenger.Nonce(peerKey, time.Now())
challenge := func(cas ...string) *proto.Checks {
return &proto.Checks{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: cas}}
}
checks := []*proto.Checks{
{Files: []string{"/usr/bin/agent"}},
challenge(corpCA.PEM),
challenge(corpCA.PEM),
challenge(otherCA.PEM),
challenge(unrelatedCA.PEM),
challenge("not a pem"),
}
proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey)
require.Len(t, proofs, 2)
var subjects []string
for _, p := range proofs {
chain, err := challenger.Verify(p, peerKey, time.Now())
require.NoError(t, err)
subjects = append(subjects, chain[0].Subject.CommonName)
}
assert.ElementsMatch(t, []string{"device", "other"}, subjects)
}
func TestCollect_NothingToProve(t *testing.T) {
dir := t.TempDir()
key := certtest.ECDSAKey(t)
ca := certtest.NewCA(t, "root")
writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key))
tests := []struct {
name string
store Store
checks []*proto.Checks
}{
{"no checks", NewFileStore(dir), nil},
{"files only", NewFileStore(dir), []*proto.Checks{{Files: []string{"/bin/x"}}}},
{"challenge without nonce", NewFileStore(dir), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{CaCertificates: []string{ca.PEM}}}}},
{"missing store dir", NewFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{ca.PEM}}}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Nil(t, Collect(context.Background(), tt.store, tt.checks, peerKey))
})
}
}
func TestFileStore_ChainWithIntermediate(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "intermediate")
key := certtest.ECDSAKey(t)
leaf := intermediate.Issue(t, key, "device")
dir := t.TempDir()
writeFile(t, dir, "device.pem", certtest.CertPEM(leaf)+certtest.CertPEM(intermediate.Cert)+certtest.KeyPEM(t, key))
candidates, err := NewFileStore(dir).Candidates(context.Background())
require.NoError(t, err)
require.Len(t, candidates, 1)
require.Len(t, candidates[0].Chain, 2)
roots, err := certposture.ParseCAs([]string{root.PEM})
require.NoError(t, err)
assert.NoError(t, certposture.VerifyChain(candidates[0].Chain, roots, time.Now()))
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
}
+156
View File
@@ -0,0 +1,156 @@
package certproof
import (
"context"
"crypto"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
log "github.com/sirupsen/logrus"
)
const (
StoreDirEnv = "NB_CERT_STORE_DIR"
defaultStoreDir = "/etc/netbird/certs"
)
// Candidate is a certificate chain the peer can sign for. Signer never exposes the key.
type Candidate struct {
Chain []*x509.Certificate
Signer crypto.Signer
}
// Store yields the certificates a peer may prove possession of. FileStore is the PEM
// directory implementation; OS keystores (CNG, Keychain, PKCS#11) slot in here.
type Store interface {
Candidates(ctx context.Context) ([]Candidate, error)
}
// FileStore reads PEM files from a directory. A file holds the chain (leaf first) and
// either its private key or a sibling "<name>.key" file holds it.
type FileStore struct {
dir string
}
func NewFileStore(dir string) *FileStore {
return &FileStore{dir: dir}
}
func StoreDir() string {
if dir := os.Getenv(StoreDirEnv); dir != "" {
return dir
}
return defaultStoreDir
}
func (s *FileStore) Candidates(_ context.Context) ([]Candidate, error) {
entries, err := os.ReadDir(s.dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read certificate store %s: %w", s.dir, err)
}
var candidates []Candidate
for _, entry := range entries {
if entry.IsDir() || !isCertFile(entry.Name()) {
continue
}
path := filepath.Join(s.dir, entry.Name())
candidate, err := s.load(path)
if err != nil {
log.Warnf("skipping certificate %s: %v", path, err)
continue
}
candidates = append(candidates, candidate)
}
return candidates, nil
}
func (s *FileStore) load(path string) (Candidate, error) {
data, err := os.ReadFile(path)
if err != nil {
return Candidate{}, err
}
chain, signer, err := parsePEM(data)
if err != nil {
return Candidate{}, err
}
if len(chain) == 0 {
return Candidate{}, errors.New("no certificate")
}
if signer == nil {
keyData, err := os.ReadFile(strings.TrimSuffix(path, filepath.Ext(path)) + ".key")
if err != nil {
return Candidate{}, fmt.Errorf("no private key: %w", err)
}
if _, signer, err = parsePEM(keyData); err != nil {
return Candidate{}, err
}
if signer == nil {
return Candidate{}, errors.New("no private key in key file")
}
}
return Candidate{Chain: chain, Signer: signer}, nil
}
func parsePEM(data []byte) ([]*x509.Certificate, crypto.Signer, error) {
var chain []*x509.Certificate
var signer crypto.Signer
for {
var block *pem.Block
block, data = pem.Decode(data)
if block == nil {
return chain, signer, nil
}
switch block.Type {
case "CERTIFICATE":
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
case "PRIVATE KEY", "EC PRIVATE KEY", "RSA PRIVATE KEY":
key, err := parsePrivateKey(block)
if err != nil {
return nil, nil, err
}
signer = key
}
}
}
func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
var key any
var err error
switch block.Type {
case "EC PRIVATE KEY":
key, err = x509.ParseECPrivateKey(block.Bytes)
case "RSA PRIVATE KEY":
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
default:
key, err = x509.ParsePKCS8PrivateKey(block.Bytes)
}
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, errors.New("private key cannot sign")
}
return signer, nil
}
func isCertFile(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".pem", ".crt", ".cer":
return true
}
return false
}
+15
View File
@@ -35,6 +35,7 @@ import (
"github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/udpmux"
"github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/acl" "github.com/netbirdio/netbird/client/internal/acl"
"github.com/netbirdio/netbird/client/internal/certproof"
"github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/dns" "github.com/netbirdio/netbird/client/internal/dns"
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
@@ -1233,6 +1234,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
return nil return nil
} }
e.applyInfoFlags(info) e.applyInfoFlags(info)
e.attachCertificateProofs(info, checks)
if err := e.mgmClient.SyncMeta(info); err != nil { if err := e.mgmClient.SyncMeta(info); err != nil {
return fmt.Errorf("could not sync meta: error %s", err) return fmt.Errorf("could not sync meta: error %s", err)
@@ -1262,6 +1264,13 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
) )
} }
// attachCertificateProofs answers the certificate challenges in checks with the
// certificates found in the local store, signing each challenge nonce for our peer key.
func (e *Engine) attachCertificateProofs(info *system.Info, checks []*mgmProto.Checks) {
peerKey := e.config.WgPrivateKey.PublicKey()
info.CertificateProofs = certproof.Collect(e.ctx, certproof.NewFileStore(certproof.StoreDir()), checks, peerKey[:])
}
// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it // overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
// can be excluded from the reported network addresses; the interface coming and // can be excluded from the reported network addresses; the interface coming and
// going otherwise churns the peer meta on the management server. // going otherwise churns the peer meta on the management server.
@@ -1427,6 +1436,7 @@ func (e *Engine) receiveManagementEvents() {
info = system.GetInfo(e.ctx) info = system.GetInfo(e.ctx)
} }
e.applyInfoFlags(info) e.applyInfoFlags(info)
e.attachCertificateProofs(info, e.checks)
err := e.mgmClient.Sync(e.ctx, info, e.handleSync) err := e.mgmClient.Sync(e.ctx, info, e.handleSync)
if err != nil { if err != nil {
@@ -2685,6 +2695,11 @@ func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool {
sortedFiles := slices.Clone(check.Files) sortedFiles := slices.Clone(check.Files)
sort.Strings(sortedFiles) sort.Strings(sortedFiles)
normalized[i] = strings.Join(sortedFiles, "|") normalized[i] = strings.Join(sortedFiles, "|")
if challenge := check.GetCertificateChallenge(); challenge != nil {
sortedCAs := slices.Clone(challenge.GetCaCertificates())
sort.Strings(sortedCAs)
normalized[i] += fmt.Sprintf("#%x|%s", challenge.GetNonce(), strings.Join(sortedCAs, "|"))
}
} }
sort.Strings(normalized) sort.Strings(normalized)
+26
View File
@@ -1079,6 +1079,32 @@ func Test_CheckFilesEqual(t *testing.T) {
}, },
expectedBool: true, expectedBool: true,
}, },
{
name: "Same files with rotated certificate challenge nonce should return false",
inputChecks1: []*mgmtProto.Checks{
{
Files: []string{"testfile1"},
CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-a"}},
},
},
inputChecks2: []*mgmtProto.Checks{
{
Files: []string{"testfile1"},
CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{2}, CaCertificates: []string{"ca-a"}},
},
},
expectedBool: false,
},
{
name: "Same certificate challenge with CA certificates in different order should return true",
inputChecks1: []*mgmtProto.Checks{
{CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-a", "ca-b"}}},
},
inputChecks2: []*mgmtProto.Checks{
{CertificateChallenge: &mgmtProto.CertificateChallenge{Nonce: []byte{1}, CaCertificates: []string{"ca-b", "ca-a"}}},
},
expectedBool: true,
},
} }
for _, testCase := range testCases { for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
+2
View File
@@ -11,6 +11,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/management/proto"
) )
@@ -61,6 +62,7 @@ type Info struct {
SystemManufacturer string SystemManufacturer string
Environment Environment Environment Environment
Files []File // for posture checks Files []File // for posture checks
CertificateProofs []certposture.Proof
RosenpassEnabled bool RosenpassEnabled bool
RosenpassPermissive bool RosenpassPermissive bool
@@ -426,6 +426,9 @@ func accountPostureChecks(id string, pc *nmdata.PostureChecks, publicID string)
} }
out.Checks.ProcessCheck = &posture.ProcessCheck{Processes: procs} out.Checks.ProcessCheck = &posture.ProcessCheck{Processes: procs}
} }
if def.CertificateCheck != nil {
out.Checks.CertificateCheck = &posture.CertificateCheck{CACertificates: def.CertificateCheck.CACertificates}
}
return out return out
} }
@@ -12,7 +12,7 @@ const (
GetPeersQuery = ` GetPeersQuery = `
select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version, meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_certificates, meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip location_country_code, location_city_name, location_connection_ip
from peers from peers
where account_id = $1 where account_id = $1
@@ -179,6 +179,7 @@ type Peer struct {
MetaKernelVersion sql.NullString `nmap:"skip"` MetaKernelVersion sql.NullString `nmap:"skip"`
MetaNetworkAddresses []byte `nmap:"skip,json"` MetaNetworkAddresses []byte `nmap:"skip,json"`
MetaFiles []byte `nmap:"skip,json"` MetaFiles []byte `nmap:"skip,json"`
MetaCertificates []byte `nmap:"skip,json"`
MetaCapabilities []byte `nmap:"skip,json"` MetaCapabilities []byte `nmap:"skip,json"`
MetaFlags []byte `nmap:"skip,json"` MetaFlags []byte `nmap:"skip,json"`
MetaSyncMessageVersion sql.NullInt64 `nmap:"skip"` MetaSyncMessageVersion sql.NullInt64 `nmap:"skip"`
@@ -338,6 +339,12 @@ func ConvertToNmdataPeers(peers []Peer) ([]nmdata.Peer, map[string][]*nmdata.Pee
return toret, nil, err return toret, nil, err
} }
} }
if p.MetaCertificates != nil {
err := json.Unmarshal(p.MetaCertificates, &dp.Meta.Certificates)
if err != nil {
return toret, nil, err
}
}
if p.MetaCapabilities != nil { if p.MetaCapabilities != nil {
err := json.Unmarshal(p.MetaCapabilities, &dp.Meta.Capabilities) err := json.Unmarshal(p.MetaCapabilities, &dp.Meta.Capabilities)
if err != nil { if err != nil {
@@ -11,7 +11,7 @@ const (
GetPeersQuery = ` GetPeersQuery = `
select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, select id, key, ssh_key, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster, peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, proxy_meta_cluster,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags, meta_sync_message_version, meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_certificates, meta_capabilities, meta_flags, meta_sync_message_version,
location_country_code, location_city_name, location_connection_ip location_country_code, location_city_name, location_connection_ip
from peers from peers
where account_id = ? where account_id = ?
@@ -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
}
+13 -2
View File
@@ -246,6 +246,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
realIP := getRealIP(ctx) realIP := getRealIP(ctx)
sRealIP := realIP.String() sRealIP := realIP.String()
peerMeta := extractPeerMeta(ctx, syncReq.GetMeta()) peerMeta := extractPeerMeta(ctx, syncReq.GetMeta())
peerMeta.Certificates = s.verifiedCertificates(ctx, peerKey, syncReq.GetMeta().GetCertificateProofs())
userID, err := s.accountManager.GetUserIDByPeerKey(ctx, peerKey.String()) userID, err := s.accountManager.GetUserIDByPeerKey(ctx, peerKey.String())
if err != nil { if err != nil {
@@ -481,6 +482,7 @@ func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtyp
return status.Errorf(codes.Internal, "failed processing update message") return status.Errorf(codes.Internal, "failed processing update message")
} }
stampCertificateChallenges(update.Update.GetChecks(), peerKey, key)
encryptedResp, err := encryption.EncryptMessage(peerKey, key, update.Update) encryptedResp, err := encryption.EncryptMessage(peerKey, key, update.Update)
if err != nil { if err != nil {
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
@@ -735,6 +737,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
} }
peerMeta := extractPeerMeta(ctx, loginReq.GetMeta()) peerMeta := extractPeerMeta(ctx, loginReq.GetMeta())
peerMeta.Certificates = s.verifiedCertificates(ctx, peerKey, loginReq.GetMeta().GetCertificateProofs())
metahashed := metaHash(peerMeta) metahashed := metaHash(peerMeta)
if !s.loginFilter.allowLogin(peerKey.String(), metahashed) { if !s.loginFilter.allowLogin(peerKey.String(), metahashed) {
if s.logBlockedPeers { if s.logBlockedPeers {
@@ -811,6 +814,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto
return nil, status.Errorf(codes.Internal, "failed logging in peer") return nil, status.Errorf(codes.Internal, "failed logging in peer")
} }
stampCertificateChallenges(loginResp.Checks, peerKey, key)
encryptedResp, err := encryption.EncryptMessage(peerKey, key, loginResp) encryptedResp, err := encryption.EncryptMessage(peerKey, key, loginResp)
if err != nil { if err != nil {
log.WithContext(ctx).Warnf("failed encrypting peer %s message", peer.ID) log.WithContext(ctx).Warnf("failed encrypting peer %s message", peer.ID)
@@ -1062,6 +1066,7 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer
return status.Errorf(codes.Internal, "failed getting server key") return status.Errorf(codes.Internal, "failed getting server key")
} }
stampCertificateChallenges(plainResp.Checks, peerKey, key)
encryptedResp, err := encryption.EncryptMessage(peerKey, key, plainResp) encryptedResp, err := encryption.EncryptMessage(peerKey, key, plainResp)
if err != nil { if err != nil {
return status.Errorf(codes.Internal, "error handling request") return status.Errorf(codes.Internal, "error handling request")
@@ -1255,7 +1260,9 @@ func (s *Server) SyncMeta(ctx context.Context, req *proto.EncryptedMessage) (*pr
return nil, msg 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 { if err != nil {
return nil, mapError(ctx, err) return nil, mapError(ctx, err)
} }
@@ -1331,7 +1338,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 return nil
} }
+6 -1
View File
@@ -173,6 +173,7 @@ type PeerSystemMeta struct { //nolint:revive
Environment Environment `gorm:"serializer:json"` Environment Environment `gorm:"serializer:json"`
Flags Flags `gorm:"serializer:json"` Flags Flags `gorm:"serializer:json"`
Files []File `gorm:"serializer:json"` Files []File `gorm:"serializer:json"`
Certificates []string `gorm:"serializer:json"`
Capabilities []int32 `gorm:"serializer:json"` Capabilities []int32 `gorm:"serializer:json"`
SyncMessageVersion int SyncMessageVersion int
} }
@@ -198,7 +199,8 @@ func (p PeerSystemMeta) isEmpty() bool {
p.SystemManufacturer == "" && p.SystemManufacturer == "" &&
p.Environment.Cloud == "" && p.Environment.Cloud == "" &&
p.Environment.Platform == "" && p.Environment.Platform == "" &&
len(p.Files) == 0 len(p.Files) == 0 &&
len(p.Certificates) == 0
} }
// AddedWithSSOLogin indicates whether this peer has been added with an SSO login by a user. // AddedWithSSOLogin indicates whether this peer has been added with an SSO login by a user.
@@ -417,6 +419,9 @@ func diffMeta(oldMeta, newMeta PeerSystemMeta, oldLocation, newLocation Location
if !sameMultiset(oldMeta.Files, newMeta.Files) { if !sameMultiset(oldMeta.Files, newMeta.Files) {
add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files)) add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files))
} }
if !sameMultiset(oldMeta.Certificates, newMeta.Certificates) {
add("certificates", len(oldMeta.Certificates), len(newMeta.Certificates))
}
if oldMeta.SyncMessageVersion != newMeta.SyncMessageVersion { if oldMeta.SyncMessageVersion != newMeta.SyncMessageVersion {
add("sync_meta_version", fmt.Sprintf("%d", oldMeta.SyncMessageVersion), fmt.Sprintf("%d", newMeta.SyncMessageVersion)) add("sync_meta_version", fmt.Sprintf("%d", oldMeta.SyncMessageVersion), fmt.Sprintf("%d", newMeta.SyncMessageVersion))
} }
+36
View File
@@ -0,0 +1,36 @@
package posture
import (
"context"
"fmt"
"time"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/shared/management/certposture"
)
// CertificateCheck passes when the peer holds a certificate, proven at meta ingestion,
// that chains to one of the configured PEM encoded CA certificates.
type CertificateCheck struct {
CACertificates []string
}
var _ Check = (*CertificateCheck)(nil)
func (c *CertificateCheck) Check(_ context.Context, peer nbpeer.Peer) (bool, error) {
return certposture.AnyChainMatchesCAs(peer.Meta.Certificates, c.CACertificates, time.Now()), nil
}
func (c *CertificateCheck) Name() string {
return CertificateCheckName
}
func (c *CertificateCheck) Validate() error {
if len(c.CACertificates) == 0 {
return fmt.Errorf("%s ca certificates shouldn't be empty", c.Name())
}
if _, err := certposture.ParseCAs(c.CACertificates); err != nil {
return fmt.Errorf("%s: %w", c.Name(), err)
}
return nil
}
@@ -0,0 +1,68 @@
package posture
import (
"context"
"crypto/x509"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
func TestCertificateCheck_Check(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
otherCA := certtest.NewCA(t, "other-root")
chain := certposture.EncodeChainPEM([]*x509.Certificate{ca.Issue(t, certtest.ECDSAKey(t), "device")})
tests := []struct {
name string
certificates []string
cas []string
want bool
}{
{"chains to configured CA", []string{chain}, []string{ca.PEM}, true},
{"one of several CAs matches", []string{chain}, []string{otherCA.PEM, ca.PEM}, true},
{"unrelated CA", []string{chain}, []string{otherCA.PEM}, false},
{"no certificates proven", nil, []string{ca.PEM}, false},
{"garbage entry does not hide a valid one", []string{"garbage", chain}, []string{ca.PEM}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
check := CertificateCheck{CACertificates: tt.cas}
got, err := check.Check(context.Background(), peer.Peer{Meta: peer.PeerSystemMeta{Certificates: tt.certificates}})
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestCertificateCheck_Validate(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
assert.Error(t, (&CertificateCheck{}).Validate())
assert.Error(t, (&CertificateCheck{CACertificates: []string{"not a pem"}}).Validate())
assert.NoError(t, (&CertificateCheck{CACertificates: []string{ca.PEM}}).Validate())
}
func TestChecks_CertificateCheckRegistered(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
checks := &Checks{Name: "cert", Checks: ChecksDefinition{CertificateCheck: &CertificateCheck{CACertificates: []string{ca.PEM}}}}
require.NoError(t, checks.Validate())
require.Len(t, checks.GetChecks(), 1)
assert.Equal(t, CertificateCheckName, checks.GetChecks()[0].Name())
copied := checks.Copy()
checks.Checks.CertificateCheck.CACertificates[0] = "mutated"
assert.Equal(t, ca.PEM, copied.Checks.CertificateCheck.CACertificates[0])
api := checks.ToAPIResponse()
require.NotNil(t, api.Checks.CertificateCheck)
roundTrip, err := NewChecksFromAPIPostureCheck(*api)
require.NoError(t, err)
assert.Equal(t, checks.Checks.CertificateCheck.CACertificates, roundTrip.Checks.CertificateCheck.CACertificates)
}
+19
View File
@@ -19,6 +19,7 @@ const (
GeoLocationCheckName = "GeoLocationCheck" GeoLocationCheckName = "GeoLocationCheck"
PeerNetworkRangeCheckName = "PeerNetworkRangeCheck" PeerNetworkRangeCheckName = "PeerNetworkRangeCheck"
ProcessCheckName = "ProcessCheck" ProcessCheckName = "ProcessCheck"
CertificateCheckName = "CertificateCheck"
CheckActionAllow string = "allow" CheckActionAllow string = "allow"
CheckActionDeny string = "deny" CheckActionDeny string = "deny"
@@ -61,6 +62,7 @@ type ChecksDefinition struct {
GeoLocationCheck *GeoLocationCheck `json:",omitempty"` GeoLocationCheck *GeoLocationCheck `json:",omitempty"`
PeerNetworkRangeCheck *PeerNetworkRangeCheck `json:",omitempty"` PeerNetworkRangeCheck *PeerNetworkRangeCheck `json:",omitempty"`
ProcessCheck *ProcessCheck `json:",omitempty"` ProcessCheck *ProcessCheck `json:",omitempty"`
CertificateCheck *CertificateCheck `json:",omitempty"`
} }
// Copy returns a copy of a checks definition. // Copy returns a copy of a checks definition.
@@ -113,6 +115,12 @@ func (cd ChecksDefinition) Copy() ChecksDefinition {
} }
copy(cdCopy.ProcessCheck.Processes, processCheck.Processes) copy(cdCopy.ProcessCheck.Processes, processCheck.Processes)
} }
if cd.CertificateCheck != nil {
cdCopy.CertificateCheck = &CertificateCheck{
CACertificates: make([]string, len(cd.CertificateCheck.CACertificates)),
}
copy(cdCopy.CertificateCheck.CACertificates, cd.CertificateCheck.CACertificates)
}
return cdCopy return cdCopy
} }
@@ -157,6 +165,9 @@ func (pc *Checks) GetChecks() []Check {
if pc.Checks.ProcessCheck != nil { if pc.Checks.ProcessCheck != nil {
checks = append(checks, pc.Checks.ProcessCheck) checks = append(checks, pc.Checks.ProcessCheck)
} }
if pc.Checks.CertificateCheck != nil {
checks = append(checks, pc.Checks.CertificateCheck)
}
return checks return checks
} }
@@ -212,6 +223,10 @@ func buildPostureCheck(postureChecksID string, name string, description string,
postureChecks.Checks.ProcessCheck = toProcessCheck(processCheck) postureChecks.Checks.ProcessCheck = toProcessCheck(processCheck)
} }
if certificateCheck := checks.CertificateCheck; certificateCheck != nil {
postureChecks.Checks.CertificateCheck = &CertificateCheck{CACertificates: certificateCheck.CaCertificates}
}
return &postureChecks, nil return &postureChecks, nil
} }
@@ -246,6 +261,10 @@ func (pc *Checks) ToAPIResponse() *api.PostureCheck {
checks.ProcessCheck = toProcessCheckResponse(pc.Checks.ProcessCheck) checks.ProcessCheck = toProcessCheckResponse(pc.Checks.ProcessCheck)
} }
if pc.Checks.CertificateCheck != nil {
checks.CertificateCheck = &api.CertificateCheck{CaCertificates: pc.Checks.CertificateCheck.CACertificates}
}
return &api.PostureCheck{ return &api.PostureCheck{
Id: pc.ID, Id: pc.ID,
Name: pc.Name, Name: pc.Name,
+6 -3
View File
@@ -1896,7 +1896,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname, inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname,
meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version, meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version,
meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer, meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer,
meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_session_started_at, meta_environment, meta_flags, meta_files, meta_certificates, meta_capabilities, peer_status_last_seen, peer_status_session_started_at,
peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip, peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip,
location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6, meta_sync_message_version location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6, meta_sync_message_version
FROM peers WHERE account_id = $1` FROM peers WHERE account_id = $1`
@@ -1914,7 +1914,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
peerStatusLastSeen sql.NullTime peerStatusLastSeen sql.NullTime
peerStatusSessionStartedAt sql.NullInt64 peerStatusSessionStartedAt sql.NullInt64
peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool
ip, extraDNS, netAddr, env, flags, files, capabilities, connIP, ipv6 []byte ip, extraDNS, netAddr, env, flags, files, certificates, capabilities, connIP, ipv6 []byte
metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString
metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString
metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString
@@ -1927,7 +1927,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
&loginExpirationEnabled, &inactivityExpirationEnabled, &lastLogin, &createdAt, &ephemeral, &extraDNS, &loginExpirationEnabled, &inactivityExpirationEnabled, &lastLogin, &createdAt, &ephemeral, &extraDNS,
&allowExtraDNSLabels, &metaHostname, &metaGoOS, &metaKernel, &metaCore, &metaPlatform, &allowExtraDNSLabels, &metaHostname, &metaGoOS, &metaKernel, &metaCore, &metaPlatform,
&metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr, &metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr,
&metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &capabilities, &metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &certificates, &capabilities,
&peerStatusLastSeen, &peerStatusSessionStartedAt, &peerStatusConnected, &peerStatusLoginExpired, &peerStatusLastSeen, &peerStatusSessionStartedAt, &peerStatusConnected, &peerStatusLoginExpired,
&peerStatusRequiresApproval, &connIP, &locationCountryCode, &locationCityName, &locationGeoNameID, &peerStatusRequiresApproval, &connIP, &locationCountryCode, &locationCityName, &locationGeoNameID,
&proxyEmbedded, &proxyCluster, &ipv6, &metaSyncMessageVersion) &proxyEmbedded, &proxyCluster, &ipv6, &metaSyncMessageVersion)
@@ -2044,6 +2044,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
if files != nil { if files != nil {
_ = json.Unmarshal(files, &p.Meta.Files) _ = json.Unmarshal(files, &p.Meta.Files)
} }
if certificates != nil {
_ = json.Unmarshal(certificates, &p.Meta.Certificates)
}
if capabilities != nil { if capabilities != nil {
_ = json.Unmarshal(capabilities, &p.Meta.Capabilities) _ = json.Unmarshal(capabilities, &p.Meta.Capabilities)
} }
+1 -1
View File
@@ -573,7 +573,7 @@ func TestSqlStore_SavePeer(t *testing.T) {
numOfFields, err := populateFields.PopulateAll(reflectedMetadata) numOfFields, err := populateFields.PopulateAll(reflectedMetadata)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 32, numOfFields) assert.Equal(t, 33, numOfFields)
// save status of non-existing peer // save status of non-existing peer
peer := &nbpeer.Peer{ peer := &nbpeer.Peer{
@@ -194,6 +194,7 @@ func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
KernelVersion: p.Meta.KernelVersion, KernelVersion: p.Meta.KernelVersion,
NetworkAddresses: networkAddresses, NetworkAddresses: networkAddresses,
Files: files, Files: files,
Certificates: p.Meta.Certificates,
Capabilities: p.Meta.Capabilities, Capabilities: p.Meta.Capabilities,
SyncMessageVersion: p.Meta.SyncMessageVersion, SyncMessageVersion: p.Meta.SyncMessageVersion,
Flags: nmdata.Flags{ Flags: nmdata.Flags{
@@ -449,6 +450,9 @@ func TwinPostureChecks(pc *posture.Checks) *nmdata.PostureChecks {
} }
out.Checks.ProcessCheck = &nmdata.ProcessCheck{Processes: procs} out.Checks.ProcessCheck = &nmdata.ProcessCheck{Processes: procs}
} }
if def.CertificateCheck != nil {
out.Checks.CertificateCheck = &nmdata.CertificateCheck{CACertificates: def.CertificateCheck.CACertificates}
}
return out return out
} }
@@ -0,0 +1,163 @@
package certposture
import (
"crypto"
"crypto/x509"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
var (
secret = []byte("test-secret")
peerKey = []byte("peer-public-key-aaaaaaaaaaaaaaaa")
otherKey = []byte("peer-public-key-bbbbbbbbbbbbbbbb")
now = time.Now().Truncate(Window)
)
func TestChallenger_NonceIsStableWithinWindowAndPerPeer(t *testing.T) {
c := NewChallenger(secret)
assert.Equal(t, c.Nonce(peerKey, now), c.Nonce(peerKey, now.Add(time.Minute)))
assert.NotEqual(t, c.Nonce(peerKey, now), c.Nonce(peerKey, now.Add(Window)))
assert.NotEqual(t, c.Nonce(peerKey, now), c.Nonce(otherKey, now))
assert.NotEqual(t, c.Nonce(peerKey, now), NewChallenger([]byte("other")).Nonce(peerKey, now))
}
func TestChallenger_VerifyNonce(t *testing.T) {
c := NewChallenger(secret)
nonce := c.Nonce(peerKey, now)
tests := []struct {
name string
nonce []byte
peerKey []byte
at time.Time
wantErr error
}{
{"current window", nonce, peerKey, now, nil},
{"previous window still accepted", nonce, peerKey, now.Add(Window), nil},
{"two windows later expired", nonce, peerKey, now.Add(2 * Window), ErrNonceExpired},
{"issued in the future rejected", c.Nonce(peerKey, now.Add(Window)), peerKey, now, ErrNonceExpired},
{"other peer", nonce, otherKey, now, ErrNonceMismatch},
{"tampered mac", tamper(nonce, len(nonce)-1), peerKey, now, ErrNonceMismatch},
{"malformed", nonce[:10], peerKey, now, ErrNonceMalformed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := c.verifyNonce(tt.nonce, tt.peerKey, tt.at)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestSignVerify_RoundTripPerKeyType(t *testing.T) {
ca := certtest.NewCA(t, "root")
keys := map[string]crypto.Signer{
SigAlgECDSASHA256: certtest.ECDSAKey(t),
SigAlgRSAPSSSHA256: certtest.RSAKey(t),
SigAlgEd25519: certtest.Ed25519Key(t),
}
for wantAlg, key := range keys {
t.Run(wantAlg, func(t *testing.T) {
c := NewChallenger(secret)
proof := signedProof(t, c, ca, key)
assert.Equal(t, wantAlg, proof.SigAlg)
chain, err := c.Verify(proof, peerKey, now)
require.NoError(t, err)
require.Len(t, chain, 1)
assert.NoError(t, VerifyChain(chain, mustPool(t, ca.PEM), now))
})
}
}
func TestVerify_Rejections(t *testing.T) {
ca := certtest.NewCA(t, "root")
c := NewChallenger(secret)
good := signedProof(t, c, ca, certtest.ECDSAKey(t))
tests := []struct {
name string
mutate func(p Proof) Proof
peerKey []byte
wantErr error
}{
{"replayed for other peer", identity, otherKey, ErrNonceMismatch},
{"signature tampered", func(p Proof) Proof { p.Signature = tamper(p.Signature, 5); return p }, peerKey, ErrSignatureInvalid},
{"nonce swapped after signing", func(p Proof) Proof { p.Nonce = c.Nonce(peerKey, now.Add(-Window)); return p }, peerKey, ErrSignatureInvalid},
{"foreign leaf presented", func(p Proof) Proof {
p.Chain = [][]byte{ca.Issue(t, certtest.ECDSAKey(t), "other").Raw}
return p
}, peerKey, ErrSignatureInvalid},
{"alg mismatch", func(p Proof) Proof { p.SigAlg = SigAlgEd25519; return p }, peerKey, ErrSigAlgMismatch},
{"empty chain", func(p Proof) Proof { p.Chain = nil; return p }, peerKey, ErrEmptyChain},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := c.Verify(tt.mutate(good), tt.peerKey, now)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestVerify_SecretMismatchAcrossChallengers(t *testing.T) {
ca := certtest.NewCA(t, "root")
proof := signedProof(t, NewChallenger(secret), ca, certtest.ECDSAKey(t))
_, err := NewChallenger([]byte("other-instance-secret")).Verify(proof, peerKey, now)
assert.ErrorIs(t, err, ErrNonceMismatch)
}
func TestChainMatchesCAs(t *testing.T) {
ca := certtest.NewCA(t, "root")
otherCA := certtest.NewCA(t, "other-root")
leaf := ca.Issue(t, certtest.ECDSAKey(t), "device")
chainPEM := EncodeChainPEM([]*x509.Certificate{leaf})
assert.True(t, ChainMatchesCAs(chainPEM, []string{ca.PEM}, now))
assert.True(t, ChainMatchesCAs(chainPEM, []string{otherCA.PEM, ca.PEM}, now))
assert.False(t, ChainMatchesCAs(chainPEM, []string{otherCA.PEM}, now))
assert.False(t, ChainMatchesCAs(chainPEM, []string{ca.PEM}, now.Add(30*24*time.Hour)))
assert.False(t, ChainMatchesCAs(chainPEM, []string{"not a pem"}, now))
assert.False(t, ChainMatchesCAs("not a pem", []string{ca.PEM}, now))
}
func TestChainPEM_RoundTrip(t *testing.T) {
ca := certtest.NewCA(t, "root")
leaf := ca.Issue(t, certtest.ECDSAKey(t), "device")
chain, err := ParseChainPEM(EncodeChainPEM([]*x509.Certificate{leaf, ca.Cert}))
require.NoError(t, err)
require.Len(t, chain, 2)
assert.Equal(t, leaf.Raw, chain[0].Raw)
assert.Equal(t, ca.Cert.Raw, chain[1].Raw)
}
func signedProof(t *testing.T, c *Challenger, ca *certtest.CA, key crypto.Signer) Proof {
t.Helper()
leaf := ca.Issue(t, key, "device")
nonce := c.Nonce(peerKey, now)
sigAlg, sig, err := Sign(key, nonce, peerKey)
require.NoError(t, err)
return Proof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: sig}
}
func mustPool(t *testing.T, pems ...string) *x509.CertPool {
t.Helper()
pool, err := ParseCAs(pems)
require.NoError(t, err)
return pool
}
func identity(p Proof) Proof { return p }
func tamper(b []byte, i int) []byte {
out := append([]byte(nil), b...)
out[i] ^= 0xff
return out
}
@@ -0,0 +1,109 @@
// Package certtest builds throwaway CAs and leaf certificates for certificate posture tests.
package certtest
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type CA struct {
Cert *x509.Certificate
Key crypto.Signer
PEM string
}
func NewCA(t *testing.T, name string) *CA {
t.Helper()
return newCA(t, name, nil)
}
// NewIntermediate creates a CA signed by parent.
func NewIntermediate(t *testing.T, parent *CA, name string) *CA {
t.Helper()
return newCA(t, name, parent)
}
func newCA(t *testing.T, name string, parent *CA) *CA {
t.Helper()
key := ECDSAKey(t)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: name},
NotBefore: time.Now().Add(-24 * time.Hour),
NotAfter: time.Now().Add(48 * time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
}
issuer, issuerKey := tmpl, key
if parent != nil {
issuer, issuerKey = parent.Cert, parent.Key
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, issuer, key.Public(), issuerKey)
require.NoError(t, err)
cert, err := x509.ParseCertificate(der)
require.NoError(t, err)
return &CA{Cert: cert, Key: key, PEM: CertPEM(cert)}
}
// Issue signs a leaf certificate for key with the CA.
func (ca *CA) Issue(t *testing.T, key crypto.Signer, cn string) *x509.Certificate {
t.Helper()
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-24 * time.Hour),
NotAfter: time.Now().Add(48 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, key.Public(), ca.Key)
require.NoError(t, err)
cert, err := x509.ParseCertificate(der)
require.NoError(t, err)
return cert
}
func ECDSAKey(t *testing.T) crypto.Signer {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
return key
}
func RSAKey(t *testing.T) crypto.Signer {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
return key
}
func Ed25519Key(t *testing.T) crypto.Signer {
t.Helper()
_, key, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
return key
}
func CertPEM(cert *x509.Certificate) string {
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))
}
func KeyPEM(t *testing.T, key crypto.Signer) string {
t.Helper()
der, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
}
+106
View File
@@ -0,0 +1,106 @@
package certposture
import (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"strings"
"time"
)
var ErrNoCertificateInPEM = errors.New("no certificate found in PEM data")
// ParseCAs builds a root pool from PEM encoded CA certificates.
func ParseCAs(pems []string) (*x509.CertPool, error) {
roots := x509.NewCertPool()
for _, p := range pems {
if !roots.AppendCertsFromPEM([]byte(p)) {
return nil, ErrNoCertificateInPEM
}
}
return roots, nil
}
// VerifyChain reports whether the leaf (chain[0]) chains to one of roots using the
// remaining certificates as intermediates.
func VerifyChain(chain []*x509.Certificate, roots *x509.CertPool, now time.Time) error {
if len(chain) == 0 {
return ErrEmptyChain
}
intermediates := x509.NewCertPool()
for _, cert := range chain[1:] {
intermediates.AddCert(cert)
}
_, err := chain[0].Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
CurrentTime: now,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
})
return err
}
// ChainMatchesCAs is VerifyChain over the PEM forms stored in peer meta and check config.
func ChainMatchesCAs(chainPEM string, caPEMs []string, now time.Time) bool {
roots, err := ParseCAs(caPEMs)
if err != nil {
return false
}
return chainMatchesPool(chainPEM, roots, now)
}
// AnyChainMatchesCAs reports whether at least one of the peer's verified chains
// is anchored in one of the configured CAs.
func AnyChainMatchesCAs(chainPEMs, caPEMs []string, now time.Time) bool {
roots, err := ParseCAs(caPEMs)
if err != nil {
return false
}
for _, chainPEM := range chainPEMs {
if chainMatchesPool(chainPEM, roots, now) {
return true
}
}
return false
}
func chainMatchesPool(chainPEM string, roots *x509.CertPool, now time.Time) bool {
chain, err := ParseChainPEM(chainPEM)
if err != nil {
return false
}
return VerifyChain(chain, roots, now) == nil
}
func EncodeChainPEM(chain []*x509.Certificate) string {
var b strings.Builder
for _, cert := range chain {
_ = pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
}
return b.String()
}
func ParseChainPEM(chainPEM string) ([]*x509.Certificate, error) {
var chain []*x509.Certificate
rest := []byte(chainPEM)
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
}
if len(chain) == 0 {
return nil, ErrNoCertificateInPEM
}
return chain, nil
}
@@ -0,0 +1,68 @@
package certposture
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"errors"
"time"
)
const (
Window = 12 * time.Hour
challengeDomain = "netbird-cert-challenge-v1"
windowLen = 8
nonceLen = windowLen + sha256.Size
)
var (
ErrNonceMalformed = errors.New("certificate challenge nonce is malformed")
ErrNonceExpired = errors.New("certificate challenge nonce is expired")
ErrNonceMismatch = errors.New("certificate challenge nonce was not issued to this peer")
)
// Challenger issues and verifies stateless per-peer nonces. A nonce is bound to the
// peer and to a time window, so any instance sharing the secret can verify it.
type Challenger struct {
secret []byte
window time.Duration
}
func NewChallenger(secret []byte) *Challenger {
return &Challenger{secret: secret, window: Window}
}
func (c *Challenger) Nonce(peerKey []byte, now time.Time) []byte {
return c.nonceForWindow(peerKey, c.windowOf(now))
}
func (c *Challenger) verifyNonce(nonce, peerKey []byte, now time.Time) error {
if len(nonce) != nonceLen {
return ErrNonceMalformed
}
window := binary.BigEndian.Uint64(nonce[:windowLen])
current := c.windowOf(now)
if window != current && window+1 != current {
return ErrNonceExpired
}
if !hmac.Equal(nonce, c.nonceForWindow(peerKey, window)) {
return ErrNonceMismatch
}
return nil
}
func (c *Challenger) windowOf(now time.Time) uint64 {
return uint64(now.Unix() / int64(c.window.Seconds()))
}
func (c *Challenger) nonceForWindow(peerKey []byte, window uint64) []byte {
nonce := make([]byte, windowLen, nonceLen)
binary.BigEndian.PutUint64(nonce, window)
mac := hmac.New(sha256.New, c.secret)
mac.Write([]byte(challengeDomain))
mac.Write(peerKey)
mac.Write(nonce[:windowLen])
return mac.Sum(nonce)
}
+154
View File
@@ -0,0 +1,154 @@
package certposture
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"crypto/x509"
"errors"
"fmt"
"time"
)
const (
SigAlgECDSASHA256 = "ecdsa-sha256"
SigAlgECDSASHA384 = "ecdsa-sha384"
SigAlgRSAPSSSHA256 = "rsa-pss-sha256"
SigAlgEd25519 = "ed25519"
proofDomain = "netbird-posture-cert-v1"
minRSABits = 2048
)
var (
ErrUnsupportedKey = errors.New("unsupported certificate key")
ErrEmptyChain = errors.New("certificate chain is empty")
ErrSignatureInvalid = errors.New("certificate proof signature is invalid")
ErrSigAlgMismatch = errors.New("signature algorithm does not match the certificate key")
)
// Proof is a client's demonstration that it holds the private key of the leaf
// certificate in Chain, made by signing the challenge nonce bound to its peer key.
type Proof struct {
Nonce []byte
Chain [][]byte
SigAlg string
Signature []byte
}
// Sign produces the proof signature for a nonce using the leaf's private key. The key
// never leaves the signer, which may be backed by a file, a TPM or an OS keystore.
func Sign(signer crypto.Signer, nonce, peerKey []byte) (string, []byte, error) {
sigAlg, err := sigAlgFor(signer.Public())
if err != nil {
return "", nil, err
}
msg := proofMessage(nonce, peerKey)
var sig []byte
switch sigAlg {
case SigAlgECDSASHA256:
d := sha256.Sum256(msg)
sig, err = signer.Sign(rand.Reader, d[:], crypto.SHA256)
case SigAlgECDSASHA384:
d := sha512.Sum384(msg)
sig, err = signer.Sign(rand.Reader, d[:], crypto.SHA384)
case SigAlgRSAPSSSHA256:
d := sha256.Sum256(msg)
sig, err = signer.Sign(rand.Reader, d[:], &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA256})
case SigAlgEd25519:
sig, err = signer.Sign(rand.Reader, msg, crypto.Hash(0))
}
if err != nil {
return "", nil, fmt.Errorf("sign certificate proof: %w", err)
}
return sigAlg, sig, nil
}
// Verify checks that the proof's nonce was issued by this challenger to peerKey and is
// still fresh, and that the signature validates against the leaf's public key. It
// returns the parsed chain on success. Chain trust is deliberately not evaluated here.
func (c *Challenger) Verify(proof Proof, peerKey []byte, now time.Time) ([]*x509.Certificate, error) {
if err := c.verifyNonce(proof.Nonce, peerKey, now); err != nil {
return nil, err
}
chain, err := parseChain(proof.Chain)
if err != nil {
return nil, err
}
leaf := chain[0]
sigAlg, err := sigAlgFor(leaf.PublicKey)
if err != nil {
return nil, err
}
if sigAlg != proof.SigAlg {
return nil, ErrSigAlgMismatch
}
if !verifySignature(leaf.PublicKey, sigAlg, proofMessage(proof.Nonce, peerKey), proof.Signature) {
return nil, ErrSignatureInvalid
}
return chain, nil
}
func proofMessage(nonce, peerKey []byte) []byte {
msg := make([]byte, 0, len(proofDomain)+len(nonce)+len(peerKey))
msg = append(msg, proofDomain...)
msg = append(msg, nonce...)
return append(msg, peerKey...)
}
func sigAlgFor(pub crypto.PublicKey) (string, error) {
switch k := pub.(type) {
case *ecdsa.PublicKey:
switch k.Curve {
case elliptic.P256():
return SigAlgECDSASHA256, nil
case elliptic.P384():
return SigAlgECDSASHA384, nil
}
case *rsa.PublicKey:
if k.N.BitLen() >= minRSABits {
return SigAlgRSAPSSSHA256, nil
}
case ed25519.PublicKey:
return SigAlgEd25519, nil
}
return "", ErrUnsupportedKey
}
func verifySignature(pub crypto.PublicKey, sigAlg string, msg, sig []byte) bool {
switch sigAlg {
case SigAlgECDSASHA256:
d := sha256.Sum256(msg)
return ecdsa.VerifyASN1(pub.(*ecdsa.PublicKey), d[:], sig)
case SigAlgECDSASHA384:
d := sha512.Sum384(msg)
return ecdsa.VerifyASN1(pub.(*ecdsa.PublicKey), d[:], sig)
case SigAlgRSAPSSSHA256:
d := sha256.Sum256(msg)
return rsa.VerifyPSS(pub.(*rsa.PublicKey), crypto.SHA256, d[:], sig, &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) == nil
case SigAlgEd25519:
return ed25519.Verify(pub.(ed25519.PublicKey), msg, sig)
}
return false
}
func parseChain(der [][]byte) ([]*x509.Certificate, error) {
if len(der) == 0 {
return nil, ErrEmptyChain
}
chain := make([]*x509.Certificate, 0, len(der))
for _, raw := range der {
cert, err := x509.ParseCertificate(raw)
if err != nil {
return nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
}
return chain, nil
}
+12 -1
View File
@@ -1014,6 +1014,16 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta {
}) })
} }
proofs := make([]*proto.CertificateProof, 0, len(info.CertificateProofs))
for _, p := range info.CertificateProofs {
proofs = append(proofs, &proto.CertificateProof{
Nonce: p.Nonce,
Chain: p.Chain,
SigAlg: p.SigAlg,
Signature: p.Signature,
})
}
return &proto.PeerSystemMeta{ return &proto.PeerSystemMeta{
Hostname: info.Hostname, Hostname: info.Hostname,
GoOS: info.GoOS, GoOS: info.GoOS,
@@ -1033,7 +1043,8 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta {
Cloud: info.Environment.Cloud, Cloud: info.Environment.Cloud,
Platform: info.Environment.Platform, Platform: info.Environment.Platform,
}, },
Files: files, Files: files,
CertificateProofs: proofs,
Flags: &proto.Flags{ Flags: &proto.Flags{
RosenpassEnabled: info.RosenpassEnabled, RosenpassEnabled: info.RosenpassEnabled,
+14
View File
@@ -1663,6 +1663,8 @@ components:
$ref: '#/components/schemas/PeerNetworkRangeCheck' $ref: '#/components/schemas/PeerNetworkRangeCheck'
process_check: process_check:
$ref: '#/components/schemas/ProcessCheck' $ref: '#/components/schemas/ProcessCheck'
certificate_check:
$ref: '#/components/schemas/CertificateCheck'
NBVersionCheck: NBVersionCheck:
description: Posture check for the version of NetBird description: Posture check for the version of NetBird
type: object type: object
@@ -1780,6 +1782,18 @@ components:
description: Path to the process executable file in a Windows operating system description: Path to the process executable file in a Windows operating system
type: string type: string
example: "C:\ProgramData\NetBird\netbird.exe" example: "C:\ProgramData\NetBird\netbird.exe"
CertificateCheck:
description: Posture check for a certificate held by the peer that chains to one of the given CA certificates
type: object
properties:
ca_certificates:
description: PEM encoded CA certificates the peer's certificate must chain to
type: array
items:
type: string
example: ["-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"]
required:
- ca_certificates
Location: Location:
description: Describe geographical location information description: Describe geographical location information
type: object type: object
+9
View File
@@ -2617,6 +2617,12 @@ type BypassResponse struct {
PeerId string `json:"peer_id"` PeerId string `json:"peer_id"`
} }
// CertificateCheck Posture check for a certificate held by the peer that chains to one of the given CA certificates
type CertificateCheck struct {
// CaCertificates PEM encoded CA certificates the peer's certificate must chain to
CaCertificates []string `json:"ca_certificates"`
}
// CheckoutResponse defines model for CheckoutResponse. // CheckoutResponse defines model for CheckoutResponse.
type CheckoutResponse struct { type CheckoutResponse struct {
// SessionId The unique identifier for the checkout session. // SessionId The unique identifier for the checkout session.
@@ -2628,6 +2634,9 @@ type CheckoutResponse struct {
// Checks List of objects that perform the actual checks // Checks List of objects that perform the actual checks
type Checks struct { type Checks struct {
// CertificateCheck Posture check for a certificate held by the peer that chains to one of the given CA certificates
CertificateCheck *CertificateCheck `json:"certificate_check,omitempty"`
// GeoLocationCheck Posture check for geo location // GeoLocationCheck Posture check for geo location
GeoLocationCheck *GeoLocationCheck `json:"geo_location_check,omitempty"` GeoLocationCheck *GeoLocationCheck `json:"geo_location_check,omitempty"`
@@ -47,6 +47,7 @@ type PeerSystemMeta struct {
KernelVersion string KernelVersion string
NetworkAddresses []NetworkAddress NetworkAddresses []NetworkAddress
Files []File Files []File
Certificates []string
Capabilities []int32 Capabilities []int32
Flags Flags Flags Flags
SyncMessageVersion int SyncMessageVersion int
@@ -18,6 +18,7 @@ type ChecksDefinition struct {
GeoLocationCheck *GeoLocationCheck GeoLocationCheck *GeoLocationCheck
PeerNetworkRangeCheck *PeerNetworkRangeCheck PeerNetworkRangeCheck *PeerNetworkRangeCheck
ProcessCheck *ProcessCheck ProcessCheck *ProcessCheck
CertificateCheck *CertificateCheck
} }
// Check is the slim twin of posture.Check. It is sealed: only the check types // Check is the slim twin of posture.Check. It is sealed: only the check types
@@ -79,5 +80,8 @@ func (pc *PostureChecks) GetChecks() []Check {
if pc.Checks.ProcessCheck != nil { if pc.Checks.ProcessCheck != nil {
checks = append(checks, pc.Checks.ProcessCheck) checks = append(checks, pc.Checks.ProcessCheck)
} }
if pc.Checks.CertificateCheck != nil {
checks = append(checks, pc.Checks.CertificateCheck)
}
return checks return checks
} }
@@ -0,0 +1,16 @@
package nmdata
import (
"time"
"github.com/netbirdio/netbird/shared/management/certposture"
)
// CertificateCheck is the slim twin of posture.CertificateCheck.
type CertificateCheck struct {
CACertificates []string
}
func (c *CertificateCheck) check(peer *Peer) (bool, error) {
return certposture.AnyChainMatchesCAs(peer.Meta.Certificates, c.CACertificates, time.Now()), nil
}
@@ -0,0 +1,28 @@
package nmdata
import (
"crypto/x509"
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/shared/management/certposture"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
func TestCertificateCheck_Check(t *testing.T) {
ca := certtest.NewCA(t, "corp-root")
otherCA := certtest.NewCA(t, "other-root")
chain := certposture.EncodeChainPEM([]*x509.Certificate{ca.Issue(t, certtest.ECDSAKey(t), "device")})
c := bundle(ChecksDefinition{CertificateCheck: &CertificateCheck{CACertificates: []string{ca.PEM}}})
without := &Peer{}
with := &Peer{Meta: PeerSystemMeta{Certificates: []string{chain}}}
assert.False(t, c[0].Passes(without))
assert.True(t, c[0].Passes(with))
assert.True(t, PostureVerdictChanged(c, without, with))
otherOnly := bundle(ChecksDefinition{CertificateCheck: &CertificateCheck{CACertificates: []string{otherCA.PEM}}})
assert.False(t, otherOnly[0].Passes(with))
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -267,6 +267,7 @@ message PeerSystemMeta {
repeated PeerCapability capabilities = 18; repeated PeerCapability capabilities = 18;
int32 syncMessageVersion = 19; int32 syncMessageVersion = 19;
repeated CertificateProof certificateProofs = 20;
} }
message LoginResponse { message LoginResponse {
@@ -696,6 +697,24 @@ message NetworkAddress {
message Checks { message Checks {
repeated string Files = 1; repeated string Files = 1;
// certificateChallenge asks the peer to prove possession of a certificate chaining to caCertificates.
CertificateChallenge certificateChallenge = 2;
}
message CertificateChallenge {
// nonce is issued by management, bound to the peer and a time window; the peer signs it.
bytes nonce = 1;
// caCertificates are PEM encoded trust anchors the presented certificate must chain to.
repeated string caCertificates = 2;
}
// CertificateProof demonstrates possession of the private key of chain[0] by signing the challenge nonce.
message CertificateProof {
bytes nonce = 1;
// chain is DER encoded, leaf first.
repeated bytes chain = 2;
string sigAlg = 3;
bytes signature = 4;
} }