mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-10 07:29:06 +02:00
[client, management] implement certificate posture check (#7535)
Co-authored-by: mlsmaycon <mlsmaycon@gmail.com>
This commit is contained in:
co-authored by
mlsmaycon
parent
a5834fdaab
commit
53a14551c8
@@ -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}))
|
||||
}
|
||||
@@ -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.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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+1424
-1226
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user