mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-28 07:19:08 +01:00
Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt
# Conflicts: # shared/management/proto/management.pb.go
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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},
|
||||
// An instance whose clock runs a little ahead issues the next window's nonce early.
|
||||
{"issued one window ahead accepted", c.Nonce(peerKey, now.Add(Window)), peerKey, now, nil},
|
||||
{"issued two windows ahead rejected", c.Nonce(peerKey, now.Add(2*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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonceAcceptedAlongside must agree with verifyNonce: a proof for a nonce is still
|
||||
// accepted while the next window's nonce is issued, and no longer two windows on.
|
||||
func TestNonceAcceptedAlongside(t *testing.T) {
|
||||
c := NewChallenger(secret)
|
||||
issued := c.Nonce(peerKey, now)
|
||||
|
||||
assert.True(t, NonceAcceptedAlongside(issued, issued), "the current nonce is accepted")
|
||||
assert.True(t, NonceAcceptedAlongside(issued, c.Nonce(peerKey, now.Add(Window))), "a proof from the previous window is accepted")
|
||||
assert.False(t, NonceAcceptedAlongside(issued, c.Nonce(peerKey, now.Add(2*Window))), "a proof two windows old is not")
|
||||
assert.True(t, NonceAcceptedAlongside(c.Nonce(peerKey, now.Add(Window)), issued), "a nonce from an instance one window ahead is accepted")
|
||||
assert.False(t, NonceAcceptedAlongside(c.Nonce(peerKey, now.Add(2*Window)), issued), "a nonce two windows ahead is not")
|
||||
assert.False(t, NonceAcceptedAlongside([]byte("short"), issued), "a malformed nonce is not")
|
||||
|
||||
for _, tt := range []struct {
|
||||
later time.Duration
|
||||
want bool
|
||||
}{{0, true}, {Window, true}, {2 * Window, false}} {
|
||||
err := c.verifyNonce(issued, peerKey, now.Add(tt.later))
|
||||
assert.Equal(t, tt.want, err == nil, "verifyNonce agrees %s later", tt.later)
|
||||
}
|
||||
}
|
||||
|
||||
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,121 @@
|
||||
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
|
||||
}
|
||||
_, err := VerifiedChain(chain[0], chain[1:], roots, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// VerifiedChain finds a path from leaf to one of roots through any of intermediates,
|
||||
// trying every candidate issuer rather than the first that matches, and returns it
|
||||
// without the root, leaf first. It applies the same rules as VerifyChain.
|
||||
func VerifiedChain(leaf *x509.Certificate, intermediates []*x509.Certificate, roots *x509.CertPool, now time.Time) ([]*x509.Certificate, error) {
|
||||
pool := x509.NewCertPool()
|
||||
for _, cert := range intermediates {
|
||||
pool.AddCert(cert)
|
||||
}
|
||||
chains, err := leaf.Verify(x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
Intermediates: pool,
|
||||
CurrentTime: now,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chain := chains[0]
|
||||
if len(chain) > 1 {
|
||||
chain = chain[:len(chain)-1]
|
||||
}
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
// 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,139 @@
|
||||
package certposture
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// Window is the default challenge window. A nonce stays valid through the window
|
||||
// after the one it was issued in, so a peer re-proves possession of its key between
|
||||
// once and twice per window. One window early is accepted too, for clock skew.
|
||||
Window = 12 * time.Hour
|
||||
|
||||
// EnvWindow overrides Window, for end-to-end tests that cannot wait half a day to
|
||||
// watch a renewal. Every management instance has to be given the same value: the
|
||||
// window is part of the nonce, so instances that disagree reject each other's.
|
||||
EnvWindow = "NB_CERT_CHALLENGE_WINDOW"
|
||||
|
||||
// minWindow keeps the renewal period, a third of the window, well above the
|
||||
// refresher's one-second tick.
|
||||
minWindow = 30 * time.Second
|
||||
maxWindow = 24 * time.Hour
|
||||
|
||||
challengeDomain = "netbird-cert-challenge-v1"
|
||||
windowLen = 8
|
||||
nonceLen = windowLen + sha256.Size
|
||||
|
||||
// NonceSize is the length of every nonce a Challenger issues.
|
||||
NonceSize = nonceLen
|
||||
)
|
||||
|
||||
var effectiveWindow = sync.OnceValue(resolveWindow)
|
||||
|
||||
// EffectiveWindow returns the challenge window in force, which is Window unless
|
||||
// EnvWindow overrides it. Everything timed against the window derives from this, so a
|
||||
// test that shortens it shortens the renewal that goes with it.
|
||||
func EffectiveWindow() time.Duration {
|
||||
return effectiveWindow()
|
||||
}
|
||||
|
||||
func resolveWindow() time.Duration {
|
||||
val := os.Getenv(EnvWindow)
|
||||
if val == "" {
|
||||
return Window
|
||||
}
|
||||
|
||||
window, err := time.ParseDuration(val)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse %s, keeping the %s certificate challenge window: %v", EnvWindow, Window, err)
|
||||
return Window
|
||||
}
|
||||
if window < minWindow || window > maxWindow {
|
||||
log.Warnf("%s of %s is outside %s..%s, keeping the %s certificate challenge window", EnvWindow, window, minWindow, maxWindow, Window)
|
||||
return Window
|
||||
}
|
||||
if window%time.Second != 0 {
|
||||
log.Warnf("%s of %s is not a whole number of seconds, keeping the %s certificate challenge window", EnvWindow, window, Window)
|
||||
return Window
|
||||
}
|
||||
|
||||
// Loud on purpose: this sets how long a device can pass the certificate check after
|
||||
// its key has gone, and it has to match on every instance.
|
||||
log.Warnf("certificate challenge window overridden to %s by %s", window, EnvWindow)
|
||||
return window
|
||||
}
|
||||
|
||||
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: EffectiveWindow()}
|
||||
}
|
||||
|
||||
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])
|
||||
if !windowAccepted(window, c.windowOf(now)) {
|
||||
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)
|
||||
}
|
||||
|
||||
// NonceAcceptedAlongside reports whether a proof answering nonce is still accepted while
|
||||
// management issues current to the same peer, by the same window rule verification uses.
|
||||
func NonceAcceptedAlongside(nonce, current []byte) bool {
|
||||
if len(nonce) != nonceLen || len(current) != nonceLen {
|
||||
return false
|
||||
}
|
||||
window := binary.BigEndian.Uint64(nonce[:windowLen])
|
||||
return windowAccepted(window, binary.BigEndian.Uint64(current[:windowLen]))
|
||||
}
|
||||
|
||||
// windowAccepted reports whether a nonce of window is accepted in window current: the
|
||||
// current window, the previous one so a nonce outlives a rollover, and the next one so an
|
||||
// instance whose clock runs slightly ahead is not rejected by the others.
|
||||
func windowAccepted(window, current uint64) bool {
|
||||
return window == current || window+1 == current || window == current+1
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package certposture
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolveWindow(t *testing.T) {
|
||||
// An end-to-end run shortens the window so a renewal can be watched in seconds
|
||||
// rather than half a day. Anything it cannot make sense of leaves the default in
|
||||
// place, because a window nobody intended is a security property nobody chose.
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "unset keeps the default", env: "", want: Window},
|
||||
{name: "a test-sized window is taken", env: "30s", want: 30 * time.Second},
|
||||
{name: "garbage keeps the default", env: "soon", want: Window},
|
||||
{name: "below the floor keeps the default", env: "10ms", want: Window},
|
||||
{name: "too short for the refresh tick keeps the default", env: "1s", want: Window},
|
||||
{name: "a fractional second keeps the default", env: "30500ms", want: Window},
|
||||
{name: "above the ceiling keeps the default", env: "100h", want: Window},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(EnvWindow, tt.env)
|
||||
if tt.env == "" {
|
||||
require.NoError(t, os.Unsetenv(EnvWindow))
|
||||
}
|
||||
assert.Equal(t, tt.want, resolveWindow())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChallenger_HonoursAShortenedWindow(t *testing.T) {
|
||||
// The window is what a nonce is stamped with, so a shortened one has to make a
|
||||
// nonce expire sooner, not just change a number in a log line.
|
||||
short := 2 * time.Second
|
||||
c := &Challenger{secret: []byte("secret"), window: short}
|
||||
peerKey := []byte("peer-key")
|
||||
|
||||
issued := time.Unix(1_790_000_000, 0)
|
||||
nonce := c.Nonce(peerKey, issued)
|
||||
|
||||
require.NoError(t, c.verifyNonce(nonce, peerKey, issued), "a nonce is valid when issued")
|
||||
require.NoError(t, c.verifyNonce(nonce, peerKey, issued.Add(short)), "and through the window after it")
|
||||
assert.ErrorIs(t, c.verifyNonce(nonce, peerKey, issued.Add(3*short)), ErrNonceExpired,
|
||||
"a shortened window must actually expire the nonce sooner")
|
||||
}
|
||||
|
||||
// heldNonceAlwaysAccepted walks ten windows and, at every step, checks the nonce the peer
|
||||
// would be holding if it were re-stamped every period starting at offset. It returns how
|
||||
// many of those checks would have rejected the peer.
|
||||
func heldNonceAlwaysAccepted(c *Challenger, peerKey []byte, period, offset time.Duration) int {
|
||||
start := time.Unix(0, 0).UTC().Add(offset)
|
||||
var rejected int
|
||||
for elapsed := time.Duration(0); elapsed < 10*Window; elapsed += 7 * time.Minute {
|
||||
periods := int64(elapsed / period)
|
||||
lastRefresh := start.Add(time.Duration(periods) * period)
|
||||
if c.verifyNonce(c.Nonce(peerKey, lastRefresh), peerKey, start.Add(elapsed)) != nil {
|
||||
rejected++
|
||||
}
|
||||
}
|
||||
return rejected
|
||||
}
|
||||
|
||||
// TestNonce_StaysValidWhenRestampedWithinAWindow is the reason management keeps no per-peer
|
||||
// nonce state. A nonce carries the window it was minted in, not the instant, and is accepted
|
||||
// for that window and the next. A peer re-stamped at least once per window therefore can
|
||||
// never be holding one that has fallen outside the accepted pair, whoever it is and whenever
|
||||
// it was last served. There is nothing to track and nothing to search for: the guarantee
|
||||
// comes from the cadence alone.
|
||||
//
|
||||
// The offsets matter: each account is given a phase of its own so a restart does not fan out
|
||||
// to every account at once, so the property has to hold off the window boundary too.
|
||||
func TestNonce_StaysValidWhenRestampedWithinAWindow(t *testing.T) {
|
||||
c := &Challenger{secret: []byte("secret"), window: Window}
|
||||
peerKey := []byte("peer-key")
|
||||
|
||||
for _, period := range []time.Duration{Window / 3, Window / 2, Window} {
|
||||
for _, offset := range []time.Duration{0, Window / 7, Window / 2, Window - time.Minute} {
|
||||
t.Run(period.String()+"+"+offset.String(), func(t *testing.T) {
|
||||
assert.Zero(t, heldNonceAlwaysAccepted(c, peerKey, period, offset),
|
||||
"a peer re-stamped every %v is never left holding an expired nonce", period)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonce_ExpiresWhenRestampedTooSlowly(t *testing.T) {
|
||||
// The counterpart, which is what gives the test above its teeth. Note the aligned case
|
||||
// does not fail: a cadence of exactly two windows that lands on the boundaries is
|
||||
// covered by the grace window. Off the boundary it is not, and real refreshes are off
|
||||
// the boundary by design.
|
||||
c := &Challenger{secret: []byte("secret"), window: Window}
|
||||
peerKey := []byte("peer-key")
|
||||
|
||||
assert.Zero(t, heldNonceAlwaysAccepted(c, peerKey, 2*Window, 0),
|
||||
"two windows exactly on the boundary happens to be covered by the grace window")
|
||||
assert.NotZero(t, heldNonceAlwaysAccepted(c, peerKey, 2*Window, Window/2),
|
||||
"the same cadence off the boundary must leave the peer rejected for a stretch")
|
||||
}
|
||||
@@ -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.Debugf("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,
|
||||
|
||||
@@ -1699,6 +1699,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
|
||||
@@ -1816,6 +1818,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
|
||||
|
||||
@@ -2638,6 +2638,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.
|
||||
@@ -2649,6 +2655,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))
|
||||
}
|
||||
+1431
-1233
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 {
|
||||
@@ -727,6 +728,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