feat(pki): agent CA, bundle signing key and certificate issuance
The CA (ECDSA P-256, 10 years) and the Ed25519 bundle signing key are created on first start, sealed with the master key and stored once (concurrent starts read back the winner). The agent-port server certificate is issued in memory; agent CSRs are signed for 14 days. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8e76c0cf32
commit
c60cd4a21e
@@ -0,0 +1,421 @@
|
||||
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
||||
|
||||
// Package pki is the server side of the agent PKI (ADR 0008, 0009, 0021):
|
||||
// the agent CA, the bundle signing key, the agent-port server certificate
|
||||
// and the signing of agent certificate requests.
|
||||
package pki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
|
||||
"git.beugsiud.de/beug.k/DDI/internal/brand"
|
||||
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
|
||||
"git.beugsiud.de/beug.k/DDI/server/internal/store"
|
||||
)
|
||||
|
||||
// Lifetimes (ADR 0008/0021).
|
||||
const (
|
||||
CALifetime = 10 * 365 * 24 * time.Hour
|
||||
ServerCertLifetime = 365 * 24 * time.Hour
|
||||
AgentCertLifetime = 14 * 24 * time.Hour
|
||||
// clockSkew backdates NotBefore so that hosts with a slightly late
|
||||
// clock accept fresh certificates.
|
||||
clockSkew = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Authority holds the CA and the bundle signing key.
|
||||
type Authority struct {
|
||||
caCert *x509.Certificate
|
||||
caKey *ecdsa.PrivateKey
|
||||
bundleKey ed25519.PrivateKey
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New creates an Authority from existing key material.
|
||||
func New(caCert *x509.Certificate, caKey *ecdsa.PrivateKey, bundleKey ed25519.PrivateKey, now func() time.Time) (*Authority, error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
if caCert == nil || caKey == nil || !caCert.IsCA {
|
||||
return nil, errors.New("invalid CA")
|
||||
}
|
||||
pub, ok := caCert.PublicKey.(*ecdsa.PublicKey)
|
||||
if !ok || !pub.Equal(caKey.Public()) {
|
||||
return nil, errors.New("CA certificate does not match its key")
|
||||
}
|
||||
if len(bundleKey) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("invalid bundle signing key")
|
||||
}
|
||||
return &Authority{caCert: caCert, caKey: caKey, bundleKey: bundleKey, now: now}, nil
|
||||
}
|
||||
|
||||
// Generate creates a new CA and bundle signing key in memory.
|
||||
func Generate(now func() time.Time) (*Authority, error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
caCert, caKey, err := newCA(now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, bundleKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return New(caCert, caKey, bundleKey, now)
|
||||
}
|
||||
|
||||
// RandomSerial returns a random positive 128-bit serial number.
|
||||
func RandomSerial() (*big.Int, error) {
|
||||
limit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
for {
|
||||
n, err := rand.Int(rand.Reader, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n.Sign() > 0 {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SerialHex formats a serial number as stored in the database.
|
||||
func SerialHex(n *big.Int) string { return n.Text(16) }
|
||||
|
||||
func newCA(now time.Time) (*x509.Certificate, *ecdsa.PrivateKey, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
serial, err := RandomSerial()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: brand.DisplayName + " Agent CA"},
|
||||
NotBefore: now.Add(-clockSkew),
|
||||
NotAfter: now.Add(CALifetime),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLenZero: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create CA certificate: %w", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return cert, key, nil
|
||||
}
|
||||
|
||||
// CACertificate returns the CA certificate.
|
||||
func (a *Authority) CACertificate() *x509.Certificate { return a.caCert }
|
||||
|
||||
// Fingerprint returns "sha256:<hex>" of the CA certificate.
|
||||
func (a *Authority) Fingerprint() string { return agentpki.Fingerprint(a.caCert) }
|
||||
|
||||
// CAPool returns a pool holding only the CA.
|
||||
func (a *Authority) CAPool() *x509.CertPool {
|
||||
p := x509.NewCertPool()
|
||||
p.AddCert(a.caCert)
|
||||
return p
|
||||
}
|
||||
|
||||
// BundleSigningKey returns the Ed25519 key that signs bundles.
|
||||
func (a *Authority) BundleSigningKey() ed25519.PrivateKey { return a.bundleKey }
|
||||
|
||||
// BundlePublicKey returns the public half of the bundle signing key.
|
||||
func (a *Authority) BundlePublicKey() ed25519.PublicKey {
|
||||
return a.bundleKey.Public().(ed25519.PublicKey)
|
||||
}
|
||||
|
||||
// notAfter caps a lifetime at the CA's expiry.
|
||||
func (a *Authority) notAfter(now time.Time, lifetime time.Duration) time.Time {
|
||||
end := now.Add(lifetime)
|
||||
if end.After(a.caCert.NotAfter) {
|
||||
end = a.caCert.NotAfter
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
// ServerCertificate issues a fresh server certificate for the agent port
|
||||
// with a new key. The chain contains the leaf and the CA, so that agents can
|
||||
// check the CA fingerprint during enrollment.
|
||||
func (a *Authority) ServerCertificate(names []string) (tls.Certificate, error) {
|
||||
if len(names) == 0 {
|
||||
return tls.Certificate{}, errors.New("no server names")
|
||||
}
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
serial, err := RandomSerial()
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
now := a.now()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: names[0]},
|
||||
NotBefore: now.Add(-clockSkew),
|
||||
NotAfter: a.notAfter(now, ServerCertLifetime),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
for _, n := range names {
|
||||
if ip := net.ParseIP(strings.Trim(n, "[]")); ip != nil {
|
||||
tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
|
||||
} else {
|
||||
tmpl.DNSNames = append(tmpl.DNSNames, n)
|
||||
}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, a.caCert, key.Public(), a.caKey)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("create server certificate: %w", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
return tls.Certificate{Certificate: [][]byte{der, a.caCert.Raw}, PrivateKey: key, Leaf: leaf}, nil
|
||||
}
|
||||
|
||||
// ErrInvalidCSR is returned for unusable certificate requests.
|
||||
var ErrInvalidCSR = errors.New("invalid certificate signing request")
|
||||
|
||||
// SignAgentCSR verifies a DER-encoded CSR and issues a client certificate
|
||||
// for agentID. Only the CSR's public key is used; subject and extensions are
|
||||
// set by the server. Accepted keys: ECDSA P-256 and Ed25519.
|
||||
func (a *Authority) SignAgentCSR(csrDER []byte, agentID string) (*x509.Certificate, error) {
|
||||
if !agentpki.ValidAgentID(agentID) {
|
||||
return nil, errors.New("invalid agent ID")
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(csrDER)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInvalidCSR, err)
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, fmt.Errorf("%w: bad signature", ErrInvalidCSR)
|
||||
}
|
||||
if err := checkAgentKey(csr.PublicKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serial, err := RandomSerial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := a.now()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: agentID},
|
||||
URIs: []*url.URL{agentpki.AgentURI(agentID)},
|
||||
NotBefore: now.Add(-clockSkew),
|
||||
NotAfter: a.notAfter(now, AgentCertLifetime),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, a.caCert, csr.PublicKey, a.caKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create agent certificate: %w", err)
|
||||
}
|
||||
return x509.ParseCertificate(der)
|
||||
}
|
||||
|
||||
func checkAgentKey(pub crypto.PublicKey) error {
|
||||
switch k := pub.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
if k.Curve != elliptic.P256() {
|
||||
return fmt.Errorf("%w: only ECDSA P-256 and Ed25519 keys are accepted", ErrInvalidCSR)
|
||||
}
|
||||
return nil
|
||||
case ed25519.PublicKey:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%w: only ECDSA P-256 and Ed25519 keys are accepted", ErrInvalidCSR)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyMode selects how VerifyAgentCertificate treats expiry.
|
||||
type VerifyMode int
|
||||
|
||||
// Verify modes.
|
||||
const (
|
||||
// VerifyCurrent requires a currently valid certificate.
|
||||
VerifyCurrent VerifyMode = iota
|
||||
// VerifyRenewal also accepts a certificate that expired at most
|
||||
// RenewalGrace ago (RenewCertificate).
|
||||
VerifyRenewal
|
||||
)
|
||||
|
||||
// RenewalGrace is how long after expiry a certificate may still renew.
|
||||
const RenewalGrace = 30 * 24 * time.Hour
|
||||
|
||||
// VerifyAgentCertificate checks that cert was issued by the CA for client
|
||||
// authentication, is (still) valid and carries an agent ID, which it
|
||||
// returns. Serial and revocation are checked by the caller against the
|
||||
// database.
|
||||
func (a *Authority) VerifyAgentCertificate(cert *x509.Certificate, mode VerifyMode) (string, error) {
|
||||
now := a.now()
|
||||
at := now
|
||||
if mode == VerifyRenewal && now.After(cert.NotAfter) {
|
||||
if now.Sub(cert.NotAfter) > RenewalGrace {
|
||||
return "", errors.New("certificate expired more than 30 days ago")
|
||||
}
|
||||
// Check the chain at a time the certificate was valid.
|
||||
at = cert.NotAfter
|
||||
}
|
||||
if _, err := cert.Verify(x509.VerifyOptions{
|
||||
Roots: a.CAPool(), CurrentTime: at, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("verify client certificate: %w", err)
|
||||
}
|
||||
return agentpki.AgentID(cert)
|
||||
}
|
||||
|
||||
// Purpose-bound associated data for the sealed private keys.
|
||||
func keyAAD(purpose string) []byte { return secrets.AAD("pki_keys", "private_key", purpose) }
|
||||
|
||||
// LoadOrCreate reads the CA and the bundle signing key from the database
|
||||
// and creates them on first start. Concurrent first starts are safe: the
|
||||
// first insert wins and every server reads back the stored key.
|
||||
func LoadOrCreate(ctx context.Context, st *store.Store, kr *secrets.Keyring, log *slog.Logger, now func() time.Time) (*Authority, error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
caCert, caKey, err := loadCA(ctx, st, kr, log, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundleKey, err := loadBundleKey(ctx, st, kr, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return New(caCert, caKey, bundleKey, now)
|
||||
}
|
||||
|
||||
func loadCA(ctx context.Context, st *store.Store, kr *secrets.Keyring, log *slog.Logger, now func() time.Time) (*x509.Certificate, *ecdsa.PrivateKey, error) {
|
||||
k, err := st.Q().GetPKIKey(ctx, store.PKIAgentCA)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
if err := createCA(ctx, st, kr, log, now); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
k, err = st.Q().GetPKIKey(ctx, store.PKIAgentCA)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load agent CA: %w", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(k.Certificate)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse agent CA certificate: %w", err)
|
||||
}
|
||||
der, err := kr.Open(k.PrivateKey, keyAAD(store.PKIAgentCA))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("decrypt agent CA key: %w", err)
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(der)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse agent CA key: %w", err)
|
||||
}
|
||||
key, ok := parsed.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("agent CA key is not an ECDSA key")
|
||||
}
|
||||
return cert, key, nil
|
||||
}
|
||||
|
||||
func loadBundleKey(ctx context.Context, st *store.Store, kr *secrets.Keyring, log *slog.Logger) (ed25519.PrivateKey, error) {
|
||||
k, err := st.Q().GetPKIKey(ctx, store.PKIBundleSigning)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
if err := createBundleKey(ctx, st, kr, log); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, err = st.Q().GetPKIKey(ctx, store.PKIBundleSigning)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load bundle signing key: %w", err)
|
||||
}
|
||||
seed, err := kr.Open(k.PrivateKey, keyAAD(store.PKIBundleSigning))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt bundle signing key: %w", err)
|
||||
}
|
||||
if len(seed) != ed25519.SeedSize {
|
||||
return nil, errors.New("bundle signing key has an invalid size")
|
||||
}
|
||||
key := ed25519.NewKeyFromSeed(seed)
|
||||
if !key.Public().(ed25519.PublicKey).Equal(ed25519.PublicKey(k.PublicKey)) {
|
||||
return nil, errors.New("bundle signing key does not match its public key")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// createCA generates the CA and stores it unless another server was faster.
|
||||
func createCA(ctx context.Context, st *store.Store, kr *secrets.Keyring, log *slog.Logger, now func() time.Time) error {
|
||||
cert, key, err := newCA(now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sealed, err := kr.Seal(der, keyAAD(store.PKIAgentCA))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inserted, err := st.Q().InsertPKIKeyIfAbsent(ctx, &store.PKIKey{Purpose: store.PKIAgentCA, Certificate: cert.Raw,
|
||||
PublicKey: cert.RawSubjectPublicKeyInfo, PrivateKey: sealed})
|
||||
if err != nil {
|
||||
return fmt.Errorf("store agent CA: %w", err)
|
||||
}
|
||||
if inserted {
|
||||
log.Info("created agent CA", slog.String("fingerprint", agentpki.Fingerprint(cert)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createBundleKey generates the bundle signing key and stores it unless
|
||||
// another server was faster.
|
||||
func createBundleKey(ctx context.Context, st *store.Store, kr *secrets.Keyring, log *slog.Logger) error {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sealed, err := kr.Seal(priv.Seed(), keyAAD(store.PKIBundleSigning))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inserted, err := st.Q().InsertPKIKeyIfAbsent(ctx, &store.PKIKey{Purpose: store.PKIBundleSigning, PublicKey: pub, PrivateKey: sealed})
|
||||
if err != nil {
|
||||
return fmt.Errorf("store bundle signing key: %w", err)
|
||||
}
|
||||
if inserted {
|
||||
log.Info("created bundle signing key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
||||
|
||||
package pki_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
|
||||
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
|
||||
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
|
||||
"git.beugsiud.de/beug.k/DDI/server/internal/store/storetest"
|
||||
)
|
||||
|
||||
const agentID = "0f8fad5b-d9cb-469f-a165-70867728950e"
|
||||
|
||||
func csrFor(t *testing.T, key crypto.Signer) []byte {
|
||||
t.Helper()
|
||||
der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: "ignored"}, DNSNames: []string{"evil.example"},
|
||||
}, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return der
|
||||
}
|
||||
|
||||
func TestCAAndServerCertificate(t *testing.T) {
|
||||
now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
a, err := pki.Generate(func() time.Time { return now })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ca := a.CACertificate()
|
||||
if !ca.IsCA || ca.NotAfter.Sub(now) != pki.CALifetime || ca.PublicKeyAlgorithm != x509.ECDSA {
|
||||
t.Errorf("CA %v %v %v", ca.IsCA, ca.NotAfter, ca.PublicKeyAlgorithm)
|
||||
}
|
||||
if fp, err := agentpki.ParseFingerprint(a.Fingerprint()); err != nil || fp != a.Fingerprint() {
|
||||
t.Errorf("fingerprint %q: %v", a.Fingerprint(), err)
|
||||
}
|
||||
|
||||
tc, err := a.ServerCertificate([]string{"ddi.example.test", "192.0.2.1", "[2001:db8::1]"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tc.Certificate) != 2 {
|
||||
t.Fatalf("chain length %d", len(tc.Certificate))
|
||||
}
|
||||
leaf := tc.Leaf
|
||||
if leaf.NotAfter.Sub(now) != pki.ServerCertLifetime || len(leaf.DNSNames) != 1 || len(leaf.IPAddresses) != 2 {
|
||||
t.Errorf("leaf %v %v %v", leaf.NotAfter, leaf.DNSNames, leaf.IPAddresses)
|
||||
}
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{Roots: a.CAPool(), DNSName: "192.0.2.1", CurrentTime: now}); err != nil {
|
||||
t.Errorf("verify IP SAN: %v", err)
|
||||
}
|
||||
if _, err := a.ServerCertificate(nil); err == nil {
|
||||
t.Error("server certificate without names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerChainWithRealClock(t *testing.T) {
|
||||
a, err := pki.Generate(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tc, err := a.ServerCertificate([]string{"agents.example.test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ca, err := agentpki.VerifyChainWithFingerprint(tc.Certificate, a.Fingerprint(), "agents.example.test")
|
||||
if err != nil || !ca.Equal(a.CACertificate()) {
|
||||
t.Fatalf("fingerprint verification: %v", err)
|
||||
}
|
||||
other, _ := pki.Generate(nil)
|
||||
if _, err := agentpki.VerifyChainWithFingerprint(tc.Certificate, other.Fingerprint(), "agents.example.test"); err == nil {
|
||||
t.Error("wrong fingerprint accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignAgentCSR(t *testing.T) {
|
||||
clock := time.Now()
|
||||
a, err := pki.Generate(func() time.Time { return clock })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ecKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
_, edKey, _ := ed25519.GenerateKey(rand.Reader)
|
||||
seen := map[string]bool{}
|
||||
for name, key := range map[string]crypto.Signer{"ecdsa": ecKey, "ed25519": edKey} {
|
||||
cert, err := a.SignAgentCSR(csrFor(t, key), agentID)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
id, err := agentpki.AgentID(cert)
|
||||
if err != nil || id != agentID {
|
||||
t.Errorf("%s: agent ID %q %v", name, id, err)
|
||||
}
|
||||
if len(cert.DNSNames) != 0 || len(cert.ExtKeyUsage) != 1 || cert.ExtKeyUsage[0] != x509.ExtKeyUsageClientAuth || cert.IsCA {
|
||||
t.Errorf("%s: unexpected certificate contents", name)
|
||||
}
|
||||
if cert.NotAfter.Sub(clock) != pki.AgentCertLifetime {
|
||||
t.Errorf("%s: lifetime %v", name, cert.NotAfter.Sub(clock))
|
||||
}
|
||||
if cert.SerialNumber.BitLen() > 128 || seen[pki.SerialHex(cert.SerialNumber)] {
|
||||
t.Errorf("%s: serial %v", name, cert.SerialNumber)
|
||||
}
|
||||
seen[pki.SerialHex(cert.SerialNumber)] = true
|
||||
if got, err := a.VerifyAgentCertificate(cert, pki.VerifyCurrent); err != nil || got != agentID {
|
||||
t.Errorf("%s: verify %q %v", name, got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rejected requests.
|
||||
rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
p384, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
for name, csr := range map[string][]byte{
|
||||
"rsa": csrFor(t, rsaKey),
|
||||
"p384": csrFor(t, p384),
|
||||
"garbage": []byte("not a csr"),
|
||||
} {
|
||||
if _, err := a.SignAgentCSR(csr, agentID); !errors.Is(err, pki.ErrInvalidCSR) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
tampered := csrFor(t, ecKey)
|
||||
tampered[len(tampered)-5] ^= 0xff
|
||||
if _, err := a.SignAgentCSR(tampered, agentID); err == nil {
|
||||
t.Error("tampered CSR accepted")
|
||||
}
|
||||
if _, err := a.SignAgentCSR(csrFor(t, ecKey), "not-a-uuid"); err == nil {
|
||||
t.Error("invalid agent ID accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyAgentCertificateExpiry(t *testing.T) {
|
||||
clock := time.Now()
|
||||
a, _ := pki.Generate(func() time.Time { return clock })
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
cert, err := a.SignAgentCSR(csrFor(t, key), agentID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clock = cert.NotAfter.Add(time.Hour)
|
||||
if _, err := a.VerifyAgentCertificate(cert, pki.VerifyCurrent); err == nil {
|
||||
t.Error("expired certificate accepted")
|
||||
}
|
||||
if _, err := a.VerifyAgentCertificate(cert, pki.VerifyRenewal); err != nil {
|
||||
t.Errorf("renewal within grace: %v", err)
|
||||
}
|
||||
clock = cert.NotAfter.Add(pki.RenewalGrace + time.Hour)
|
||||
if _, err := a.VerifyAgentCertificate(cert, pki.VerifyRenewal); err == nil {
|
||||
t.Error("renewal after grace accepted")
|
||||
}
|
||||
|
||||
// A certificate from another CA is rejected.
|
||||
clock = time.Now()
|
||||
other, _ := pki.Generate(nil)
|
||||
foreign, _ := other.SignAgentCSR(csrFor(t, key), agentID)
|
||||
if _, err := a.VerifyAgentCertificate(foreign, pki.VerifyCurrent); err == nil {
|
||||
t.Error("foreign certificate accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreate(t *testing.T) {
|
||||
st := storetest.New(t)
|
||||
keyData, _ := secrets.GenerateKeyFile("k1")
|
||||
kr, _ := secrets.ParseKeyFile(keyData)
|
||||
ctx := context.Background()
|
||||
|
||||
// Concurrent first starts end up with the same keys.
|
||||
const n = 4
|
||||
auths := make([]*pki.Authority, n)
|
||||
errs := make([]error, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
auths[i], errs[i] = pki.LoadOrCreate(ctx, st, kr, nil, nil)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for i := range n {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("start %d: %v", i, errs[i])
|
||||
}
|
||||
if auths[i].Fingerprint() != auths[0].Fingerprint() || !auths[i].BundlePublicKey().Equal(auths[0].BundlePublicKey()) {
|
||||
t.Errorf("start %d created different keys", i)
|
||||
}
|
||||
}
|
||||
|
||||
again, err := pki.LoadOrCreate(ctx, st, kr, nil, nil)
|
||||
if err != nil || again.Fingerprint() != auths[0].Fingerprint() {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
msg := []byte("bundle")
|
||||
if !ed25519.Verify(auths[0].BundlePublicKey(), msg, ed25519.Sign(again.BundleSigningKey(), msg)) {
|
||||
t.Error("reloaded bundle key differs")
|
||||
}
|
||||
|
||||
// Another master key cannot open the keys.
|
||||
otherData, _ := secrets.GenerateKeyFile("k2")
|
||||
otherKR, _ := secrets.ParseKeyFile(otherData)
|
||||
if _, err := pki.LoadOrCreate(ctx, st, otherKR, nil, nil); err == nil {
|
||||
t.Error("keys opened with a foreign master key")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user