diff --git a/cmd/ateapi/internal/actoridentity/actoridentity.go b/cmd/ateapi/internal/actoridentity/actoridentity.go index 8de6dc0b3..2c3eef1fa 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity.go @@ -50,7 +50,7 @@ type Server struct { // TODO: Cache the signing keys in memory, so we don't read from a file every time. actorIDJWTPoolFile string - actorIDCAPoolFile string + actorIDCAPool localca.Pool // store is the actor database. MintCert consults it to confirm the caller // is entitled to the actor it is asking for a credential for. @@ -60,11 +60,11 @@ type Server struct { var _ ateapipb.ActorIdentityServer = (*Server)(nil) -func New(actorIdentityJWTIssuer, actorIDJWTPoolFile, actorIDCAPoolFile string, store store.Interface, workers *workercache.Cache) *Server { +func New(actorIdentityJWTIssuer, actorIDJWTPoolFile string, actorIDCAPool localca.Pool, store store.Interface, workers *workercache.Cache) *Server { return &Server{ actorIdentityJWTIssuer: actorIdentityJWTIssuer, actorIDJWTPoolFile: actorIDJWTPoolFile, - actorIDCAPoolFile: actorIDCAPoolFile, + actorIDCAPool: actorIDCAPool, store: store, workers: workers, } @@ -175,18 +175,6 @@ func (s *Server) MintCert(ctx context.Context, req *ateapipb.MintCertRequest) (* return nil, status.Error(codes.FailedPrecondition, "worker assignment changed while minting actor certificate") } - // Load the CA pool for signing - poolBytes, err := os.ReadFile(s.actorIDCAPoolFile) - if err != nil { - slog.ErrorContext(ctx, "Failed to read actor CA pool file", slog.Any("err", err)) - return nil, status.Errorf(codes.Internal, "Failed to load actor CA") - } - caPool, err := localca.Unmarshal(poolBytes) - if err != nil || len(caPool.CAs) == 0 { - slog.ErrorContext(ctx, "Failed to load actor CA", slog.Any("err", err)) - return nil, status.Errorf(codes.Internal, "Failed to load actor CA") - } - // Parse the CSR csr, err := x509.ParseCertificateRequest(req.GetCertificateSigningRequest()) if err != nil { @@ -227,20 +215,14 @@ func (s *Server) MintCert(ctx context.Context, req *ateapipb.MintCertRequest) (* } // Sign and return the actor cert. - ca := caPool.CAs[0] - derBytes, err := x509.CreateCertificate(rand.Reader, template, ca.RootCertificate, csr.PublicKey, ca.SigningKey) + chain, err := s.actorIDCAPool.CreateCertificate(template, csr.PublicKey) if err != nil { slog.ErrorContext(ctx, "Failed to sign certificate", slog.Any("err", err)) return nil, status.Errorf(codes.Internal, "Failed to sign certificate") } - certificates := [][]byte{derBytes} - for _, intermed := range ca.IntermediateCertificates { - certificates = append(certificates, intermed.Raw) - } - return &ateapipb.MintCertResponse{ - ActorCertificates: certificates, + ActorCertificates: chain, }, nil } diff --git a/cmd/ateapi/internal/actoridentity/actoridentity_test.go b/cmd/ateapi/internal/actoridentity/actoridentity_test.go index e91420d38..8c04f2157 100644 --- a/cmd/ateapi/internal/actoridentity/actoridentity_test.go +++ b/cmd/ateapi/internal/actoridentity/actoridentity_test.go @@ -23,9 +23,7 @@ import ( "crypto/x509/pkix" "math/big" "net/url" - "os" "path" - "path/filepath" "testing" "time" @@ -140,13 +138,9 @@ func newTestServer(t *testing.T, st store.Interface) *Server { if err != nil { t.Fatalf("generate CA: %v", err) } - poolBytes, err := localca.Marshal(&localca.Pool{CAs: []*localca.CA{ca}}) - if err != nil { - t.Fatalf("marshal CA pool: %v", err) - } - poolFile := filepath.Join(t.TempDir(), "actor-ca-pool.json") - if err := os.WriteFile(poolFile, poolBytes, 0o600); err != nil { - t.Fatalf("write CA pool: %v", err) + pool := &localca.ConcretePool{ + CAs: []*localca.CA{ca}, + ActiveForSigning: "test-actor-ca", } var workers *workercache.Cache @@ -158,7 +152,7 @@ func newTestServer(t *testing.T, st store.Interface) *Server { t.Fatalf("start worker cache: %v", err) } } - return New("issuer", "", poolFile, st, workers) + return New("issuer", "", pool, st, workers) } func TestMintJWTRequiresConfiguredJWTProvider(t *testing.T) { @@ -726,7 +720,17 @@ func TestMintCertAuthorizesBeforeSigning(t *testing.T) { if err := workers.Start(cacheCtx); err != nil { t.Fatal(err) } - srv := New("issuer", "", filepath.Join(t.TempDir(), "missing.json"), st, workers) + + ca, err := localca.GenerateED25519CA("test-actor-ca") + if err != nil { + t.Fatalf("generate CA: %v", err) + } + pool := &localca.ConcretePool{ + CAs: []*localca.CA{ca}, + ActiveForSigning: "test-actor-ca", + } + + srv := New("issuer", "", pool, st, workers) actor, err := st.GetActor(ctx, resources.ActorRef{Atespace: testAtespace, Name: testActorName}) if err != nil { diff --git a/cmd/ateapi/main.go b/cmd/ateapi/main.go index 5d1d3e0d5..a0554e3fd 100644 --- a/cmd/ateapi/main.go +++ b/cmd/ateapi/main.go @@ -37,6 +37,7 @@ import ( "github.com/agent-substrate/substrate/internal/ateapiauth" "github.com/agent-substrate/substrate/internal/ateinterceptors" "github.com/agent-substrate/substrate/internal/credbundle" + "github.com/agent-substrate/substrate/internal/localca" "github.com/agent-substrate/substrate/internal/serverboot" "github.com/agent-substrate/substrate/internal/version" "github.com/agent-substrate/substrate/internal/volume" @@ -195,7 +196,12 @@ func main() { ateletDialer := controlapi.NewAteletDialer(workerPodInformer.GetIndexer(), ateletPodInformer.GetIndexer(), *ateletClientCredBundle, *podIdentityCACerts) sm := controlapi.NewService(persistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, csiDriverConfigLister, storageClassLister, ateletDialer, instruments, *egressGatewayAddress, volPlugins) - actorIdentitySrv := actoridentity.New(actorIdentityJWTIssuer, *actorIDJWTPoolFile, *actorIDCAPoolFile, persistence, workerCache) + actorIDCAPool, err := localca.NewRefreshingPool(*actorIDCAPoolFile) + if err != nil { + serverboot.Fatal(ctx, "while loading the Actor ID CA", err) + } + + actorIdentitySrv := actoridentity.New(actorIdentityJWTIssuer, *actorIDJWTPoolFile, actorIDCAPool, persistence, workerCache) debugSrv := debugapi.NewService(persistence) lisCfg := &net.ListenConfig{} diff --git a/cmd/kubectl-ate/internal/cmd/admin_make_ca_pool.go b/cmd/kubectl-ate/internal/cmd/admin_make_ca_pool.go index ab42f2690..0602aaed8 100644 --- a/cmd/kubectl-ate/internal/cmd/admin_make_ca_pool.go +++ b/cmd/kubectl-ate/internal/cmd/admin_make_ca_pool.go @@ -50,7 +50,7 @@ var makeCaPoolCmd = &cobra.Command{ return fmt.Errorf("while generating CA: %w", err) } - pool := &localca.Pool{ + pool := &localca.ConcretePool{ CAs: []*localca.CA{ca}, } diff --git a/cmd/podcertcontroller/internal/podidentitysigner/podidentitysigner.go b/cmd/podcertcontroller/internal/podidentitysigner/podidentitysigner.go index 3ea8c6688..25a50312d 100644 --- a/cmd/podcertcontroller/internal/podidentitysigner/podidentitysigner.go +++ b/cmd/podcertcontroller/internal/podidentitysigner/podidentitysigner.go @@ -17,7 +17,6 @@ package podidentitysigner import ( "bytes" "context" - "crypto/rand" "crypto/x509" "encoding/pem" "fmt" @@ -67,12 +66,12 @@ func extKeyUsages(pod *corev1.Pod, namespace, serviceAccount string) []x509.ExtK type Impl struct { kc kubernetes.Interface - caPool *localca.Pool + caPool localca.Pool clock clock.PassiveClock } -func NewImpl(kc kubernetes.Interface, caPool *localca.Pool, clock clock.PassiveClock) *Impl { +func NewImpl(kc kubernetes.Interface, caPool localca.Pool, clock clock.PassiveClock) *Impl { return &Impl{ kc: kc, caPool: caPool, @@ -86,14 +85,19 @@ func (h *Impl) SignerName() string { return Name } -func (h *Impl) DesiredClusterTrustBundles() []*certsv1beta1.ClusterTrustBundle { +func (h *Impl) DesiredClusterTrustBundles() ([]*certsv1beta1.ClusterTrustBundle, error) { name := CTBPrefix + "primary-bundle" + trustAnchors, err := h.caPool.TrustAnchors() + if err != nil { + return nil, fmt.Errorf("while retrieving CA pool trust anchors: %w", err) + } + wantTrustBundle := bytes.Buffer{} - for _, ca := range h.caPool.CAs { + for _, anchor := range trustAnchors { block := pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE", - Bytes: ca.RootCertificate.Raw, + Bytes: anchor.Raw, }) _, _ = wantTrustBundle.Write(block) } @@ -113,7 +117,7 @@ func (h *Impl) DesiredClusterTrustBundles() []*certsv1beta1.ClusterTrustBundle { return []*certsv1beta1.ClusterTrustBundle{ wantCTB, - } + }, nil } func (h *Impl) MakeCert(ctx context.Context, pcr *certsv1beta1.PodCertificateRequest) error { @@ -148,8 +152,6 @@ func (h *Impl) MakeCert(ctx context.Context, pcr *certsv1beta1.PodCertificateReq Path: path.Join("ns", pcr.ObjectMeta.Namespace, "sa", pcr.Spec.ServiceAccountName), } - parent := h.caPool.CAs[0].RootCertificate - template := &x509.Certificate{ BasicConstraintsValid: true, NotBefore: notBefore, @@ -157,11 +159,8 @@ func (h *Impl) MakeCert(ctx context.Context, pcr *certsv1beta1.PodCertificateReq URIs: []*url.URL{spiffeURI}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: extKeyUsages(pod, pcr.ObjectMeta.Namespace, pcr.Spec.ServiceAccountName), - // Link the leaf to its issuing CA by key id so verifiers can disambiguate - // a multi-CA trust bundle (e.g. valkey trusts both the servicedns and - // podidentity CAs). - // https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.1 - AuthorityKeyId: parent.SubjectKeyId, + // AuthorityKeyID is automatically set to the SubjectKeyID of the parent + // certificate. } // Fields are sourced from the PCR spec (attested by kube-apiserver) rather @@ -179,14 +178,9 @@ func (h *Impl) MakeCert(ctx context.Context, pcr *certsv1beta1.PodCertificateReq return fmt.Errorf("while adding pod identity to certificate: %w", err) } - subjectCertDER, err := x509.CreateCertificate(rand.Reader, template, parent, subjectPublicKey, h.caPool.CAs[0].SigningKey) + chainDER, err := h.caPool.CreateCertificate(template, subjectPublicKey) if err != nil { - return fmt.Errorf("while signing subject cert: %w", err) - } - - chainDER := [][]byte{subjectCertDER} - for _, intermed := range h.caPool.CAs[0].IntermediateCertificates { - chainDER = append(chainDER, intermed.Raw) + return fmt.Errorf("while signing certificate: %w", err) } chainPEM := &bytes.Buffer{} diff --git a/cmd/podcertcontroller/internal/podidentitysigner/podidentitysigner_test.go b/cmd/podcertcontroller/internal/podidentitysigner/podidentitysigner_test.go index f3bd18d1f..a05ebc07a 100644 --- a/cmd/podcertcontroller/internal/podidentitysigner/podidentitysigner_test.go +++ b/cmd/podcertcontroller/internal/podidentitysigner/podidentitysigner_test.go @@ -212,7 +212,7 @@ func TestMakeCert(t *testing.T) { if err != nil { t.Fatalf("while generating CA: %v", err) } - caPool := &localca.Pool{CAs: []*localca.CA{ca}} + caPool := &localca.ConcretePool{CAs: []*localca.CA{ca}} subjectPub, subjectPriv, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -340,7 +340,7 @@ func TestMakeCertErrors(t *testing.T) { if err != nil { t.Fatalf("while generating CA: %v", err) } - caPool := &localca.Pool{CAs: []*localca.CA{ca}} + caPool := &localca.ConcretePool{CAs: []*localca.CA{ca}} pod, pcr := makePodAndPCR("ate-system", "atelet-abcde", "atelet", 86400) pod.ObjectMeta.UID = tc.podUID @@ -389,7 +389,7 @@ func TestMakeCertChainIncludesIntermediates(t *testing.T) { t.Fatalf("while generating intermediate CA: %v", err) } ca.IntermediateCertificates = []*x509.Certificate{intermediateCA.RootCertificate} - caPool := &localca.Pool{CAs: []*localca.CA{ca}} + caPool := &localca.ConcretePool{CAs: []*localca.CA{ca}} _, subjectPriv, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -444,10 +444,13 @@ func TestDesiredClusterTrustBundles(t *testing.T) { if err != nil { t.Fatalf("while generating CA 2: %v", err) } - caPool := &localca.Pool{CAs: []*localca.CA{ca1, ca2}} + caPool := &localca.ConcretePool{CAs: []*localca.CA{ca1, ca2}} impl := NewImpl(nil, caPool, fixedClock{now: testNow}) - ctbs := impl.DesiredClusterTrustBundles() + ctbs, err := impl.DesiredClusterTrustBundles() + if err != nil { + t.Fatalf("Error while getting desired ClusterTrustBundles: %v", err) + } if len(ctbs) != 1 { t.Fatalf("got %d ClusterTrustBundles, want 1", len(ctbs)) } diff --git a/cmd/podcertcontroller/internal/servicednssigner/servicednssigner.go b/cmd/podcertcontroller/internal/servicednssigner/servicednssigner.go index 1a995b20d..06f7d21e4 100644 --- a/cmd/podcertcontroller/internal/servicednssigner/servicednssigner.go +++ b/cmd/podcertcontroller/internal/servicednssigner/servicednssigner.go @@ -17,7 +17,6 @@ package servicednssigner import ( "bytes" "context" - "crypto/rand" "crypto/x509" "encoding/pem" "fmt" @@ -40,12 +39,12 @@ const CTBPrefix = "servicedns.podcert.ate.dev:identity:" type Impl struct { kc kubernetes.Interface - caPool *localca.Pool + caPool localca.Pool clock clock.PassiveClock } -func NewImpl(kc kubernetes.Interface, caPool *localca.Pool, clock clock.PassiveClock) *Impl { +func NewImpl(kc kubernetes.Interface, caPool localca.Pool, clock clock.PassiveClock) *Impl { return &Impl{ kc: kc, caPool: caPool, @@ -59,14 +58,19 @@ func (h *Impl) SignerName() string { return Name } -func (h *Impl) DesiredClusterTrustBundles() []*certsv1beta1.ClusterTrustBundle { +func (h *Impl) DesiredClusterTrustBundles() ([]*certsv1beta1.ClusterTrustBundle, error) { name := CTBPrefix + "primary-bundle" + trustAnchors, err := h.caPool.TrustAnchors() + if err != nil { + return nil, fmt.Errorf("while retrieving CA pool trust anchors: %w", err) + } + wantTrustBundle := bytes.Buffer{} - for _, ca := range h.caPool.CAs { + for _, anchor := range trustAnchors { block := pem.EncodeToMemory(&pem.Block{ Type: "CERTIFICATE", - Bytes: ca.RootCertificate.Raw, + Bytes: anchor.Raw, }) _, _ = wantTrustBundle.Write(block) } @@ -86,7 +90,7 @@ func (h *Impl) DesiredClusterTrustBundles() []*certsv1beta1.ClusterTrustBundle { return []*certsv1beta1.ClusterTrustBundle{ wantCTB, - } + }, nil } func (h *Impl) MakeCert(ctx context.Context, pcr *certsv1beta1.PodCertificateRequest) error { @@ -163,7 +167,6 @@ func (h *Impl) MakeCert(ctx context.Context, pcr *certsv1beta1.PodCertificateReq notAfter := notBefore.Add(lifetime) beginRefreshAt := notAfter.Add(-30 * time.Minute) - parent := h.caPool.CAs[0].RootCertificate template := &x509.Certificate{ BasicConstraintsValid: true, NotBefore: notBefore, @@ -171,19 +174,13 @@ func (h *Impl) MakeCert(ctx context.Context, pcr *certsv1beta1.PodCertificateReq DNSNames: dnsNames, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, - // Link the leaf to its issuing CA by key id. Needed this for Valkey - // to understand which CA to use when validating a client cert. - AuthorityKeyId: parent.SubjectKeyId, + // AuthorityKeyID is automatically set to the SubjectKeyID of the parent + // certificate. } - subjectCertDER, err := x509.CreateCertificate(rand.Reader, template, parent, subjectPublicKey, h.caPool.CAs[0].SigningKey) + chainDER, err := h.caPool.CreateCertificate(template, subjectPublicKey) if err != nil { - return fmt.Errorf("while signing subject cert: %w", err) - } - - chainDER := [][]byte{subjectCertDER} - for _, intermed := range h.caPool.CAs[0].IntermediateCertificates { - chainDER = append(chainDER, intermed.Raw) + return fmt.Errorf("while signing certificate: %w", err) } chainPEM := &bytes.Buffer{} diff --git a/cmd/podcertcontroller/internal/signercontroller/signercontroller.go b/cmd/podcertcontroller/internal/signercontroller/signercontroller.go index 87c59e31b..e7e0e5c56 100644 --- a/cmd/podcertcontroller/internal/signercontroller/signercontroller.go +++ b/cmd/podcertcontroller/internal/signercontroller/signercontroller.go @@ -37,7 +37,7 @@ import ( type SignerImpl interface { SignerName() string - DesiredClusterTrustBundles() []*certsv1beta1.ClusterTrustBundle + DesiredClusterTrustBundles() ([]*certsv1beta1.ClusterTrustBundle, error) MakeCert(context.Context, *certsv1beta1.PodCertificateRequest) error } @@ -206,7 +206,14 @@ func (c *Controller) ensureBundles(ctx context.Context) { return } - wantCTBs := c.handler.DesiredClusterTrustBundles() + wantCTBs, err := c.handler.DesiredClusterTrustBundles() + if err != nil { + slog.ErrorContext(ctx, "Error while retrieving CA trust anchors", + slog.String("err", err.Error()), + slog.String("signer", c.handler.SignerName()), + ) + return + } for _, wantCTB := range wantCTBs { ctb, err := c.kc.CertificatesV1beta1().ClusterTrustBundles().Get(ctx, wantCTB.ObjectMeta.Name, metav1.GetOptions{}) diff --git a/cmd/podcertcontroller/main.go b/cmd/podcertcontroller/main.go index 08e68fc69..551792daf 100644 --- a/cmd/podcertcontroller/main.go +++ b/cmd/podcertcontroller/main.go @@ -121,28 +121,18 @@ func main() { go hasher.Run(ctx) // Create a signer for servicedns.ate.dev/identity - serviceDNSCAPoolBytes, err := os.ReadFile(*serviceDNSCAPoolFile) + serviceDNSCAPool, err := localca.NewRefreshingPool(*serviceDNSCAPoolFile) if err != nil { - slog.ErrorContext(ctx, "Error reading servicedns.ate.dev/identity CA pool state", slog.Any("err", err)) - os.Exit(1) - } - serviceDNSCAPool, err := localca.Unmarshal(serviceDNSCAPoolBytes) - if err != nil { - slog.ErrorContext(ctx, "Error unmarshing servicedns.ate.dev/identity CA pool state", slog.Any("err", err)) + slog.ErrorContext(ctx, "Error loading servicedns.ate.dev/identity CA pool state", slog.Any("err", err)) os.Exit(1) } serviceDNSSignerController := signercontroller.New(clock.RealClock{}, servicednssigner.NewImpl(kc, serviceDNSCAPool, clock.RealClock{}), kc, hasher) go serviceDNSSignerController.Run(ctx) // Create a signer for podidentity.podcert.ate.dev/identity - podIdentityCAPoolBytes, err := os.ReadFile(*podCAPoolFile) - if err != nil { - slog.ErrorContext(ctx, "Error reading podidentity.podcert.ate.dev/identity CA pool state", slog.Any("err", err)) - os.Exit(1) - } - podIdentityCAPool, err := localca.Unmarshal(podIdentityCAPoolBytes) + podIdentityCAPool, err := localca.NewRefreshingPool(*podCAPoolFile) if err != nil { - slog.ErrorContext(ctx, "Error unmarshing podidentity.podcert.ate.dev/identity CA pool state", slog.Any("err", err)) + slog.ErrorContext(ctx, "Error loading podidentity.podcert.ate.dev/identity CA pool state", slog.Any("err", err)) os.Exit(1) } podIdentitySignerController := signercontroller.New(clock.RealClock{}, podidentitysigner.NewImpl(kc, podIdentityCAPool, clock.RealClock{}), kc, hasher) diff --git a/internal/localca/localca.go b/internal/localca/localca.go index 3078435ed..0bee8e596 100644 --- a/internal/localca/localca.go +++ b/internal/localca/localca.go @@ -14,6 +14,20 @@ // Package localca implements a CA whose state can be stored in a local file or // Kubernetes secret. +// +// In substrate's default setup, the CA pool state is kept in a Kubernetes +// secret, and administered with admin CLI commands. +// +// If you are writing an online signing component, us a projected volume to put +// the secret's content into your container's filesystem, and then point a +// RefreshingPool at the file. Even if an administrator rotates the pool, your +// component will continue to work correctly with no restarts. +// +// If you are writing an admin command, read the secret from the Kubernetes API, +// use Unmarshal to parse it to a Pool, manipulate the Pool, and then use +// Marshal to serialize the state and write it back to the secret. +// +// For tests, generate an ephemeral ConcretePool. package localca import ( @@ -22,38 +36,188 @@ import ( "crypto/rand" "crypto/x509" "encoding/json" - "encoding/pem" "fmt" + "os" + "sync" "time" + + "k8s.io/utils/clock" ) -type Pool struct { +// Pool is the interface for a CA pool. +// +// Logically, a Pool is a collection of multiple CAs. One or more are +// designated as active for signing. The rest are inactive, but are still +// trusted for verifying certificates. +// +// The active/inactive designation allows a Pool to be seamlessly rotated. +// +// 1. (Steady State) The Pool has one CA, active for signing. +// 2. (Publish New Root) Add a new CA, inactive. +// 3. (Age In) Wait for trust in the new root to propagate throughout the system. +// 3. (Switch) Switch the new CA to be active, and the old CA to be inactive. +// 4. (Age Out) Wait for all certificates issued by the old CA to expire. +// 5. (Cleanup) Remove the old CA from the Pool. +// +// Normally, we let callers define their own compatibility interfaces. But in +// most cases you'll want to either use a RefreshingPool (for controllers and +// servers), or a ConcretePool (for CLIs and tests). +type Pool interface { + // CreateCertificate signs the given template certificate using one of the + // Pool's currently-active CAs. + CreateCertificate(template *x509.Certificate, subjectPublicKey crypto.PublicKey) ([][]byte, error) + + // TrustAnchors returns the root certificates for all of the pool's CAs, including + TrustAnchors() ([]*x509.Certificate, error) +} + +// RefreshingPool is a wrapper around Pool that periodically reloads the CA +// state from disk. This allows our various pieces that sign certificates +// (Actor identity broker, egress gateway) to properly continue signing even as +// an administrator rotates one of the CA pools, without requiring any +// components to restart. +type RefreshingPool struct { + stateFile string + clock clock.PassiveClock + + // lock covers nextLoad and pool + lock sync.Mutex + nextLoad time.Time + pool *ConcretePool +} + +var _ Pool = (*RefreshingPool)(nil) + +func NewRefreshingPool(stateFile string) (*RefreshingPool, error) { + rp := &RefreshingPool{ + stateFile: stateFile, + clock: clock.RealClock{}, + } + if err := rp.refreshIfNecessary(); err != nil { + return nil, fmt.Errorf("while loading pool: %w", err) + } + return rp, nil +} + +// refreshIfNecessary must be called while p.lock is held. +func (p *RefreshingPool) refreshIfNecessary() error { + if p.pool != nil && p.clock.Now().Before(p.nextLoad) { + return nil + } + + poolBytes, err := os.ReadFile(p.stateFile) + if err != nil { + return fmt.Errorf("while reading pool state: %w", err) + } + + pool, err := Unmarshal(poolBytes) + if err != nil { + return fmt.Errorf("while unmarshaling pool: %w", err) + } + + p.pool = pool + p.nextLoad = p.clock.Now().Add(time.Minute) + + return nil +} + +func (p *RefreshingPool) CreateCertificate(template *x509.Certificate, subjectPublicKey crypto.PublicKey) ([][]byte, error) { + p.lock.Lock() + defer p.lock.Unlock() + if err := p.refreshIfNecessary(); err != nil { + return nil, fmt.Errorf("while refreshing pool: %w", err) + } + return p.pool.CreateCertificate(template, subjectPublicKey) +} + +func (p *RefreshingPool) TrustAnchors() ([]*x509.Certificate, error) { + p.lock.Lock() + defer p.lock.Unlock() + if err := p.refreshIfNecessary(); err != nil { + return nil, fmt.Errorf("while refreshing pool: %w", err) + } + return p.pool.TrustAnchors() +} + +type ConcretePool struct { CAs []*CA + + // Which CA is active for signing operations? + ActiveForSigning string +} + +var _ Pool = (*ConcretePool)(nil) + +func (p *ConcretePool) CreateCertificate(template *x509.Certificate, subjectPublicKey crypto.PublicKey) ([][]byte, error) { + if len(p.CAs) == 0 { + return nil, fmt.Errorf("pool has no CAs") + } + + // For backwards compatibility, pick the first CA if none is designated. + selectedCA := p.CAs[0] + for _, ca := range p.CAs { + if ca.ID == p.ActiveForSigning { + selectedCA = ca + } + } + + // Pick which cert should be used for signing the leaf --- if there are + // intermediates, pick the intermediate closest to the leaf. Otherwise, use + // the root certificate. + signingCert := selectedCA.RootCertificate + if len(selectedCA.IntermediateCertificates) != 0 { + signingCert = selectedCA.IntermediateCertificates[0] + } + + subjectCertDER, err := x509.CreateCertificate(rand.Reader, template, signingCert, subjectPublicKey, selectedCA.SigningKey) + if err != nil { + return nil, fmt.Errorf("while creating certificate: %w", err) + } + + chain := [][]byte{subjectCertDER} + for _, intermediate := range selectedCA.IntermediateCertificates { + chain = append(chain, intermediate.Raw) + } + + return chain, nil +} + +func (p *ConcretePool) TrustAnchors() ([]*x509.Certificate, error) { + var anchors []*x509.Certificate + for _, ca := range p.CAs { + anchors = append(anchors, ca.RootCertificate) + } + return anchors, nil } type CA struct { - ID string - SigningKey crypto.PrivateKey - RootCertificate *x509.Certificate + ID string + SigningKey crypto.PrivateKey + + // The root certificate for this CA pool. + RootCertificate *x509.Certificate + + // Any intermediate certificates, in leaf-to-root order. IntermediateCertificates []*x509.Certificate } type serializedPool struct { - CAs []*serializedCA + CAs []*serializedCA + ActiveForSigning string } type serializedCA struct { ID string SigningKeyPKCS8 []byte - SigningKeyPEM string RootCertificateDER []byte - RootCertificatePEM string IntermediateCertificatesDER [][]byte } -func Marshal(ca *Pool) ([]byte, error) { - wire := &serializedPool{} +func Marshal(pool *ConcretePool) ([]byte, error) { + wire := &serializedPool{ + ActiveForSigning: pool.ActiveForSigning, + } - for _, ca := range ca.CAs { + for _, ca := range pool.CAs { caWire := &serializedCA{} caWire.ID = ca.ID @@ -80,7 +244,7 @@ func Marshal(ca *Pool) ([]byte, error) { return wireBytes, nil } -func Unmarshal(wireBytes []byte) (*Pool, error) { +func Unmarshal(wireBytes []byte) (*ConcretePool, error) { var err error wire := &serializedPool{} @@ -88,19 +252,19 @@ func Unmarshal(wireBytes []byte) (*Pool, error) { return nil, fmt.Errorf("while unmarshaling JSON: %w", err) } - pool := &Pool{} + pool := &ConcretePool{} for _, wireCA := range wire.CAs { ca := &CA{ ID: wireCA.ID, } - ca.SigningKey, err = parsePrivateKey(wireCA.SigningKeyPKCS8, wireCA.SigningKeyPEM) + ca.SigningKey, err = x509.ParsePKCS8PrivateKey(wireCA.SigningKeyPKCS8) if err != nil { return nil, fmt.Errorf("while parsing signing key: %w", err) } - ca.RootCertificate, err = parseCertificate(wireCA.RootCertificateDER, wireCA.RootCertificatePEM) + ca.RootCertificate, err = x509.ParseCertificate(wireCA.RootCertificateDER) if err != nil { return nil, fmt.Errorf("while parsing root certificate: %w", err) } @@ -116,44 +280,9 @@ func Unmarshal(wireBytes []byte) (*Pool, error) { pool.CAs = append(pool.CAs, ca) } - return pool, nil -} - -func parsePrivateKey(pkcs8 []byte, pemData string) (crypto.PrivateKey, error) { - if len(pkcs8) != 0 { - return x509.ParsePKCS8PrivateKey(pkcs8) - } - - block, _ := pem.Decode([]byte(pemData)) - if block == nil { - return nil, fmt.Errorf("missing PEM block") - } - - if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { - return key, nil - } - if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil { - return key, nil - } - if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { - return key, nil - } - return nil, fmt.Errorf("unsupported private key PEM type %q", block.Type) -} + pool.ActiveForSigning = wire.ActiveForSigning -func parseCertificate(der []byte, pemData string) (*x509.Certificate, error) { - if len(der) != 0 { - return x509.ParseCertificate(der) - } - - block, _ := pem.Decode([]byte(pemData)) - if block == nil { - return nil, fmt.Errorf("missing PEM block") - } - if block.Type != "CERTIFICATE" { - return nil, fmt.Errorf("unsupported certificate PEM type %q", block.Type) - } - return x509.ParseCertificate(block.Bytes) + return pool, nil } func GenerateED25519CA(id string) (*CA, error) { diff --git a/internal/localca/localca_test.go b/internal/localca/localca_test.go index 0cca4ef6f..15b15607b 100644 --- a/internal/localca/localca_test.go +++ b/internal/localca/localca_test.go @@ -22,7 +22,6 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/json" - "encoding/pem" "math/big" "strings" "testing" @@ -92,7 +91,7 @@ func TestMarshalUnmarshalRoundTrip(t *testing.T) { t.Fatalf("GenerateED25519CA(ca-2): %v", err) } - pool := &Pool{CAs: []*CA{ca1, ca2}} + pool := &ConcretePool{CAs: []*CA{ca1, ca2}} data, err := Marshal(pool) if err != nil { @@ -167,7 +166,7 @@ func TestMarshalUnmarshalWithIntermediates(t *testing.T) { root.IntermediateCertificates = []*x509.Certificate{intermCert} - pool := &Pool{CAs: []*CA{root}} + pool := &ConcretePool{CAs: []*CA{root}} data, err := Marshal(pool) if err != nil { @@ -207,6 +206,11 @@ func TestUnmarshalPEMPool(t *testing.T) { if err != nil { t.Fatalf("GenerateKey(): %v", err) } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatalf("Error marshaling key: %v", err) + } + template := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "actor-id-ca"}, @@ -220,14 +224,12 @@ func TestUnmarshalPEMPool(t *testing.T) { if err != nil { t.Fatalf("CreateCertificate(): %v", err) } - keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) - certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})) data, err := json.Marshal(&serializedPool{ CAs: []*serializedCA{{ ID: "1", - SigningKeyPEM: keyPEM, - RootCertificatePEM: certPEM, + SigningKeyPKCS8: keyDER, + RootCertificateDER: certDER, }}, }) if err != nil { @@ -254,7 +256,7 @@ func TestUnmarshalErrors(t *testing.T) { if err != nil { t.Fatalf("GenerateED25519CA(): %v", err) } - validData, err := Marshal(&Pool{CAs: []*CA{ca}}) + validData, err := Marshal(&ConcretePool{CAs: []*CA{ca}}) if err != nil { t.Fatalf("Marshal(): %v", err) }