Find a chain to each challenge's CAs through every intermediate the store holds

This commit is contained in:
Viktor Liu
2026-10-01 10:06:50 +02:00
parent 21be4420d1
commit 3cfff34d2c
7 changed files with 148 additions and 21 deletions
+25 -10
View File
@@ -2,7 +2,9 @@ package certproof
import (
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"time"
log "github.com/sirupsen/logrus"
@@ -73,18 +75,22 @@ func CollectChallenges(ctx context.Context, store Store, challenges []*proto.Cer
continue
}
leaf := candidate.Chain[0]
if err := certposture.VerifyChain(candidate.Chain, roots, now); err != nil {
log.Debugf("certificate posture: challenge %d rejected %q issued by %q, chain of %d: %v", i, leaf.Subject, leaf.Issuer, len(candidate.Chain), err)
chain, err := certposture.VerifiedChain(leaf, candidate.issuers(), roots, now)
if err != nil {
log.Debugf("certificate posture: challenge %d rejected %q issued by %q: %v", i, leaf.Subject, leaf.Issuer, err)
continue
}
matched = true
fingerprint := sha256.Sum256(leaf.Raw)
// The same leaf can chain to different CAs for different challenges, and
// management checks each chain against each check's CAs, so a proof is
// deduplicated by its whole chain rather than by its leaf.
fingerprint := chainFingerprint(chain)
if _, done := proven[fingerprint]; done {
log.Debugf("certificate posture: challenge %d matched %q, already proven for an earlier challenge", i, leaf.Subject)
break
}
proof, err := prove(candidate, challenge.GetNonce(), peerKey)
proof, err := prove(candidate.Signer, chain, challenge.GetNonce(), peerKey)
if err != nil {
log.Warnf("failed signing certificate proof for %s: %v", leaf.Subject, err)
continue
@@ -125,14 +131,23 @@ func wellFormed(challenges []*proto.CertificateChallenge) []*proto.CertificateCh
return kept
}
func prove(candidate Candidate, nonce, peerKey []byte) (certposture.Proof, error) {
sigAlg, sig, err := certposture.Sign(candidate.Signer, nonce, peerKey)
func prove(signer crypto.Signer, chain []*x509.Certificate, nonce, peerKey []byte) (certposture.Proof, error) {
sigAlg, sig, err := certposture.Sign(signer, nonce, peerKey)
if err != nil {
return certposture.Proof{}, err
}
chain := make([][]byte, 0, len(candidate.Chain))
for _, cert := range candidate.Chain {
chain = append(chain, cert.Raw)
der := make([][]byte, 0, len(chain))
for _, cert := range chain {
der = append(der, cert.Raw)
}
return certposture.Proof{Nonce: nonce, Chain: chain, SigAlg: sigAlg, Signature: sig}, nil
return certposture.Proof{Nonce: nonce, Chain: der, SigAlg: sigAlg, Signature: sig}, nil
}
func chainFingerprint(chain []*x509.Certificate) [sha256.Size]byte {
buf := make([]byte, 0, len(chain)*sha256.Size)
for _, cert := range chain {
certHash := sha256.Sum256(cert.Raw)
buf = append(buf, certHash[:]...)
}
return sha256.Sum256(buf)
}
+87
View File
@@ -2,6 +2,9 @@ package certproof
import (
"context"
"crypto/rand"
"crypto/x509"
"math/big"
"os"
"path/filepath"
"testing"
@@ -160,3 +163,87 @@ func TestFileStore_SkipsKeyOfAnotherCertificate(t *testing.T) {
_, err = challenger.Verify(proofs[0], peerKey, now)
assert.NoError(t, err, "the proof sent is one management accepts")
}
// staticStore hands out fixed candidates, for scenarios no on-disk layout can express.
type staticStore []Candidate
func (s staticStore) Candidates(context.Context) ([]Candidate, error) { return s, nil }
func TestCollectChallenges_RoutesAroundExpiredCopyOfRenewedIntermediate(t *testing.T) {
root := certtest.NewCA(t, "root")
intermediate := certtest.NewIntermediate(t, root, "issuing-ca")
// Renewing a CA with the same key pair leaves two certificates with the same
// subject and key in the store. The expired one sorts first here.
expiredTmpl := *intermediate.Cert
expiredTmpl.SerialNumber = big.NewInt(1)
expiredTmpl.NotBefore = time.Now().Add(-72 * time.Hour)
expiredTmpl.NotAfter = time.Now().Add(-48 * time.Hour)
der, err := x509.CreateCertificate(rand.Reader, &expiredTmpl, root.Cert, intermediate.Key.Public(), root.Key)
require.NoError(t, err)
expired, err := x509.ParseCertificate(der)
require.NoError(t, err)
key := certtest.ECDSAKey(t)
leaf := intermediate.Issue(t, key, "device")
pool := []*x509.Certificate{expired, intermediate.Cert}
chain := buildChain(leaf, pool)
require.Len(t, chain, 2)
require.True(t, expired.Equal(chain[1]), "precondition: the first-match chain runs through the expired copy")
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
store := staticStore{{Chain: chain, Signer: key, Intermediates: pool}}
proofs := CollectChallenges(context.Background(), store, []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{root.PEM}}}, peerKey)
require.Len(t, proofs, 1, "a valid path through the renewed intermediate exists, so the challenge is answered")
verified, err := challenger.Verify(proofs[0], peerKey, now)
require.NoError(t, err)
assert.True(t, intermediate.Cert.Equal(verified[1]), "the proof carries the valid intermediate, not the expired copy")
assert.True(t, certposture.ChainMatchesCAs(certposture.EncodeChainPEM(verified), []string{root.PEM}, now),
"management's own check accepts the chain the proof carries")
}
func TestCollectChallenges_ProvesALeafOncePerDistinctChain(t *testing.T) {
rootA := certtest.NewCA(t, "root-a")
rootB := certtest.NewCA(t, "root-b")
issuer := certtest.NewIntermediate(t, rootA, "issuing-ca")
// The same issuing CA cross-signed by a second root: one leaf, two valid paths.
crossTmpl := *issuer.Cert
crossTmpl.SerialNumber = big.NewInt(2)
der, err := x509.CreateCertificate(rand.Reader, &crossTmpl, rootB.Cert, issuer.Key.Public(), rootB.Key)
require.NoError(t, err)
cross, err := x509.ParseCertificate(der)
require.NoError(t, err)
key := certtest.ECDSAKey(t)
leaf := issuer.Issue(t, key, "device")
pool := []*x509.Certificate{issuer.Cert, cross}
store := staticStore{{Chain: buildChain(leaf, pool), Signer: key, Intermediates: pool}}
challenger := certposture.NewChallenger([]byte("secret"))
now := time.Now()
nonce := challenger.Nonce(peerKey, now)
challenges := []*proto.CertificateChallenge{
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
{Nonce: nonce, CaCertificates: []string{rootB.PEM}},
{Nonce: nonce, CaCertificates: []string{rootA.PEM}},
}
proofs := CollectChallenges(context.Background(), store, challenges, peerKey)
require.Len(t, proofs, 2, "one proof per distinct chain, the repeated root-a challenge reuses the first")
for _, root := range []*certtest.CA{rootA, rootB} {
matched := false
for _, p := range proofs {
chain, err := challenger.Verify(p, peerKey, now)
require.NoError(t, err)
matched = matched || certposture.ChainMatchesCAs(certposture.EncodeChainPEM(chain), []string{root.PEM}, now)
}
assert.True(t, matched, "management can match a chain for the check that trusts %s", root.Cert.Subject.CommonName)
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ func (s *KeychainStore) Candidates(_ context.Context) ([]Candidate, error) {
if len(chain) == 1 && leaf.CheckSignatureFrom(leaf) != nil {
log.Debugf("keychain candidate %q has no issuer in the keychain, its proof carries the leaf alone and only verifies if the challenge supplies %q", leaf.Subject, leaf.Issuer)
}
candidates = append(candidates, Candidate{Chain: chain, Signer: &keychainSigner{leaf: leaf}})
candidates = append(candidates, Candidate{Chain: chain, Signer: &keychainSigner{leaf: leaf}, Intermediates: pool})
}
return candidates, nil
}
+1 -1
View File
@@ -113,7 +113,7 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) {
func (s *PKCS11Store) candidate(leaf *x509.Certificate, id []byte, pool []*x509.Certificate) Candidate {
chain := buildChain(leaf, pool)
log.Debugf("%s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
return Candidate{Chain: chain, Signer: &pkcs11Signer{store: s, leaf: leaf, id: id}}
return Candidate{Chain: chain, Signer: &pkcs11Signer{store: s, leaf: leaf, id: id}, Intermediates: pool}
}
// fileChains reads the certificate files in the PEM directory that carry no key of their
+12 -2
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
log "github.com/sirupsen/logrus"
@@ -26,9 +27,18 @@ const (
var errKeyMismatch = errors.New("private key does not match the certificate")
// Candidate is a certificate chain the peer can sign for. Signer never exposes the key.
// Chain is leaf first. Intermediates holds every other certificate the store has, so a
// path to a challenge's CAs can be found even where Chain followed a different issuer,
// such as an expired copy of a renewed intermediate.
type Candidate struct {
Chain []*x509.Certificate
Signer crypto.Signer
Chain []*x509.Certificate
Signer crypto.Signer
Intermediates []*x509.Certificate
}
// issuers is every certificate other than the leaf that a path may run through.
func (c Candidate) issuers() []*x509.Certificate {
return append(slices.Clip(c.Chain[1:]), c.Intermediates...)
}
// Store yields the certificates a peer may prove possession of. FileStore is the PEM
@@ -88,7 +88,7 @@ func (s *SystemStore) Candidates(_ context.Context) ([]Candidate, error) {
for _, leaf := range leaves {
chain := buildChain(leaf, pool)
log.Debugf("certificate store %s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain))
candidates = append(candidates, Candidate{Chain: chain, Signer: &systemStoreSigner{leaf: leaf, location: s.location}})
candidates = append(candidates, Candidate{Chain: chain, Signer: &systemStoreSigner{leaf: leaf, location: s.location}, Intermediates: pool})
}
return candidates, nil
}