Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions cmd/cryptosctl/rotate.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions cmd/cryptosctl/subordinate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
20 changes: 20 additions & 0 deletions cmd/cryptosctl/subordinate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
67 changes: 67 additions & 0 deletions internal/grpc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions internal/grpc/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading