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:
beug.k@beugsiud.de
2026-10-13 18:54:13 +02:00
co-authored by Claude Opus 5.5
parent 8e76c0cf32
commit c60cd4a21e
2 changed files with 640 additions and 0 deletions
+421
View File
@@ -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
}
+219
View File
@@ -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")
}
}