diff --git a/cmd/cryptosctl/rotate.go b/cmd/cryptosctl/rotate.go new file mode 100644 index 0000000..0142b25 --- /dev/null +++ b/cmd/cryptosctl/rotate.go @@ -0,0 +1,97 @@ +package main + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" +) + +// newRotateKeyCmd begins a CA key rotation on an established subordinate: the +// node generates a new CA key and stages its CSR while it keeps serving with +// its current key. The printed CSR is ferried to the parent's sign-subordinate, +// then handed back with submit-rotation. Requires admin authorization on the +// node. +func newRotateKeyCmd(opts *globalOpts) *cobra.Command { + return &cobra.Command{ + Use: "rotate-key", + Short: "Begin a CA key rotation and print the new CSR (child)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, closeConn, err := dial(opts) + if err != nil { + return err + } + defer func() { _ = closeConn() }() + + resp, err := client.BeginKeyRotation(cmd.Context(), &cryptosv1.BeginKeyRotationRequest{}) + if err != nil { + return err + } + return writePEMBlock(cmd.OutOrStdout(), "CERTIFICATE REQUEST", resp.GetCsrDer()) + }, + } +} + +// newSubmitRotationCmd hands the parent-signed chain for the rotated key back to +// the child node, which verifies it against its pinned parent anchor and the +// staged rotation key, then atomically swaps to it (child side). Requires admin +// authorization on the node. +func newSubmitRotationCmd(opts *globalOpts) *cobra.Command { + var chainFile string + cmd := &cobra.Command{ + Use: "submit-rotation", + Short: "Submit the parent-signed chain for the rotated key to this node (child)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if chainFile == "" { + return errors.New("--chain is required") + } + raw, err := os.ReadFile(chainFile) + if err != nil { + return fmt.Errorf("read chain: %w", err) + } + chainDER, err := chainToDER(raw) + if err != nil { + return fmt.Errorf("parse chain: %w", err) + } + + client, closeConn, err := dial(opts) + if err != nil { + return err + } + defer func() { _ = closeConn() }() + + resp, err := client.CompleteKeyRotation(cmd.Context(), &cryptosv1.CompleteKeyRotationRequest{ + ChainDer: chainDER, + }) + if err != nil { + return err + } + return writeIdentity(cmd.OutOrStdout(), resp.GetIdentity(), opts.output) + }, + } + cmd.Flags().StringVar(&chainFile, "chain", "", "parent-signed leaf-first chain file for the rotated key (PEM, required)") + return cmd +} diff --git a/cmd/cryptosctl/subordinate.go b/cmd/cryptosctl/subordinate.go index 6a0576a..1d66592 100644 --- a/cmd/cryptosctl/subordinate.go +++ b/cmd/cryptosctl/subordinate.go @@ -42,6 +42,8 @@ func newCACmd(opts *globalOpts) *cobra.Command { newGetSubordinateCSRCmd(opts), newSignSubordinateCmd(opts), newSubmitSubordinateCertCmd(opts), + newRotateKeyCmd(opts), + newSubmitRotationCmd(opts), newIssueLeafCmd(opts), newRevokeCmd(opts), newListIssuedCmd(opts), diff --git a/cmd/cryptosctl/subordinate_test.go b/cmd/cryptosctl/subordinate_test.go index 76bde96..b19c6e7 100644 --- a/cmd/cryptosctl/subordinate_test.go +++ b/cmd/cryptosctl/subordinate_test.go @@ -87,6 +87,26 @@ func TestSubmitSubordinateCertRequiresChain(t *testing.T) { } } +func TestRotateKeyRegistered(t *testing.T) { + ca := newCACmd(&globalOpts{}) + names := map[string]bool{} + for _, sub := range ca.Commands() { + names[sub.Name()] = true + } + if !names["rotate-key"] { + t.Error("rotate-key not registered under ca") + } + if !names["submit-rotation"] { + t.Error("submit-rotation not registered under ca") + } +} + +func TestSubmitRotationRequiresChain(t *testing.T) { + if _, err := runCmd(t, "ca", "submit-rotation"); err == nil { + t.Error("submit-rotation without --chain = nil, want error") + } +} + func TestReadCertDER(t *testing.T) { der := selfSignedCA(t) dir := t.TempDir() diff --git a/go.mod b/go.mod index dce0bf3..4e9e0f7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/CryptOS-PKI/cryptos go 1.25.0 require ( - github.com/CryptOS-PKI/api v0.0.0-20260706152934-f50bf1d2bbf0 + github.com/CryptOS-PKI/api v0.0.0-20260706155657-ec67df1c59d0 github.com/google/go-tpm v0.9.8 github.com/google/go-tpm-tools v0.4.8 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 5c42c6a..68120fa 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/CryptOS-PKI/api v0.0.0-20260706152934-f50bf1d2bbf0 h1:br57Uvphp7B0PfKI0FP3Y0lgMxQTEhtP4LfvD3FoFEQ= -github.com/CryptOS-PKI/api v0.0.0-20260706152934-f50bf1d2bbf0/go.mod h1:/jCADIIDvMejmYdW7j/F3cx7XAz2eAqJ+FRovkzzR+8= +github.com/CryptOS-PKI/api v0.0.0-20260706155657-ec67df1c59d0 h1:5xFGdoPULcwF2i2r71w/Eo4alz55ASFn2u08U/Y245Y= +github.com/CryptOS-PKI/api v0.0.0-20260706155657-ec67df1c59d0/go.mod h1:/jCADIIDvMejmYdW7j/F3cx7XAz2eAqJ+FRovkzzR+8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= diff --git a/internal/grpc/server.go b/internal/grpc/server.go index 1ab002a..38ef607 100644 --- a/internal/grpc/server.go +++ b/internal/grpc/server.go @@ -129,6 +129,20 @@ type SubordinateEnroller interface { AcceptCertificate(ctx context.Context, chainDER [][]byte) (*cryptosv1.Identity, error) } +// Rekeyer drives CA key rotation on an established subordinate: BeginRotation +// generates a new CA key and stages its CSR (the node keeps serving with its +// current key), and CompleteRotation verifies the parent-signed chain for the +// new key and atomically swaps to it. It is wired on the mTLS and local servers +// of an established subordinate node; a Root and the maintenance servers leave +// it nil so the RPCs return Unimplemented there. A no-identity node or any +// other precondition failure surfaces as FailedPrecondition from the impl. +// Implemented in internal/init over the RootKeyBackend, the store, and the +// pinned parent trust. +type Rekeyer interface { + BeginRotation(ctx context.Context) (csrDER []byte, err error) + CompleteRotation(ctx context.Context, chainDER [][]byte) (*cryptosv1.Identity, error) +} + // Revoker revokes a certificate this node issued and lists the issued and // revoked inventories. It is wired on the mTLS and local servers of a running // node; the maintenance servers leave it nil so the revocation RPCs return @@ -205,6 +219,12 @@ type ServerConfig struct { // Unimplemented there. SubordinateEnroller SubordinateEnroller + // Rekeyer backs the CA key rotation RPCs (BeginKeyRotation, + // CompleteKeyRotation). It is wired on the mTLS and local servers of an + // established subordinate node; a Root and the maintenance servers leave it + // nil, so those RPCs return Unimplemented there. + Rekeyer Rekeyer + // Revoker backs the revocation RPCs (RevokeCertificate, ListIssued, // ListRevocations). It is wired on the mTLS and local servers of a running // node; the maintenance servers leave it nil, so those RPCs return @@ -451,6 +471,53 @@ func (s *Server) SubmitSubordinateCertificate(ctx context.Context, req *cryptosv return &cryptosv1.SubmitSubordinateCertificateResponse{Identity: id}, nil } +// BeginKeyRotation handles cryptos.v1.NodeService/BeginKeyRotation: an +// established subordinate generates a new CA key and stages its CSR so an +// operator can ferry it to the parent CA, while the node keeps serving with its +// current key. A Root and the maintenance servers leave Rekeyer nil, so the RPC +// returns Unimplemented there. On a subordinate node the caller is authorized +// against the bootstrap admin trust before any key material is generated. A +// no-identity node surfaces as FailedPrecondition from the rekeyer. This handler +// is thin: the key generation and staging live in the rekeyer. +func (s *Server) BeginKeyRotation(ctx context.Context, _ *cryptosv1.BeginKeyRotationRequest) (*cryptosv1.BeginKeyRotationResponse, error) { + if s.cfg.Rekeyer == nil { + return nil, status.Error(codes.Unimplemented, "key rotation is not available on this node") + } + if err := AuthorizeAdmin(ctx, s.cfg.Trust); err != nil { + return nil, err + } + csrDER, err := s.cfg.Rekeyer.BeginRotation(ctx) + if err != nil { + return nil, err + } + return &cryptosv1.BeginKeyRotationResponse{CsrDer: csrDER}, nil +} + +// CompleteKeyRotation handles cryptos.v1.NodeService/CompleteKeyRotation: an +// operator hands back the parent-signed chain for the new key and the node +// atomically swaps to it. A Root and the maintenance servers leave Rekeyer nil, +// so the RPC returns Unimplemented there. On a subordinate node the caller is +// authorized against the bootstrap admin trust before any state changes. This +// handler is thin: the security-critical chain verification (that the chain +// roots to the pinned parent anchor and that the leaf carries the staged +// rotation key) and the atomic swap live in the rekeyer. +func (s *Server) CompleteKeyRotation(ctx context.Context, req *cryptosv1.CompleteKeyRotationRequest) (*cryptosv1.CompleteKeyRotationResponse, error) { + if s.cfg.Rekeyer == nil { + return nil, status.Error(codes.Unimplemented, "key rotation is not available on this node") + } + if err := AuthorizeAdmin(ctx, s.cfg.Trust); err != nil { + return nil, err + } + if req == nil || len(req.GetChainDer()) == 0 { + return nil, status.Error(codes.InvalidArgument, "CompleteKeyRotation: chain_der is required") + } + id, err := s.cfg.Rekeyer.CompleteRotation(ctx, req.GetChainDer()) + if err != nil { + return nil, err + } + return &cryptosv1.CompleteKeyRotationResponse{Identity: id}, nil +} + // RevokeCertificate handles cryptos.v1.NodeService/RevokeCertificate: it marks // a certificate this node issued (identified by its hex serial) as revoked and // refreshes the published CRL. The maintenance servers leave Revoker nil, so the diff --git a/internal/grpc/server_test.go b/internal/grpc/server_test.go index 6ac90ac..6ed4871 100644 --- a/internal/grpc/server_test.go +++ b/internal/grpc/server_test.go @@ -681,6 +681,125 @@ func (f *fakeSubordinateEnroller) AcceptCertificate(_ context.Context, chainDER return f.identity, f.acceptErr } +type fakeRekeyer struct { + csr []byte + beginErr error + gotChain [][]byte + identity *cryptosv1.Identity + acceptErr error +} + +func (f *fakeRekeyer) BeginRotation(_ context.Context) ([]byte, error) { + return f.csr, f.beginErr +} + +func (f *fakeRekeyer) CompleteRotation(_ context.Context, chainDER [][]byte) (*cryptosv1.Identity, error) { + f.gotChain = chainDER + return f.identity, f.acceptErr +} + +// TestRekeyer_UnimplementedWhenNil verifies that with a nil Rekeyer (a Root and +// the maintenance servers) both key-rotation RPCs refuse with Unimplemented. +func TestRekeyer_UnimplementedWhenNil(t *testing.T) { + srv, err := New(ServerConfig{ + TLSConfig: newFixtures(t).serverConf, + Auditor: &mockAuditor{}, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, err := srv.BeginKeyRotation(context.Background(), &cryptosv1.BeginKeyRotationRequest{}); status.Code(err) != codes.Unimplemented { + t.Errorf("BeginKeyRotation code = %v, want Unimplemented", status.Code(err)) + } + if _, err := srv.CompleteKeyRotation(context.Background(), &cryptosv1.CompleteKeyRotationRequest{ChainDer: [][]byte{[]byte("x")}}); status.Code(err) != codes.Unimplemented { + t.Errorf("CompleteKeyRotation code = %v, want Unimplemented", status.Code(err)) + } +} + +// TestRekeyer_BeginAndComplete verifies that with a rekeyer wired and a nil +// Trust (local, no peer) BeginKeyRotation returns the new CSR and +// CompleteKeyRotation passes the chain through and returns the Identity. +func TestRekeyer_BeginAndComplete(t *testing.T) { + rk := &fakeRekeyer{ + csr: []byte("rotation-csr"), + identity: &cryptosv1.Identity{ChainPem: "REKEYED"}, + } + srv, err := New(ServerConfig{ + TLSConfig: newFixtures(t).serverConf, + Auditor: &mockAuditor{}, + Rekeyer: rk, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + beginResp, err := srv.BeginKeyRotation(context.Background(), &cryptosv1.BeginKeyRotationRequest{}) + if err != nil { + t.Fatalf("BeginKeyRotation: %v", err) + } + if string(beginResp.GetCsrDer()) != "rotation-csr" { + t.Fatalf("BeginKeyRotation csr = %q", beginResp.GetCsrDer()) + } + + compResp, err := srv.CompleteKeyRotation(context.Background(), &cryptosv1.CompleteKeyRotationRequest{ChainDer: [][]byte{[]byte("leaf"), []byte("parent")}}) + if err != nil { + t.Fatalf("CompleteKeyRotation: %v", err) + } + if len(rk.gotChain) != 2 { + t.Fatalf("rekeyer got chain len = %d, want 2", len(rk.gotChain)) + } + if compResp.GetIdentity().GetChainPem() != "REKEYED" { + t.Fatalf("CompleteKeyRotation identity = %v", compResp.GetIdentity()) + } +} + +// TestRekeyer_RejectEmptyChain verifies InvalidArgument for an empty chain_der +// even with the rekeyer wired, before the rekeyer is touched. +func TestRekeyer_RejectEmptyChain(t *testing.T) { + rk := &fakeRekeyer{} + srv, err := New(ServerConfig{ + TLSConfig: newFixtures(t).serverConf, + Auditor: &mockAuditor{}, + Rekeyer: rk, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, err := srv.CompleteKeyRotation(context.Background(), &cryptosv1.CompleteKeyRotationRequest{}); status.Code(err) != codes.InvalidArgument { + t.Errorf("CompleteKeyRotation(empty) code = %v, want InvalidArgument", status.Code(err)) + } + if rk.gotChain != nil { + t.Fatal("rekeyer was consulted despite an empty chain") + } +} + +// TestRekeyer_MismatchIsPermissionDenied verifies that a peer presenting a +// certificate that is not the pinned admin is denied on both rotation RPCs +// before the rekeyer is consulted. +func TestRekeyer_MismatchIsPermissionDenied(t *testing.T) { + rk := &fakeRekeyer{identity: &cryptosv1.Identity{}} + trust := trustForCert(t, authzTestCert(t)) + srv, err := New(ServerConfig{ + TLSConfig: newFixtures(t).serverConf, + Auditor: &mockAuditor{}, + Rekeyer: rk, + Trust: trust, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + ctx := authzMTLSContext(authzTestCert(t)) // a different cert than the trust + if _, err := srv.BeginKeyRotation(ctx, &cryptosv1.BeginKeyRotationRequest{}); status.Code(err) != codes.PermissionDenied { + t.Errorf("BeginKeyRotation code = %v, want PermissionDenied", status.Code(err)) + } + if _, err := srv.CompleteKeyRotation(ctx, &cryptosv1.CompleteKeyRotationRequest{ChainDer: [][]byte{[]byte("leaf")}}); status.Code(err) != codes.PermissionDenied { + t.Errorf("CompleteKeyRotation code = %v, want PermissionDenied", status.Code(err)) + } + if rk.gotChain != nil { + t.Fatal("rekeyer was consulted despite a denied caller") + } +} + // TestSubordinateEnroller_UnimplementedWhenNil verifies that with a nil // SubordinateEnroller (a Root and the maintenance servers) both P3b RPCs refuse // with Unimplemented. diff --git a/internal/init/rekey.go b/internal/init/rekey.go new file mode 100644 index 0000000..7a04414 --- /dev/null +++ b/internal/init/rekey.go @@ -0,0 +1,132 @@ +package init + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "errors" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" + "github.com/CryptOS-PKI/cryptos/internal/ceremony" + "github.com/CryptOS-PKI/cryptos/internal/config" + cgrpc "github.com/CryptOS-PKI/cryptos/internal/grpc" + "github.com/CryptOS-PKI/cryptos/internal/node" + "github.com/CryptOS-PKI/cryptos/internal/tpm" +) + +// rekeyStore is the slice of node.Store the rekeyer uses. It keeps the rekeyer +// testable against a real embedded-etcd store while narrowing the surface. +type rekeyStore interface { + HasIdentity(ctx context.Context) (bool, error) + StageRotation(ctx context.Context, csrDER, keyBlob, keyPublic []byte) error +} + +// rotationAccepter verifies a parent-signed re-key chain and swaps the node to +// it. It is the CompleteRotation half of the rekeyer, satisfied by +// *node.SubordinateEnroller.AcceptRotation. +type rotationAccepter interface { + AcceptRotation(ctx context.Context, chainDER [][]byte) (*cryptosv1.Identity, error) +} + +// nodeRekeyer implements grpc.Rekeyer for CA key rotation on an established +// subordinate. BeginRotation generates a new CA key through the same +// RootKeyBackend the ceremony provisions with, builds the subordinate CSR (same +// subject as first-boot enrollment), and stages it in the store's rotation +// slot; the node keeps serving with its current key. CompleteRotation delegates +// to the enroller's AcceptRotation, which owns the trust decision and the atomic +// swap. It is built only on an established subordinate; a Root is handled by +// leaving the Rekeyer nil at wiring time, so the RPC returns Unimplemented there. +type nodeRekeyer struct { + store rekeyStore + backend ceremony.RootKeyBackend + cfg *config.Config + accepter rotationAccepter +} + +var _ cgrpc.Rekeyer = (*nodeRekeyer)(nil) + +// newRekeyer builds a nodeRekeyer. All dependencies are required: the backend +// generates the new CA key, the store stages it, the config supplies the CA +// subject, and the accepter (the subordinate enroller) verifies and swaps on +// completion. +func newRekeyer(store rekeyStore, backend ceremony.RootKeyBackend, cfg *config.Config, accepter rotationAccepter) (*nodeRekeyer, error) { + switch { + case store == nil: + return nil, errors.New("init: newRekeyer: nil store") + case backend == nil: + return nil, errors.New("init: newRekeyer: nil backend") + case cfg == nil: + return nil, errors.New("init: newRekeyer: nil config") + case accepter == nil: + return nil, errors.New("init: newRekeyer: nil accepter") + } + return &nodeRekeyer{store: store, backend: backend, cfg: cfg, accepter: accepter}, nil +} + +// BeginRotation generates a new CA key and stages its CSR in the rotation slot, +// returning the CSR DER to ferry to the parent. It fails closed: an established +// identity is required (StageRotation is guarded to that state and returns +// node.ErrNoIdentity otherwise, which surfaces as FailedPrecondition). The +// current key is untouched; the node keeps serving until CompleteRotation swaps. +func (r *nodeRekeyer) BeginRotation(ctx context.Context) ([]byte, error) { + hasIdentity, err := r.store.HasIdentity(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "init: check identity: %v", err) + } + if !hasIdentity { + return nil, status.Error(codes.FailedPrecondition, + "init: key rotation requires an established identity") + } + + if err := r.backend.ProvisionSRK(); err != nil { + return nil, status.Errorf(codes.Internal, "init: provision SRK: %v", err) + } + created, err := r.backend.CreateKey(tpm.AlgorithmECDSAP384) + if err != nil { + return nil, status.Errorf(codes.Internal, "init: create rotation key: %v", err) + } + signer, err := r.backend.LoadKey(created.Private, created.Public) + if err != nil { + return nil, status.Errorf(codes.Internal, "init: load rotation key: %v", err) + } + defer func() { _ = signer.Close() }() + + csrDER, err := buildSubordinateCSR(signer, subordinateSubject(r.cfg)) + if err != nil { + return nil, status.Errorf(codes.Internal, "init: build rotation CSR: %v", err) + } + if err := r.store.StageRotation(ctx, csrDER, created.Private, created.Public); err != nil { + if errors.Is(err, node.ErrNoIdentity) { + return nil, status.Error(codes.FailedPrecondition, + "init: key rotation requires an established identity") + } + return nil, status.Errorf(codes.Internal, "init: stage rotation: %v", err) + } + return csrDER, nil +} + +// CompleteRotation verifies the parent-signed chain for the staged rotation key +// and swaps the node's identity to it. The trust decision and the atomic swap +// live in the accepter (the subordinate enroller); this method only delegates. +func (r *nodeRekeyer) CompleteRotation(ctx context.Context, chainDER [][]byte) (*cryptosv1.Identity, error) { + return r.accepter.AcceptRotation(ctx, chainDER) +} diff --git a/internal/init/rekey_test.go b/internal/init/rekey_test.go new file mode 100644 index 0000000..42888f3 --- /dev/null +++ b/internal/init/rekey_test.go @@ -0,0 +1,273 @@ +package init + +/* +Apache License 2.0 + +Copyright 2026 Shane + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "testing" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/CryptOS-PKI/cryptos/internal/bootstrap" + "github.com/CryptOS-PKI/cryptos/internal/ca" + "github.com/CryptOS-PKI/cryptos/internal/config" + "github.com/CryptOS-PKI/cryptos/internal/node" + "github.com/CryptOS-PKI/cryptos/internal/storage/etcd" + "github.com/CryptOS-PKI/cryptos/internal/tpm" +) + +// rekeyParent is an in-memory ECDSA-P384 parent CA used to sign both the +// first-boot subordinate leaf and the re-key leaf in the rekeyer tests. +type rekeyParent struct { + key *ecdsa.PrivateKey + cert *x509.Certificate + der []byte +} + +func newRekeyParent(t *testing.T) *rekeyParent { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + now := time.Now() + der, _, err := ca.SelfSignRoot(ca.RootParams{ + Signer: key, + Subject: pkix.Name{CommonName: "Rekey Parent Root CA"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(24 * time.Hour), + }) + if err != nil { + t.Fatalf("SelfSignRoot: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + return &rekeyParent{key: key, cert: cert, der: der} +} + +// signCSR signs a subordinate-CA certificate for the CSR's public key. +func (p *rekeyParent) signCSR(t *testing.T, csrDER []byte, cn string) []byte { + t.Helper() + csr, err := x509.ParseCertificateRequest(csrDER) + if err != nil { + t.Fatalf("ParseCertificateRequest: %v", err) + } + now := time.Now() + pathLen := 0 + der, _, err := ca.Sign(ca.Profile{ + Subject: pkix.Name{CommonName: cn}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(12 * time.Hour), + IsCA: true, + PathLen: &pathLen, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + }, csr.PublicKey, p.cert, p.key) + if err != nil { + t.Fatalf("ca.Sign: %v", err) + } + return der +} + +// newRekeyStore spins up an embedded etcd and returns a node.Store plus context. +func newRekeyStore(t *testing.T) (*node.Store, context.Context) { + t.Helper() + srv, err := etcd.Open(t.TempDir()) + if err != nil { + t.Fatalf("etcd.Open: %v", err) + } + t.Cleanup(func() { _ = srv.Close() }) + cli, err := srv.Client() + if err != nil { + t.Fatalf("etcd.Client: %v", err) + } + t.Cleanup(func() { _ = cli.Close() }) + s, err := node.New(cli) + if err != nil { + t.Fatalf("node.New: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + t.Cleanup(cancel) + return s, ctx +} + +func rekeyConfig() *config.Config { + c := &config.Config{} + c.PKI.RootSubject.CommonName = "Child Issuing CA" + c.PKI.RootSubject.Organization = "CryptOS" + return c +} + +func certPEM(der []byte) string { + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} + +func TestBeginRotationRequiresIdentity(t *testing.T) { + s, ctx := newRekeyStore(t) + parent := newRekeyParent(t) + trust, err := bootstrap.LoadTrust(certPEM(parent.der), "") + if err != nil { + t.Fatalf("LoadTrust: %v", err) + } + enr, err := node.NewSubordinateEnroller(s, trust) + if err != nil { + t.Fatalf("NewSubordinateEnroller: %v", err) + } + r, err := newRekeyer(s, NewSoftRootBackend(), rekeyConfig(), enr) + if err != nil { + t.Fatalf("newRekeyer: %v", err) + } + + // No identity yet: FailedPrecondition, and nothing staged. + if _, err := r.BeginRotation(ctx); status.Code(err) != codes.FailedPrecondition { + t.Fatalf("BeginRotation on no-identity node code = %v, want FailedPrecondition", status.Code(err)) + } + if _, ok, _ := s.RotationCSR(ctx); ok { + t.Error("BeginRotation staged a rotation on a no-identity node") + } +} + +func TestNewRekeyerValidation(t *testing.T) { + s, _ := newRekeyStore(t) + parent := newRekeyParent(t) + trust, _ := bootstrap.LoadTrust(certPEM(parent.der), "") + enr, _ := node.NewSubordinateEnroller(s, trust) + if _, err := newRekeyer(nil, NewSoftRootBackend(), rekeyConfig(), enr); err == nil { + t.Error("newRekeyer(nil store) = nil, want error") + } + if _, err := newRekeyer(s, nil, rekeyConfig(), enr); err == nil { + t.Error("newRekeyer(nil backend) = nil, want error") + } + if _, err := newRekeyer(s, NewSoftRootBackend(), nil, enr); err == nil { + t.Error("newRekeyer(nil config) = nil, want error") + } + if _, err := newRekeyer(s, NewSoftRootBackend(), rekeyConfig(), nil); err == nil { + t.Error("newRekeyer(nil accepter) = nil, want error") + } +} + +func TestRekeyerFullCycle(t *testing.T) { + s, ctx := newRekeyStore(t) + parent := newRekeyParent(t) + backend := NewSoftRootBackend() + trust, err := bootstrap.LoadTrust(certPEM(parent.der), "") + if err != nil { + t.Fatalf("LoadTrust: %v", err) + } + enr, err := node.NewSubordinateEnroller(s, trust) + if err != nil { + t.Fatalf("NewSubordinateEnroller: %v", err) + } + + // Establish a first identity through the real first-boot staging path. + if err := backend.ProvisionSRK(); err != nil { + t.Fatalf("ProvisionSRK: %v", err) + } + created, err := backend.CreateKey(tpm.AlgorithmECDSAP384) + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + signer, err := backend.LoadKey(created.Private, created.Public) + if err != nil { + t.Fatalf("LoadKey: %v", err) + } + firstCSR, err := buildSubordinateCSR(signer, subordinateSubject(rekeyConfig())) + _ = signer.Close() + if err != nil { + t.Fatalf("buildSubordinateCSR: %v", err) + } + if err := s.StageSubordinate(ctx, firstCSR, created.Private, created.Public); err != nil { + t.Fatalf("StageSubordinate: %v", err) + } + firstLeaf := parent.signCSR(t, firstCSR, "Child Issuing CA") + if _, err := enr.AcceptCertificate(ctx, [][]byte{firstLeaf, parent.der}); err != nil { + t.Fatalf("AcceptCertificate: %v", err) + } + oldPriv, _, _, _ := s.RootKeyBlobs(ctx) + + // Re-key: begin, parent signs the new CSR, complete. + r, err := newRekeyer(s, backend, rekeyConfig(), enr) + if err != nil { + t.Fatalf("newRekeyer: %v", err) + } + newCSR, err := r.BeginRotation(ctx) + if err != nil { + t.Fatalf("BeginRotation: %v", err) + } + if len(newCSR) == 0 { + t.Fatal("BeginRotation returned an empty CSR") + } + + // The active identity is still the original key while only staged. + stillOld, _, _, _ := s.RootKeyBlobs(ctx) + if string(stillOld) != string(oldPriv) { + t.Error("CA key changed on BeginRotation; the swap must wait for CompleteRotation") + } + + newLeaf := parent.signCSR(t, newCSR, "Child Issuing CA rekeyed") + id, err := r.CompleteRotation(ctx, [][]byte{newLeaf, parent.der}) + if err != nil { + t.Fatalf("CompleteRotation: %v", err) + } + if len(id.GetChainDer()) != 2 || string(id.GetChainDer()[0]) != string(newLeaf) { + t.Fatalf("identity after rotation = %v, want new leaf-first chain", id.GetChainDer()) + } + + // The CA key was swapped away from the original, and the swapped key is the + // one that signed the new leaf. + newPriv, _, ok, err := s.RootKeyBlobs(ctx) + if err != nil || !ok { + t.Fatalf("RootKeyBlobs after rotation ok=%v err=%v", ok, err) + } + if string(newPriv) == string(oldPriv) { + t.Error("CA key was not swapped after CompleteRotation") + } + assertSwappedKeySignedLeaf(t, newPriv, newLeaf) +} + +// assertSwappedKeySignedLeaf loads the swapped software CA key blob and checks +// its public key equals the new leaf certificate's public key, proving the node +// now holds the key it re-keyed to. +func assertSwappedKeySignedLeaf(t *testing.T, keyBlob, leafDER []byte) { + t.Helper() + priv, err := x509.ParseECPrivateKey(keyBlob) + if err != nil { + t.Fatalf("ParseECPrivateKey(swapped blob): %v", err) + } + leaf, err := x509.ParseCertificate(leafDER) + if err != nil { + t.Fatalf("ParseCertificate(new leaf): %v", err) + } + leafPub, ok := leaf.PublicKey.(*ecdsa.PublicKey) + if !ok { + t.Fatalf("leaf public key type = %T, want *ecdsa.PublicKey", leaf.PublicKey) + } + if !priv.PublicKey.Equal(leafPub) { + t.Error("swapped CA key public does not match the new leaf certificate") + } +} diff --git a/internal/init/run.go b/internal/init/run.go index 4623add..1b571bd 100644 --- a/internal/init/run.go +++ b/internal/init/run.go @@ -420,7 +420,15 @@ func Boot(ctx context.Context) (err error) { // the CA signers it is wired only into the management listeners below; the // maintenance/reprovision servers never see it, so the ceremony RPCs refuse // there with Unimplemented. + // + // The same enroller also backs CA key rotation on an established subordinate + // (BeginKeyRotation/CompleteKeyRotation): the rekeyer generates a new CA key + // through the RootKeyBackend, stages it in the store's rotation slot, and on + // completion delegates the trust decision + atomic swap to the enroller's + // AcceptRotation. Like the enroller it is built only on a subordinate; a Root + // leaves it nil so the rotation RPCs return Unimplemented there. var subEnroller cgrpc.SubordinateEnroller + var rekeyer cgrpc.Rekeyer parentTrust, err := cfg.ParentTrust() if err != nil { return fmt.Errorf("init: load parent trust anchor: %w", err) @@ -431,6 +439,11 @@ func Boot(ctx context.Context) (err error) { return fmt.Errorf("init: build subordinate enroller: %w", err) } subEnroller = enr + rk, err := newRekeyer(store, rootBackend, cfg, enr) + if err != nil { + return fmt.Errorf("init: build rekeyer: %w", err) + } + rekeyer = rk } // 11. Local UNIX-socket listener (root-only, no TLS). Only this server @@ -469,6 +482,7 @@ func Boot(ctx context.Context) (err error) { localCfg.SubordinateSigner = caSigner localCfg.LeafSigner = caSigner localCfg.SubordinateEnroller = subEnroller + localCfg.Rekeyer = rekeyer localCfg.Revoker = revoker localCfg.Exporter = escrow localCfg.Importer = escrow @@ -503,6 +517,7 @@ func Boot(ctx context.Context) (err error) { mtlsCfg.SubordinateSigner = caSigner mtlsCfg.LeafSigner = caSigner mtlsCfg.SubordinateEnroller = subEnroller + mtlsCfg.Rekeyer = rekeyer mtlsCfg.Revoker = revoker mtlsCfg.Exporter = escrow mtlsCfg.Importer = escrow diff --git a/internal/node/enroller.go b/internal/node/enroller.go index d57f287..6b1bca4 100644 --- a/internal/node/enroller.go +++ b/internal/node/enroller.go @@ -160,6 +160,83 @@ func (e *SubordinateEnroller) AcceptCertificate(ctx context.Context, chainDER [] return e.store.Identity(ctx) } +// AcceptRotation is the re-key sibling of AcceptCertificate: it verifies a +// parent-signed chain for the node's STAGED ROTATION key and, only if it is +// fully trustworthy, atomically swaps the node's identity to it. It fails closed +// on any doubt, reusing the same trust logic as AcceptCertificate but binding to +// the rotation key rather than the current identity key: +// +// - a rotation must be staged (a rotation CSR must exist), else +// FailedPrecondition; +// - chainDER is parsed leaf-first (chainDER[0] is this node's new certificate); +// - the leaf public key MUST equal the public key in the staged rotation CSR, +// so a chain minted for any other key (including the node's current key) is +// rejected; +// - the leaf MUST cryptographically verify to the pinned parent trust anchor +// via the same pools as AcceptCertificate. +// +// On success the swap is committed atomically (CommitRotation) and the node's +// Identity becomes the new leaf-first chain; the old key is discarded. +// Verification failures return InvalidArgument/FailedPrecondition and never +// touch the store. +func (e *SubordinateEnroller) AcceptRotation(ctx context.Context, chainDER [][]byte) (*cryptosv1.Identity, error) { + if len(chainDER) == 0 { + return nil, status.Error(codes.InvalidArgument, "node: certificate chain is empty") + } + + // A rotation must be staged; its CSR records the new key this chain must be + // bound to. + csrDER, ok, err := e.store.RotationCSR(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "node: read staged rotation CSR: %v", err) + } + if !ok { + return nil, status.Error(codes.FailedPrecondition, "node: no key rotation staged") + } + csr, err := x509.ParseCertificateRequest(csrDER) + if err != nil { + return nil, status.Errorf(codes.Internal, "node: parse staged rotation CSR: %v", err) + } + + certs := make([]*x509.Certificate, len(chainDER)) + for i, der := range chainDER { + c, err := x509.ParseCertificate(der) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "node: parse chain[%d]: %v", i, err) + } + certs[i] = c + } + leaf := certs[0] + + // The leaf must carry the node's staged ROTATION key (not the current key). + // Bind before path building so a chain minted for a different key is rejected + // outright. + if !samePublicKey(leaf.PublicKey, csr.PublicKey) { + return nil, status.Error(codes.FailedPrecondition, + "node: certificate public key does not match this node's staged rotation key") + } + + roots, intermediates, err := e.pools(certs) + if err != nil { + return nil, err + } + if _, err := leaf.Verify(x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediates, + }); err != nil { + return nil, status.Errorf(codes.FailedPrecondition, + "node: certificate chain does not verify to the pinned parent anchor: %v", err) + } + + if err := e.store.CommitRotation(ctx, chainDER); err != nil { + if errors.Is(err, ErrNoRotation) { + return nil, status.Error(codes.FailedPrecondition, "node: no key rotation staged") + } + return nil, status.Errorf(codes.Internal, "node: commit rotation: %v", err) + } + return e.store.Identity(ctx) +} + // pools builds the roots and intermediates x509 pools for verifying leaf // against the pinned parent anchor. When the anchor is a full certificate it is // the sole root and every non-leaf offered certificate is an intermediate. When diff --git a/internal/node/enroller_test.go b/internal/node/enroller_test.go index 1b281de..44f9612 100644 --- a/internal/node/enroller_test.go +++ b/internal/node/enroller_test.go @@ -112,6 +112,39 @@ func stageWithKey(t *testing.T, s *Store, ctx context.Context) *ecdsa.PrivateKey return key } +// establishIdentity stages and commits a first subordinate identity so the node +// reaches PhaseIdentityEstablished, the precondition for a re-key. +func establishIdentity(t *testing.T, s *Store, ctx context.Context, parent *parentFixture) { + t.Helper() + key := stageWithKey(t, s, ctx) + leafDER := parent.signSubCA(t, &key.PublicKey, "Child Issuing CA") + if err := s.CommitSubordinateCert(ctx, [][]byte{leafDER, parent.der}); err != nil { + t.Fatalf("CommitSubordinateCert: %v", err) + } +} + +// stageRotationWithKey generates a fresh P-384 CA key, builds a CSR for it, and +// stages it in the rotation slot. It returns the key so the test can have the +// parent sign the matching public key. +func stageRotationWithKey(t *testing.T, s *Store, ctx context.Context) *ecdsa.PrivateKey { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: "Child Issuing CA"}, + SignatureAlgorithm: x509.ECDSAWithSHA384, + }, key) + if err != nil { + t.Fatalf("CreateCertificateRequest: %v", err) + } + if err := s.StageRotation(ctx, csrDER, []byte("new-blob"), []byte("new-pub")); err != nil { + t.Fatalf("StageRotation: %v", err) + } + return key +} + func pemOf(der []byte) string { return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) } @@ -293,6 +326,153 @@ func TestAcceptCertificateFingerprintAnchorMissing(t *testing.T) { } } +func TestAcceptRotationGoodChainSwaps(t *testing.T) { + s, ctx := newTestStore(t) + parent := newParentFixture(t) + trust, err := bootstrap.LoadTrust(pemOf(parent.der), "") + if err != nil { + t.Fatalf("LoadTrust: %v", err) + } + e, err := NewSubordinateEnroller(s, trust) + if err != nil { + t.Fatalf("NewSubordinateEnroller: %v", err) + } + + establishIdentity(t, s, ctx, parent) + oldPriv, _, _, _ := s.RootKeyBlobs(ctx) + + newKey := stageRotationWithKey(t, s, ctx) + newLeafDER := parent.signSubCA(t, &newKey.PublicKey, "Child Issuing CA rekeyed") + chain := [][]byte{newLeafDER, parent.der} + + id, err := e.AcceptRotation(ctx, chain) + if err != nil { + t.Fatalf("AcceptRotation: %v", err) + } + if len(id.GetChainDer()) != 2 || string(id.GetChainDer()[0]) != string(newLeafDER) { + t.Fatalf("identity after rotation = %v, want new leaf-first chain", id.GetChainDer()) + } + + // The CA key was swapped away from the original key. + newPriv, _, ok, err := s.RootKeyBlobs(ctx) + if err != nil || !ok { + t.Fatalf("RootKeyBlobs after rotation ok=%v err=%v", ok, err) + } + if string(newPriv) == string(oldPriv) { + t.Error("CA key was not swapped after rotation") + } + if string(newPriv) != "new-blob" { + t.Errorf("CA key after rotation = %q, want the staged rotation key %q", newPriv, "new-blob") + } + + // The rotation slot was cleared, and the node stays established. + if _, ok, _ := s.RotationCSR(ctx); ok { + t.Error("rotation slot not cleared after AcceptRotation") + } + if phase, _ := s.Phase(ctx); phase != PhaseIdentityEstablished { + t.Errorf("phase after rotation = %q, want %q", phase, PhaseIdentityEstablished) + } +} + +func TestAcceptRotationRejectsWrongAnchor(t *testing.T) { + s, ctx := newTestStore(t) + pinned := newParentFixture(t) + rogue := newParentFixture(t) + trust, err := bootstrap.LoadTrust(pemOf(pinned.der), "") + if err != nil { + t.Fatalf("LoadTrust: %v", err) + } + e, err := NewSubordinateEnroller(s, trust) + if err != nil { + t.Fatalf("NewSubordinateEnroller: %v", err) + } + + establishIdentity(t, s, ctx, pinned) + newKey := stageRotationWithKey(t, s, ctx) + // The new leaf carries the staged rotation key but roots to the rogue CA. + leafDER := rogue.signSubCA(t, &newKey.PublicKey, "Child rekeyed") + chain := [][]byte{leafDER, rogue.der} + + if _, err := e.AcceptRotation(ctx, chain); status.Code(err) != codes.FailedPrecondition { + t.Fatalf("AcceptRotation(unrooted) code = %v, want FailedPrecondition", status.Code(err)) + } + // Nothing swapped: the rotation slot is intact and the original identity stands. + if _, ok, _ := s.RotationCSR(ctx); !ok { + t.Error("rotation slot cleared on a rejected rotation") + } + newPriv, _, _, _ := s.RootKeyBlobs(ctx) + if string(newPriv) == "new-blob" { + t.Error("CA key was swapped despite a rejected rotation") + } +} + +func TestAcceptRotationRejectsWrongLeafKey(t *testing.T) { + s, ctx := newTestStore(t) + parent := newParentFixture(t) + trust, err := bootstrap.LoadTrust(pemOf(parent.der), "") + if err != nil { + t.Fatalf("LoadTrust: %v", err) + } + e, err := NewSubordinateEnroller(s, trust) + if err != nil { + t.Fatalf("NewSubordinateEnroller: %v", err) + } + + establishIdentity(t, s, ctx, parent) + stageRotationWithKey(t, s, ctx) // stages the intended new key + // The parent signs a leaf for a DIFFERENT key than the staged rotation key. + // The chain roots to the pinned anchor, but the leaf key is wrong. + otherKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + leafDER := parent.signSubCA(t, &otherKey.PublicKey, "Child rekeyed") + chain := [][]byte{leafDER, parent.der} + + if _, err := e.AcceptRotation(ctx, chain); status.Code(err) != codes.FailedPrecondition { + t.Fatalf("AcceptRotation(wrong key) code = %v, want FailedPrecondition", status.Code(err)) + } + if _, ok, _ := s.RotationCSR(ctx); !ok { + t.Error("rotation slot cleared on a rejected rotation") + } +} + +func TestAcceptRotationNoStagedRotation(t *testing.T) { + s, ctx := newTestStore(t) + parent := newParentFixture(t) + trust, err := bootstrap.LoadTrust(pemOf(parent.der), "") + if err != nil { + t.Fatalf("LoadTrust: %v", err) + } + e, err := NewSubordinateEnroller(s, trust) + if err != nil { + t.Fatalf("NewSubordinateEnroller: %v", err) + } + establishIdentity(t, s, ctx, parent) + // No rotation staged: FailedPrecondition. + if _, err := e.AcceptRotation(ctx, [][]byte{[]byte("x")}); status.Code(err) != codes.FailedPrecondition { + t.Fatalf("AcceptRotation(no rotation) code = %v, want FailedPrecondition", status.Code(err)) + } +} + +func TestAcceptRotationEmptyChain(t *testing.T) { + s, ctx := newTestStore(t) + parent := newParentFixture(t) + trust, err := bootstrap.LoadTrust(pemOf(parent.der), "") + if err != nil { + t.Fatalf("LoadTrust: %v", err) + } + e, err := NewSubordinateEnroller(s, trust) + if err != nil { + t.Fatalf("NewSubordinateEnroller: %v", err) + } + establishIdentity(t, s, ctx, parent) + stageRotationWithKey(t, s, ctx) + if _, err := e.AcceptRotation(ctx, nil); status.Code(err) != codes.InvalidArgument { + t.Fatalf("AcceptRotation(nil) code = %v, want InvalidArgument", status.Code(err)) + } +} + func TestAcceptCertificateEmptyChain(t *testing.T) { s, ctx := newTestStore(t) parent := newParentFixture(t) diff --git a/internal/node/store.go b/internal/node/store.go index 0b186f3..943dda5 100644 --- a/internal/node/store.go +++ b/internal/node/store.go @@ -89,6 +89,10 @@ var ErrNotAwaitingCert = errors.New("node: node is not awaiting a certificate") // has been staged. var ErrNoSubordinateCSR = errors.New("node: no subordinate CSR staged") +// ErrNoRotation is returned by CommitRotation when no re-key has been +// staged, so the guarded transaction did not apply. +var ErrNoRotation = errors.New("node: no key rotation staged") + // Store is the typed accessor over the embedded etcd datastore. It is // the only place outside internal/storage/etcd that reads or writes // CryptOS state keys; callers go through these methods rather than @@ -482,6 +486,134 @@ func (s *Store) CommitSubordinateCert(ctx context.Context, chainDER [][]byte) er return nil } +// StageRotation persists a re-key node's pending new CSR and CA key in the +// rotation slot (separate from the active identity) for an established +// subordinate. It is the established-node sibling of StageSubordinate, guarded +// to the OPPOSITE state: it applies only when an identity already exists (a +// committed chain), so a Root or a not-yet-established subordinate cannot begin +// a re-key. Overwrite of an existing rotation slot is allowed so re-begin +// regenerates the new key. The node keeps serving with its current key while +// the rotation slot holds the new one; CommitRotation performs the atomic swap. +// Returns ErrNoIdentity when no identity has been committed. +func (s *Store) StageRotation(ctx context.Context, csrDER, keyBlob, keyPublic []byte) error { + switch { + case len(csrDER) == 0: + return errors.New("node: StageRotation: csrDER is empty") + case len(keyBlob) == 0: + return errors.New("node: StageRotation: keyBlob is empty") + case len(keyPublic) == 0: + return errors.New("node: StageRotation: keyPublic is empty") + } + + resp, err := s.cli.Txn(ctx). + If(clientv3.Compare(clientv3.CreateRevision(etcd.KeyIdentityChain), "!=", 0)). + Then( + clientv3.OpPut(etcd.KeyRotationCSR, string(csrDER)), + clientv3.OpPut(etcd.KeyRotationKeyBlob, string(keyBlob)), + clientv3.OpPut(etcd.KeyRotationKeyPublic, string(keyPublic)), + ). + Commit() + if err != nil { + return fmt.Errorf("node: StageRotation: txn: %w", err) + } + if !resp.Succeeded { + return ErrNoIdentity + } + return nil +} + +// RotationCSR returns the staged re-key CSR DER. ok is false when no rotation +// has been staged. +func (s *Store) RotationCSR(ctx context.Context) (csrDER []byte, ok bool, err error) { + kv, ok, err := s.getKV(ctx, etcd.KeyRotationCSR) + if err != nil { + return nil, false, err + } + if !ok { + return nil, false, nil + } + return append([]byte(nil), kv.Value...), true, nil +} + +// RotationKeyBlobs returns the staged re-key CA private + public key blobs, for +// loading the new signer once the new chain is committed. ok is false when no +// rotation has been staged. +func (s *Store) RotationKeyBlobs(ctx context.Context) (private, public []byte, ok bool, err error) { + privKV, okPriv, err := s.getKV(ctx, etcd.KeyRotationKeyBlob) + if err != nil { + return nil, nil, false, err + } + pubKV, okPub, err := s.getKV(ctx, etcd.KeyRotationKeyPublic) + if err != nil { + return nil, nil, false, err + } + if !okPriv || !okPub { + return nil, nil, false, nil + } + return append([]byte(nil), privKV.Value...), append([]byte(nil), pubKV.Value...), true, nil +} + +// CommitRotation atomically swaps an established subordinate to its re-keyed +// identity. The chain is leaf-first (chainDER[0] is this node's new +// certificate). In a single guarded transaction it promotes the staged rotation +// key into the canonical CA-key location (KeyRootKeyBlob/KeyRootKeyPublic), sets +// KeyIdentityChain to the new chain, mirrors the new leaf to KeyRootCert so +// existing readers keep working, and deletes the three rotation-slot keys. The +// transaction is guarded on the rotation key-blob existing so a commit with no +// staged rotation does not apply and returns ErrNoRotation, which makes commit +// re-entry after a crash idempotent. The old key is discarded; certificates it +// already signed keep validating until they expire. Chain verification (that +// the chain roots to the pinned parent anchor and that the leaf carries the +// staged rotation key) is the caller's responsibility (the enroller verifies +// before calling this). +func (s *Store) CommitRotation(ctx context.Context, chainDER [][]byte) error { + if len(chainDER) == 0 { + return errors.New("node: CommitRotation: chain is empty") + } + for i, der := range chainDER { + if len(der) == 0 { + return fmt.Errorf("node: CommitRotation: chain[%d] is empty", i) + } + } + chainJSON, err := json.Marshal(chainDER) + if err != nil { + return fmt.Errorf("node: CommitRotation: marshal chain: %w", err) + } + leafDER := chainDER[0] + + // Read the staged rotation key to promote. The guarded transaction below + // re-checks the rotation key-blob exists atomically; this pre-read only lets + // the promotion carry the key value (a node with a staged rotation always has + // all three slot keys: StageRotation writes them together). + keyPriv, keyPub, ok, err := s.RotationKeyBlobs(ctx) + if err != nil { + return fmt.Errorf("node: CommitRotation: read staged rotation key: %w", err) + } + if !ok { + return ErrNoRotation + } + + resp, err := s.cli.Txn(ctx). + If(clientv3.Compare(clientv3.CreateRevision(etcd.KeyRotationKeyBlob), "!=", 0)). + Then( + clientv3.OpPut(etcd.KeyRootKeyBlob, string(keyPriv)), + clientv3.OpPut(etcd.KeyRootKeyPublic, string(keyPub)), + clientv3.OpPut(etcd.KeyIdentityChain, string(chainJSON)), + clientv3.OpPut(etcd.KeyRootCert, string(leafDER)), + clientv3.OpDelete(etcd.KeyRotationCSR), + clientv3.OpDelete(etcd.KeyRotationKeyBlob), + clientv3.OpDelete(etcd.KeyRotationKeyPublic), + ). + Commit() + if err != nil { + return fmt.Errorf("node: CommitRotation: txn: %w", err) + } + if !resp.Succeeded { + return ErrNoRotation + } + return nil +} + // CommitRestoredIdentity atomically restores a CA identity from an operator // backup onto a node that has none. It is the storage counterpart of a CA key // import (the recovery sibling of the first-boot ceremony): it writes the diff --git a/internal/node/store_test.go b/internal/node/store_test.go index 586ee9f..5740f41 100644 --- a/internal/node/store_test.go +++ b/internal/node/store_test.go @@ -218,6 +218,160 @@ func TestCommitSubordinateCertValidation(t *testing.T) { } } +func TestStageRotationRequiresIdentity(t *testing.T) { + s, ctx := newTestStore(t) + + // No identity yet: StageRotation is refused (the opposite guard of + // StageSubordinate). + if err := s.StageRotation(ctx, []byte("csr"), []byte("blob"), []byte("pub")); !errors.Is(err, ErrNoIdentity) { + t.Fatalf("StageRotation on no-identity node = %v, want ErrNoIdentity", err) + } + if _, ok, err := s.RotationCSR(ctx); err != nil || ok { + t.Fatalf("RotationCSR after refused stage ok=%v err=%v, want ok=false", ok, err) + } + + // Establish an identity (subordinate commit), then StageRotation applies. + if err := s.StageSubordinate(ctx, []byte("csr0"), []byte("blob0"), []byte("pub0")); err != nil { + t.Fatalf("StageSubordinate: %v", err) + } + if err := s.CommitSubordinateCert(ctx, [][]byte{[]byte("leaf0"), []byte("parent0")}); err != nil { + t.Fatalf("CommitSubordinateCert: %v", err) + } + + if err := s.StageRotation(ctx, []byte("csr1"), []byte("blob1"), []byte("pub1")); err != nil { + t.Fatalf("StageRotation on established node: %v", err) + } + gotCSR, ok, err := s.RotationCSR(ctx) + if err != nil || !ok { + t.Fatalf("RotationCSR ok=%v err=%v", ok, err) + } + if string(gotCSR) != "csr1" { + t.Errorf("RotationCSR = %q, want %q", gotCSR, "csr1") + } + gotPriv, gotPub, ok, err := s.RotationKeyBlobs(ctx) + if err != nil || !ok { + t.Fatalf("RotationKeyBlobs ok=%v err=%v", ok, err) + } + if string(gotPriv) != "blob1" || string(gotPub) != "pub1" { + t.Errorf("RotationKeyBlobs = (%q,%q), want (%q,%q)", gotPriv, gotPub, "blob1", "pub1") + } + + // Re-begin overwrites the rotation slot. + if err := s.StageRotation(ctx, []byte("csr2"), []byte("blob2"), []byte("pub2")); err != nil { + t.Fatalf("StageRotation re-begin: %v", err) + } + gotCSR, _, _ = s.RotationCSR(ctx) + if string(gotCSR) != "csr2" { + t.Errorf("RotationCSR after re-begin = %q, want %q", gotCSR, "csr2") + } + + // The active identity is untouched while the rotation is only staged. + id, err := s.Identity(ctx) + if err != nil { + t.Fatalf("Identity: %v", err) + } + if string(id.ChainDer[0]) != "leaf0" { + t.Errorf("active identity leaf = %q, want unchanged %q", id.ChainDer[0], "leaf0") + } +} + +func TestStageRotationValidation(t *testing.T) { + s, ctx := newTestStore(t) + if err := s.StageRotation(ctx, nil, []byte("b"), []byte("p")); err == nil { + t.Error("StageRotation with empty CSR = nil, want error") + } + if err := s.StageRotation(ctx, []byte("c"), nil, []byte("p")); err == nil { + t.Error("StageRotation with empty keyBlob = nil, want error") + } + if err := s.StageRotation(ctx, []byte("c"), []byte("b"), nil); err == nil { + t.Error("StageRotation with empty keyPublic = nil, want error") + } +} + +func TestCommitRotationSwapsKeyAndIdentity(t *testing.T) { + s, ctx := newTestStore(t) + + // Establish an identity with an original key. + if err := s.StageSubordinate(ctx, []byte("csr0"), []byte("old-blob"), []byte("old-pub")); err != nil { + t.Fatalf("StageSubordinate: %v", err) + } + if err := s.CommitSubordinateCert(ctx, [][]byte{[]byte("leaf0"), []byte("parent0")}); err != nil { + t.Fatalf("CommitSubordinateCert: %v", err) + } + + // Stage a re-key with a new key. + if err := s.StageRotation(ctx, []byte("csr1"), []byte("new-blob"), []byte("new-pub")); err != nil { + t.Fatalf("StageRotation: %v", err) + } + + newChain := [][]byte{[]byte("leaf1"), []byte("parent1")} + if err := s.CommitRotation(ctx, newChain); err != nil { + t.Fatalf("CommitRotation: %v", err) + } + + // The CA key was swapped to the new rotation key. + priv, pub, ok, err := s.RootKeyBlobs(ctx) + if err != nil || !ok { + t.Fatalf("RootKeyBlobs after rotation ok=%v err=%v", ok, err) + } + if string(priv) != "new-blob" || string(pub) != "new-pub" { + t.Errorf("CA key after rotation = (%q,%q), want the new (%q,%q)", priv, pub, "new-blob", "new-pub") + } + + // The identity was swapped to the new chain. + id, err := s.Identity(ctx) + if err != nil { + t.Fatalf("Identity: %v", err) + } + if len(id.ChainDer) != 2 || string(id.ChainDer[0]) != "leaf1" { + t.Fatalf("identity chain = %v, want new leaf %q", id.ChainDer, "leaf1") + } + + // The rotation slot was cleared. + if _, ok, _ := s.RotationCSR(ctx); ok { + t.Error("RotationCSR still present after commit; slot not cleared") + } + if _, _, ok, _ := s.RotationKeyBlobs(ctx); ok { + t.Error("RotationKeyBlobs still present after commit; slot not cleared") + } + + // The phase remains established throughout (no awaiting-cert dip). + if phase, _ := s.Phase(ctx); phase != PhaseIdentityEstablished { + t.Errorf("phase after rotation = %q, want %q", phase, PhaseIdentityEstablished) + } +} + +func TestCommitRotationNoStagedSlot(t *testing.T) { + s, ctx := newTestStore(t) + + // Establish an identity but do not stage a rotation. + if err := s.StageSubordinate(ctx, []byte("csr0"), []byte("old-blob"), []byte("old-pub")); err != nil { + t.Fatalf("StageSubordinate: %v", err) + } + if err := s.CommitSubordinateCert(ctx, [][]byte{[]byte("leaf0"), []byte("parent0")}); err != nil { + t.Fatalf("CommitSubordinateCert: %v", err) + } + + if err := s.CommitRotation(ctx, [][]byte{[]byte("leaf1")}); !errors.Is(err, ErrNoRotation) { + t.Fatalf("CommitRotation with no staged slot = %v, want ErrNoRotation", err) + } + // The active identity is untouched. + id, _ := s.Identity(ctx) + if string(id.ChainDer[0]) != "leaf0" { + t.Error("CommitRotation without a slot mutated the active identity") + } +} + +func TestCommitRotationValidation(t *testing.T) { + s, ctx := newTestStore(t) + if err := s.CommitRotation(ctx, nil); err == nil { + t.Error("CommitRotation(nil) = nil, want error") + } + if err := s.CommitRotation(ctx, [][]byte{nil}); err == nil { + t.Error("CommitRotation with empty cert = nil, want error") + } +} + func TestCommitRestoredIdentity(t *testing.T) { s, ctx := newTestStore(t) diff --git a/internal/storage/etcd/etcd.go b/internal/storage/etcd/etcd.go index 7a35c90..4b1ad4b 100644 --- a/internal/storage/etcd/etcd.go +++ b/internal/storage/etcd/etcd.go @@ -62,6 +62,20 @@ const ( // also mirrored to KeyRootCert so existing readers keep working. KeyIdentityChain = "/cryptos/identity/chain" + // KeyRotationCSR holds the DER-encoded PKCS#10 CSR for a re-key: the + // new CA key an established subordinate stages while it keeps serving + // with its current key, awaiting a parent-signed chain for the new key. + KeyRotationCSR = "/cryptos/identity/rotation/csr" + + // KeyRotationKeyBlob holds the pending re-key CA private key blob + // (TPM-wrapped or software-sealed), staged in the rotation slot until + // the new signed chain is committed. + KeyRotationKeyBlob = "/cryptos/identity/rotation/key-blob" + + // KeyRotationKeyPublic holds the public portion of the pending re-key + // CA key. + KeyRotationKeyPublic = "/cryptos/identity/rotation/key-public" + // KeyStatePhase holds the lifecycle phase: formatting / unsealed / // no-identity / ceremony-in-progress / identity-established. KeyStatePhase = "/cryptos/state/phase"