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

Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
This commit is contained in:
Pascal Fischer
2026-10-09 14:57:00 +02:00
committed by GitHub
co-authored by mlsmaycon
parent a5834fdaab
commit 53a14551c8
78 changed files with 7028 additions and 1382 deletions
@@ -0,0 +1,182 @@
package certposture
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"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 TestVerify_AcceptsMaximumPSSSalt(t *testing.T) {
// A TPM chooses the PSS salt itself and older firmware uses the largest salt that
// fits, so a proof from such a key carries more salt than Sign asks a software key for.
ca := certtest.NewCA(t, "root")
key := certtest.RSAKey(t).(*rsa.PrivateKey)
leaf := ca.Issue(t, key, "device")
c := NewChallenger(secret)
nonce := c.Nonce(peerKey, now)
digest := sha256.Sum256(proofMessage(nonce, peerKey))
sig, err := rsa.SignPSS(rand.Reader, key, crypto.SHA256, digest[:], &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthAuto})
require.NoError(t, err)
_, err = c.Verify(Proof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: SigAlgRSAPSSSHA256, Signature: sig}, peerKey, now)
assert.NoError(t, err, "a valid PSS signature must verify regardless of salt length")
}
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.PSSSaltLengthAuto}) == 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
}
+15 -1
View File
@@ -1018,6 +1018,19 @@ 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,
})
}
if len(proofs) > 0 {
log.Infof("peer meta carries %d certificate posture proofs", len(proofs))
}
return &proto.PeerSystemMeta{
Hostname: info.Hostname,
GoOS: info.GoOS,
@@ -1037,7 +1050,8 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta {
Cloud: info.Environment.Cloud,
Platform: info.Environment.Platform,
},
Files: files,
Files: files,
CertificateProofs: proofs,
Flags: &proto.Flags{
RosenpassEnabled: info.RosenpassEnabled,
+14
View File
@@ -1691,6 +1691,8 @@ components:
$ref: '#/components/schemas/PeerNetworkRangeCheck'
process_check:
$ref: '#/components/schemas/ProcessCheck'
certificate_check:
$ref: '#/components/schemas/CertificateCheck'
NBVersionCheck:
description: Posture check for the version of NetBird
type: object
@@ -1808,6 +1810,18 @@ components:
description: Path to the process executable file in a Windows operating system
type: string
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. Not supported on mobile devices (iOS and Android), which always fail this check and are denied access
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:
description: Describe geographical location information
type: object
+9
View File
@@ -2632,6 +2632,12 @@ type BypassResponse struct {
PeerId string `json:"peer_id"`
}
// CertificateCheck Posture check for a certificate held by the peer that chains to one of the given CA certificates. Not supported on mobile devices (iOS and Android), which always fail this check and are denied access
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.
type CheckoutResponse struct {
// SessionId The unique identifier for the checkout session.
@@ -2643,6 +2649,9 @@ type CheckoutResponse struct {
// Checks List of objects that perform the actual checks
type Checks struct {
// CertificateCheck Posture check for a certificate held by the peer that chains to one of the given CA certificates. Not supported on mobile devices (iOS and Android), which always fail this check and are denied access
CertificateCheck *CertificateCheck `json:"certificate_check,omitempty"`
// GeoLocationCheck Posture check for geo location
GeoLocationCheck *GeoLocationCheck `json:"geo_location_check,omitempty"`
@@ -56,6 +56,7 @@ type PeerSystemMeta struct {
KernelVersion string
NetworkAddresses []NetworkAddress
Files []File
Certificates []string
Capabilities []int32
Flags Flags
SyncMessageVersion int
@@ -18,6 +18,7 @@ type ChecksDefinition struct {
GeoLocationCheck *GeoLocationCheck
PeerNetworkRangeCheck *PeerNetworkRangeCheck
ProcessCheck *ProcessCheck
CertificateCheck *CertificateCheck
}
// 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 {
checks = append(checks, pc.Checks.ProcessCheck)
}
if pc.Checks.CertificateCheck != nil {
checks = append(checks, pc.Checks.CertificateCheck)
}
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
@@ -275,6 +275,7 @@ message PeerSystemMeta {
repeated PeerCapability capabilities = 18;
int32 syncMessageVersion = 19;
repeated CertificateProof certificateProofs = 20;
}
message LoginResponse {
@@ -715,6 +716,24 @@ message NetworkAddress {
message Checks {
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;
}