mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-27 23:09:08 +01:00
implement certificate posture check
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
+1424
-1226
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user