mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-13 18:29:07 +02:00
implement certificate posture check
This commit is contained in:
@@ -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{
|
||||
Hostname: info.Hostname,
|
||||
GoOS: info.GoOS,
|
||||
@@ -1033,7 +1043,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,
|
||||
|
||||
@@ -1663,6 +1663,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
|
||||
@@ -1780,6 +1782,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
|
||||
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
|
||||
|
||||
@@ -2617,6 +2617,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
|
||||
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.
|
||||
@@ -2628,6 +2634,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
|
||||
CertificateCheck *CertificateCheck `json:"certificate_check,omitempty"`
|
||||
|
||||
// GeoLocationCheck Posture check for geo location
|
||||
GeoLocationCheck *GeoLocationCheck `json:"geo_location_check,omitempty"`
|
||||
|
||||
|
||||
@@ -47,6 +47,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))
|
||||
}
|
||||
+1424
-1226
File diff suppressed because it is too large
Load Diff
@@ -267,6 +267,7 @@ message PeerSystemMeta {
|
||||
|
||||
repeated PeerCapability capabilities = 18;
|
||||
int32 syncMessageVersion = 19;
|
||||
repeated CertificateProof certificateProofs = 20;
|
||||
}
|
||||
|
||||
message LoginResponse {
|
||||
@@ -696,6 +697,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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user