From 2216bb97c409f1ef06cff7a0267cc2ac2c02d8f7 Mon Sep 17 00:00:00 2001 From: Bugs5382 Date: Mon, 6 Jul 2026 12:08:49 -0400 Subject: [PATCH 1/5] feat(grpc): add key-rotation RPC handlers and Rekeyer Re-pin the api module to the revision that adds the BeginKeyRotation and CompleteKeyRotation RPCs, and implement their handlers on the NodeService server. A new Rekeyer interface (BeginRotation/CompleteRotation) backs them, wired through ServerConfig.Rekeyer. Both handlers are management-only: a nil Rekeyer (a root and the maintenance servers) refuses with Unimplemented, and the caller is authorized against the bootstrap admin trust before any key material is touched. CompleteKeyRotation validates a non-empty chain before consulting the rekeyer; the security-critical verification and the atomic swap live in the impl. --- go.mod | 2 +- go.sum | 4 +- internal/grpc/server.go | 67 ++++++++++++++++++++ internal/grpc/server_test.go | 119 +++++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 3 deletions(-) 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. From de4666aae03f6241fe9eea4096863565970aefca Mon Sep 17 00:00:00 2001 From: Bugs5382 Date: Mon, 6 Jul 2026 12:08:59 -0400 Subject: [PATCH 2/5] feat(node): add re-key rotation slot to the store Add a rotation slot under /cryptos/identity/rotation/{csr,key-blob,key-public}, the established-node sibling of the subordinate staging slot, plus the store methods that drive a CA key rotation: - StageRotation stages the new CSR + key, guarded to the OPPOSITE state of StageSubordinate: it applies only when an identity already exists, so a root or a not-yet-established subordinate cannot begin a re-key. Overwrite is allowed so re-begin regenerates the key; a no-identity node returns ErrNoIdentity. - RotationCSR and RotationKeyBlobs read the staged slot. - CommitRotation atomically promotes the staged rotation key to the canonical CA-key location, swaps KeyIdentityChain to the new chain, mirrors the new leaf to KeyRootCert, and clears the slot, guarded on the slot existing (ErrNoRotation otherwise). The node stays established throughout; the old key is discarded. --- internal/node/store.go | 132 +++++++++++++++++++++++++++++ internal/node/store_test.go | 154 ++++++++++++++++++++++++++++++++++ internal/storage/etcd/etcd.go | 14 ++++ 3 files changed, 300 insertions(+) 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" From 25fe3b953c2b0423463b681cb6d972a8d07169d7 Mon Sep 17 00:00:00 2001 From: Bugs5382 Date: Mon, 6 Jul 2026 12:09:07 -0400 Subject: [PATCH 3/5] feat(node): verify and swap on AcceptRotation in the enroller Add SubordinateEnroller.AcceptRotation, the re-key sibling of AcceptCertificate. It reuses the same trust logic (chain must root to the pinned parent anchor via the existing pools + leaf.Verify) but binds the leaf public key to the STAGED ROTATION key rather than the current identity key, then commits the atomic swap via CommitRotation. It fails closed on any doubt: no rotation staged, a wrong anchor, or a leaf carrying any other key are all rejected without touching the store. --- internal/node/enroller.go | 77 ++++++++++++++ internal/node/enroller_test.go | 180 +++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) 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) From 91b888f327cbb7b92667ab7c4ef0b5952b3e0005 Mon Sep 17 00:00:00 2001 From: Bugs5382 Date: Mon, 6 Jul 2026 12:09:16 -0400 Subject: [PATCH 4/5] feat(init): build the rekeyer and wire it on subordinate servers Add nodeRekeyer, the grpc.Rekeyer impl. 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 while the node keeps serving with its current key; it fails closed with FailedPrecondition on a node with no established identity. CompleteRotation delegates the trust decision and the atomic swap to the enroller's AcceptRotation. Wire it in Boot alongside the subordinate enroller, on the local and mTLS servers only: it is built only when a pinned parent anchor exists (a subordinate), so a root and the maintenance servers leave it nil and the rotation RPCs return Unimplemented there. --- internal/init/rekey.go | 132 +++++++++++++++++ internal/init/rekey_test.go | 273 ++++++++++++++++++++++++++++++++++++ internal/init/run.go | 15 ++ 3 files changed, 420 insertions(+) create mode 100644 internal/init/rekey.go create mode 100644 internal/init/rekey_test.go 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 From c8f3eecdcbce94e9efa95db086309498c4441c26 Mon Sep 17 00:00:00 2001 From: Bugs5382 Date: Mon, 6 Jul 2026 12:09:23 -0400 Subject: [PATCH 5/5] feat(cryptosctl): add ca rotate-key and ca submit-rotation Add the operator ferry verbs for CA key rotation, mirroring the subordinate enrollment verbs: rotate-key calls BeginKeyRotation and prints the new-key CSR as PEM to hand to the parent's sign-subordinate; submit-rotation reads the parent-signed chain and calls CompleteKeyRotation, printing the swapped identity. Both register under the ca command. --- cmd/cryptosctl/rotate.go | 97 ++++++++++++++++++++++++++++++ cmd/cryptosctl/subordinate.go | 2 + cmd/cryptosctl/subordinate_test.go | 20 ++++++ 3 files changed, 119 insertions(+) create mode 100644 cmd/cryptosctl/rotate.go 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()