implement certificate posture check

This commit is contained in:
pascal
2026-08-31 13:47:55 +02:00
parent 086d8ba507
commit 84d83fa05e
34 changed files with 2825 additions and 1236 deletions
@@ -0,0 +1,163 @@
package certposture
import (
"crypto"
"crypto/x509"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/certposture/certtest"
)
var (
secret = []byte("test-secret")
peerKey = []byte("peer-public-key-aaaaaaaaaaaaaaaa")
otherKey = []byte("peer-public-key-bbbbbbbbbbbbbbbb")
now = time.Now().Truncate(Window)
)
func TestChallenger_NonceIsStableWithinWindowAndPerPeer(t *testing.T) {
c := NewChallenger(secret)
assert.Equal(t, c.Nonce(peerKey, now), c.Nonce(peerKey, now.Add(time.Minute)))
assert.NotEqual(t, c.Nonce(peerKey, now), c.Nonce(peerKey, now.Add(Window)))
assert.NotEqual(t, c.Nonce(peerKey, now), c.Nonce(otherKey, now))
assert.NotEqual(t, c.Nonce(peerKey, now), NewChallenger([]byte("other")).Nonce(peerKey, now))
}
func TestChallenger_VerifyNonce(t *testing.T) {
c := NewChallenger(secret)
nonce := c.Nonce(peerKey, now)
tests := []struct {
name string
nonce []byte
peerKey []byte
at time.Time
wantErr error
}{
{"current window", nonce, peerKey, now, nil},
{"previous window still accepted", nonce, peerKey, now.Add(Window), nil},
{"two windows later expired", nonce, peerKey, now.Add(2 * Window), ErrNonceExpired},
{"issued in the future rejected", c.Nonce(peerKey, now.Add(Window)), peerKey, now, ErrNonceExpired},
{"other peer", nonce, otherKey, now, ErrNonceMismatch},
{"tampered mac", tamper(nonce, len(nonce)-1), peerKey, now, ErrNonceMismatch},
{"malformed", nonce[:10], peerKey, now, ErrNonceMalformed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := c.verifyNonce(tt.nonce, tt.peerKey, tt.at)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestSignVerify_RoundTripPerKeyType(t *testing.T) {
ca := certtest.NewCA(t, "root")
keys := map[string]crypto.Signer{
SigAlgECDSASHA256: certtest.ECDSAKey(t),
SigAlgRSAPSSSHA256: certtest.RSAKey(t),
SigAlgEd25519: certtest.Ed25519Key(t),
}
for wantAlg, key := range keys {
t.Run(wantAlg, func(t *testing.T) {
c := NewChallenger(secret)
proof := signedProof(t, c, ca, key)
assert.Equal(t, wantAlg, proof.SigAlg)
chain, err := c.Verify(proof, peerKey, now)
require.NoError(t, err)
require.Len(t, chain, 1)
assert.NoError(t, VerifyChain(chain, mustPool(t, ca.PEM), now))
})
}
}
func TestVerify_Rejections(t *testing.T) {
ca := certtest.NewCA(t, "root")
c := NewChallenger(secret)
good := signedProof(t, c, ca, certtest.ECDSAKey(t))
tests := []struct {
name string
mutate func(p Proof) Proof
peerKey []byte
wantErr error
}{
{"replayed for other peer", identity, otherKey, ErrNonceMismatch},
{"signature tampered", func(p Proof) Proof { p.Signature = tamper(p.Signature, 5); return p }, peerKey, ErrSignatureInvalid},
{"nonce swapped after signing", func(p Proof) Proof { p.Nonce = c.Nonce(peerKey, now.Add(-Window)); return p }, peerKey, ErrSignatureInvalid},
{"foreign leaf presented", func(p Proof) Proof {
p.Chain = [][]byte{ca.Issue(t, certtest.ECDSAKey(t), "other").Raw}
return p
}, peerKey, ErrSignatureInvalid},
{"alg mismatch", func(p Proof) Proof { p.SigAlg = SigAlgEd25519; return p }, peerKey, ErrSigAlgMismatch},
{"empty chain", func(p Proof) Proof { p.Chain = nil; return p }, peerKey, ErrEmptyChain},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := c.Verify(tt.mutate(good), tt.peerKey, now)
assert.ErrorIs(t, err, tt.wantErr)
})
}
}
func TestVerify_SecretMismatchAcrossChallengers(t *testing.T) {
ca := certtest.NewCA(t, "root")
proof := signedProof(t, NewChallenger(secret), ca, certtest.ECDSAKey(t))
_, err := NewChallenger([]byte("other-instance-secret")).Verify(proof, peerKey, now)
assert.ErrorIs(t, err, ErrNonceMismatch)
}
func TestChainMatchesCAs(t *testing.T) {
ca := certtest.NewCA(t, "root")
otherCA := certtest.NewCA(t, "other-root")
leaf := ca.Issue(t, certtest.ECDSAKey(t), "device")
chainPEM := EncodeChainPEM([]*x509.Certificate{leaf})
assert.True(t, ChainMatchesCAs(chainPEM, []string{ca.PEM}, now))
assert.True(t, ChainMatchesCAs(chainPEM, []string{otherCA.PEM, ca.PEM}, now))
assert.False(t, ChainMatchesCAs(chainPEM, []string{otherCA.PEM}, now))
assert.False(t, ChainMatchesCAs(chainPEM, []string{ca.PEM}, now.Add(30*24*time.Hour)))
assert.False(t, ChainMatchesCAs(chainPEM, []string{"not a pem"}, now))
assert.False(t, ChainMatchesCAs("not a pem", []string{ca.PEM}, now))
}
func TestChainPEM_RoundTrip(t *testing.T) {
ca := certtest.NewCA(t, "root")
leaf := ca.Issue(t, certtest.ECDSAKey(t), "device")
chain, err := ParseChainPEM(EncodeChainPEM([]*x509.Certificate{leaf, ca.Cert}))
require.NoError(t, err)
require.Len(t, chain, 2)
assert.Equal(t, leaf.Raw, chain[0].Raw)
assert.Equal(t, ca.Cert.Raw, chain[1].Raw)
}
func signedProof(t *testing.T, c *Challenger, ca *certtest.CA, key crypto.Signer) Proof {
t.Helper()
leaf := ca.Issue(t, key, "device")
nonce := c.Nonce(peerKey, now)
sigAlg, sig, err := Sign(key, nonce, peerKey)
require.NoError(t, err)
return Proof{Nonce: nonce, Chain: [][]byte{leaf.Raw}, SigAlg: sigAlg, Signature: sig}
}
func mustPool(t *testing.T, pems ...string) *x509.CertPool {
t.Helper()
pool, err := ParseCAs(pems)
require.NoError(t, err)
return pool
}
func identity(p Proof) Proof { return p }
func tamper(b []byte, i int) []byte {
out := append([]byte(nil), b...)
out[i] ^= 0xff
return out
}
@@ -0,0 +1,109 @@
// Package certtest builds throwaway CAs and leaf certificates for certificate posture tests.
package certtest
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type CA struct {
Cert *x509.Certificate
Key crypto.Signer
PEM string
}
func NewCA(t *testing.T, name string) *CA {
t.Helper()
return newCA(t, name, nil)
}
// NewIntermediate creates a CA signed by parent.
func NewIntermediate(t *testing.T, parent *CA, name string) *CA {
t.Helper()
return newCA(t, name, parent)
}
func newCA(t *testing.T, name string, parent *CA) *CA {
t.Helper()
key := ECDSAKey(t)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: name},
NotBefore: time.Now().Add(-24 * time.Hour),
NotAfter: time.Now().Add(48 * time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
}
issuer, issuerKey := tmpl, key
if parent != nil {
issuer, issuerKey = parent.Cert, parent.Key
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, issuer, key.Public(), issuerKey)
require.NoError(t, err)
cert, err := x509.ParseCertificate(der)
require.NoError(t, err)
return &CA{Cert: cert, Key: key, PEM: CertPEM(cert)}
}
// Issue signs a leaf certificate for key with the CA.
func (ca *CA) Issue(t *testing.T, key crypto.Signer, cn string) *x509.Certificate {
t.Helper()
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-24 * time.Hour),
NotAfter: time.Now().Add(48 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.Cert, key.Public(), ca.Key)
require.NoError(t, err)
cert, err := x509.ParseCertificate(der)
require.NoError(t, err)
return cert
}
func ECDSAKey(t *testing.T) crypto.Signer {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
return key
}
func RSAKey(t *testing.T) crypto.Signer {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
return key
}
func Ed25519Key(t *testing.T) crypto.Signer {
t.Helper()
_, key, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
return key
}
func CertPEM(cert *x509.Certificate) string {
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))
}
func KeyPEM(t *testing.T, key crypto.Signer) string {
t.Helper()
der, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
}
+106
View File
@@ -0,0 +1,106 @@
package certposture
import (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"strings"
"time"
)
var ErrNoCertificateInPEM = errors.New("no certificate found in PEM data")
// ParseCAs builds a root pool from PEM encoded CA certificates.
func ParseCAs(pems []string) (*x509.CertPool, error) {
roots := x509.NewCertPool()
for _, p := range pems {
if !roots.AppendCertsFromPEM([]byte(p)) {
return nil, ErrNoCertificateInPEM
}
}
return roots, nil
}
// VerifyChain reports whether the leaf (chain[0]) chains to one of roots using the
// remaining certificates as intermediates.
func VerifyChain(chain []*x509.Certificate, roots *x509.CertPool, now time.Time) error {
if len(chain) == 0 {
return ErrEmptyChain
}
intermediates := x509.NewCertPool()
for _, cert := range chain[1:] {
intermediates.AddCert(cert)
}
_, err := chain[0].Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
CurrentTime: now,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
})
return err
}
// ChainMatchesCAs is VerifyChain over the PEM forms stored in peer meta and check config.
func ChainMatchesCAs(chainPEM string, caPEMs []string, now time.Time) bool {
roots, err := ParseCAs(caPEMs)
if err != nil {
return false
}
return chainMatchesPool(chainPEM, roots, now)
}
// AnyChainMatchesCAs reports whether at least one of the peer's verified chains
// is anchored in one of the configured CAs.
func AnyChainMatchesCAs(chainPEMs, caPEMs []string, now time.Time) bool {
roots, err := ParseCAs(caPEMs)
if err != nil {
return false
}
for _, chainPEM := range chainPEMs {
if chainMatchesPool(chainPEM, roots, now) {
return true
}
}
return false
}
func chainMatchesPool(chainPEM string, roots *x509.CertPool, now time.Time) bool {
chain, err := ParseChainPEM(chainPEM)
if err != nil {
return false
}
return VerifyChain(chain, roots, now) == nil
}
func EncodeChainPEM(chain []*x509.Certificate) string {
var b strings.Builder
for _, cert := range chain {
_ = pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
}
return b.String()
}
func ParseChainPEM(chainPEM string) ([]*x509.Certificate, error) {
var chain []*x509.Certificate
rest := []byte(chainPEM)
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
}
if len(chain) == 0 {
return nil, ErrNoCertificateInPEM
}
return chain, nil
}
@@ -0,0 +1,68 @@
package certposture
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"errors"
"time"
)
const (
Window = 12 * time.Hour
challengeDomain = "netbird-cert-challenge-v1"
windowLen = 8
nonceLen = windowLen + sha256.Size
)
var (
ErrNonceMalformed = errors.New("certificate challenge nonce is malformed")
ErrNonceExpired = errors.New("certificate challenge nonce is expired")
ErrNonceMismatch = errors.New("certificate challenge nonce was not issued to this peer")
)
// Challenger issues and verifies stateless per-peer nonces. A nonce is bound to the
// peer and to a time window, so any instance sharing the secret can verify it.
type Challenger struct {
secret []byte
window time.Duration
}
func NewChallenger(secret []byte) *Challenger {
return &Challenger{secret: secret, window: Window}
}
func (c *Challenger) Nonce(peerKey []byte, now time.Time) []byte {
return c.nonceForWindow(peerKey, c.windowOf(now))
}
func (c *Challenger) verifyNonce(nonce, peerKey []byte, now time.Time) error {
if len(nonce) != nonceLen {
return ErrNonceMalformed
}
window := binary.BigEndian.Uint64(nonce[:windowLen])
current := c.windowOf(now)
if window != current && window+1 != current {
return ErrNonceExpired
}
if !hmac.Equal(nonce, c.nonceForWindow(peerKey, window)) {
return ErrNonceMismatch
}
return nil
}
func (c *Challenger) windowOf(now time.Time) uint64 {
return uint64(now.Unix() / int64(c.window.Seconds()))
}
func (c *Challenger) nonceForWindow(peerKey []byte, window uint64) []byte {
nonce := make([]byte, windowLen, nonceLen)
binary.BigEndian.PutUint64(nonce, window)
mac := hmac.New(sha256.New, c.secret)
mac.Write([]byte(challengeDomain))
mac.Write(peerKey)
mac.Write(nonce[:windowLen])
return mac.Sum(nonce)
}
+154
View File
@@ -0,0 +1,154 @@
package certposture
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"crypto/x509"
"errors"
"fmt"
"time"
)
const (
SigAlgECDSASHA256 = "ecdsa-sha256"
SigAlgECDSASHA384 = "ecdsa-sha384"
SigAlgRSAPSSSHA256 = "rsa-pss-sha256"
SigAlgEd25519 = "ed25519"
proofDomain = "netbird-posture-cert-v1"
minRSABits = 2048
)
var (
ErrUnsupportedKey = errors.New("unsupported certificate key")
ErrEmptyChain = errors.New("certificate chain is empty")
ErrSignatureInvalid = errors.New("certificate proof signature is invalid")
ErrSigAlgMismatch = errors.New("signature algorithm does not match the certificate key")
)
// Proof is a client's demonstration that it holds the private key of the leaf
// certificate in Chain, made by signing the challenge nonce bound to its peer key.
type Proof struct {
Nonce []byte
Chain [][]byte
SigAlg string
Signature []byte
}
// Sign produces the proof signature for a nonce using the leaf's private key. The key
// never leaves the signer, which may be backed by a file, a TPM or an OS keystore.
func Sign(signer crypto.Signer, nonce, peerKey []byte) (string, []byte, error) {
sigAlg, err := sigAlgFor(signer.Public())
if err != nil {
return "", nil, err
}
msg := proofMessage(nonce, peerKey)
var sig []byte
switch sigAlg {
case SigAlgECDSASHA256:
d := sha256.Sum256(msg)
sig, err = signer.Sign(rand.Reader, d[:], crypto.SHA256)
case SigAlgECDSASHA384:
d := sha512.Sum384(msg)
sig, err = signer.Sign(rand.Reader, d[:], crypto.SHA384)
case SigAlgRSAPSSSHA256:
d := sha256.Sum256(msg)
sig, err = signer.Sign(rand.Reader, d[:], &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA256})
case SigAlgEd25519:
sig, err = signer.Sign(rand.Reader, msg, crypto.Hash(0))
}
if err != nil {
return "", nil, fmt.Errorf("sign certificate proof: %w", err)
}
return sigAlg, sig, nil
}
// Verify checks that the proof's nonce was issued by this challenger to peerKey and is
// still fresh, and that the signature validates against the leaf's public key. It
// returns the parsed chain on success. Chain trust is deliberately not evaluated here.
func (c *Challenger) Verify(proof Proof, peerKey []byte, now time.Time) ([]*x509.Certificate, error) {
if err := c.verifyNonce(proof.Nonce, peerKey, now); err != nil {
return nil, err
}
chain, err := parseChain(proof.Chain)
if err != nil {
return nil, err
}
leaf := chain[0]
sigAlg, err := sigAlgFor(leaf.PublicKey)
if err != nil {
return nil, err
}
if sigAlg != proof.SigAlg {
return nil, ErrSigAlgMismatch
}
if !verifySignature(leaf.PublicKey, sigAlg, proofMessage(proof.Nonce, peerKey), proof.Signature) {
return nil, ErrSignatureInvalid
}
return chain, nil
}
func proofMessage(nonce, peerKey []byte) []byte {
msg := make([]byte, 0, len(proofDomain)+len(nonce)+len(peerKey))
msg = append(msg, proofDomain...)
msg = append(msg, nonce...)
return append(msg, peerKey...)
}
func sigAlgFor(pub crypto.PublicKey) (string, error) {
switch k := pub.(type) {
case *ecdsa.PublicKey:
switch k.Curve {
case elliptic.P256():
return SigAlgECDSASHA256, nil
case elliptic.P384():
return SigAlgECDSASHA384, nil
}
case *rsa.PublicKey:
if k.N.BitLen() >= minRSABits {
return SigAlgRSAPSSSHA256, nil
}
case ed25519.PublicKey:
return SigAlgEd25519, nil
}
return "", ErrUnsupportedKey
}
func verifySignature(pub crypto.PublicKey, sigAlg string, msg, sig []byte) bool {
switch sigAlg {
case SigAlgECDSASHA256:
d := sha256.Sum256(msg)
return ecdsa.VerifyASN1(pub.(*ecdsa.PublicKey), d[:], sig)
case SigAlgECDSASHA384:
d := sha512.Sum384(msg)
return ecdsa.VerifyASN1(pub.(*ecdsa.PublicKey), d[:], sig)
case SigAlgRSAPSSSHA256:
d := sha256.Sum256(msg)
return rsa.VerifyPSS(pub.(*rsa.PublicKey), crypto.SHA256, d[:], sig, &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) == nil
case SigAlgEd25519:
return ed25519.Verify(pub.(ed25519.PublicKey), msg, sig)
}
return false
}
func parseChain(der [][]byte) ([]*x509.Certificate, error) {
if len(der) == 0 {
return nil, ErrEmptyChain
}
chain := make([]*x509.Certificate, 0, len(der))
for _, raw := range der {
cert, err := x509.ParseCertificate(raw)
if err != nil {
return nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
}
return chain, nil
}