// cert.go implements the `orca cert` subcommand family. // // Subcommands: // // orca cert ca-init — bootstrap a local CA in ~/.orca/ // orca cert gen — generate a server CSR + sign it with the local CA // orca cert show — print the active server cert (redacted; REQ-035) // orca cert renew — re-issue and rotate the server cert // orca cert fingerprint — print the SHA-256 of ca.crt or server.crt // // All subcommands refuse to operate if the on-disk CA / cert file modes // do not match REQ-033 (0600 for keys, 0644 for certs). package cli import ( "encoding/pem" "fmt" "log/slog" "os" "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/certpaths" "git.cloudinit.dev/coreci/orca/internal/security" ) // CADir returns the directory the local CA lives in. Re-exported for // backward compatibility with callers that imported this from the cli // package directly. func CADir() string { return certpaths.Dir() } // CACertPath returns the path to ca.crt. func CACertPath() string { return certpaths.CACertPath() } // CAKeyPath returns the path to ca.key. func CAKeyPath() string { return certpaths.CAKeyPath() } // ServerCertPath returns the path to server.crt. func ServerCertPath() string { return certpaths.ServerCertPath() } // ServerKeyPath returns the path to server.key. func ServerKeyPath() string { return certpaths.ServerKeyPath() } // NewCommand builds the `orca cert` command tree. func NewCommand(log *slog.Logger) *cobra.Command { if log == nil { log = slog.Default() } certCmd := &cobra.Command{ Use: "cert", Short: "Manage orca certificates (CA, server, rotation)", Long: "Bootstrap a local CA, generate server certs, and rotate them.", } certCmd.AddCommand(newCAInitCmd(log)) certCmd.AddCommand(newGenCmd(log)) certCmd.AddCommand(newShowCmd(log)) certCmd.AddCommand(newRenewCmd(log)) certCmd.AddCommand(newFingerprintCmd(log)) return certCmd } func newCAInitCmd(log *slog.Logger) *cobra.Command { var cn string cmd := &cobra.Command{ Use: "ca-init", Short: "Initialize a local orca CA (ca.crt + ca.key) under ~/.orca", Long: "Generates a new RSA CA cert and writes it to ~/.orca/ca.crt (0644) and ~/.orca/ca.key (0600) per REQ-033.", RunE: func(cmd *cobra.Command, args []string) error { dir := CADir() if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("mkdir %s: %w", dir, err) } ca, err := security.CAInit(dir, cn) if err != nil { return fmt.Errorf("ca-init: %w", err) } fp := ca.Fingerprint() if _, err := fmt.Fprintf(cmd.OutOrStdout(), "✓ CA initialized at %s\n fingerprint (sha256): %s\n not_after: %s\n", dir, fp, ca.NotAfter.UTC().Format("2006-01-02")); err != nil { return err } log.Info("cert.ca_init", slog.String("event", "cert.ca_init"), slog.String("dir", dir), slog.String("cert_fp", fp), ) return nil }, } cmd.Flags().StringVar(&cn, "cn", "orca-local-ca", "CA common name") return cmd } func newGenCmd(log *slog.Logger) *cobra.Command { var cn string var sans []string cmd := &cobra.Command{ Use: "gen", Short: "Generate a server cert (CSR + sign) under ~/.orca", Long: "Builds a CSR with the requested SANs, signs it with the local CA, and writes server.crt + server.key.", RunE: func(cmd *cobra.Command, args []string) error { dir := CADir() if cn == "" { cn = "orca-server" } ca, err := security.LoadCA(dir) if err != nil { return fmt.Errorf("load CA (run `orca cert ca-init` first): %w", err) } keyPEM, csrPEM, err := security.GenerateCSR(cn, sans) if err != nil { return fmt.Errorf("generate CSR: %w", err) } certPEM, err := ca.SignCSR(csrPEM) if err != nil { return fmt.Errorf("sign CSR: %w", err) } certPath := ServerCertPath() keyPath := ServerKeyPath() if err := security.WriteCert(certPath, certPEM); err != nil { return fmt.Errorf("write cert: %w", err) } if err := security.WriteKey(keyPath, keyPEM); err != nil { return fmt.Errorf("write key: %w", err) } fp := security.FingerprintOf(parseFirstCertDER(certPEM)) if _, err := fmt.Fprintf(cmd.OutOrStdout(), "✓ Server cert generated\n cert: %s\n key: %s\n fingerprint (sha256): %s\n", certPath, keyPath, fp); err != nil { return err } log.Info("cert.issued", slog.String("event", "cert.issued"), slog.String("cn", cn), slog.String("cert_fp", fp), ) return nil }, } cmd.Flags().StringVar(&cn, "cn", "orca-server", "server cert common name") cmd.Flags().StringSliceVar(&sans, "san", []string{"localhost", "127.0.0.1"}, "SAN entries (DNS or IP) — at least one required (REQ-036)") return cmd } func newShowCmd(log *slog.Logger) *cobra.Command { cmd := &cobra.Command{ Use: "show", Short: "Print the server cert (private keys redacted; REQ-035)", RunE: func(cmd *cobra.Command, args []string) error { pem, err := os.ReadFile(ServerCertPath()) if err != nil { return fmt.Errorf("read server cert: %w", err) } // Per REQ-035, strip private key material before display. out := security.Redact(pem) if _, err := cmd.OutOrStdout().Write(out); err != nil { return err } log.Debug("cert.show", slog.String("event", "cert.show")) return nil }, } return cmd } func newRenewCmd(log *slog.Logger) *cobra.Command { var cn string var sans []string cmd := &cobra.Command{ Use: "renew", Short: "Rotate the server cert (hot-swapped by the daemon; REQ-034)", Long: "Re-runs `cert gen` and overwrites server.crt / server.key in place. The daemon's GetCertificate callback picks up the new cert on the next handshake — no restart required.", RunE: func(cmd *cobra.Command, args []string) error { dir := CADir() if cn == "" { cn = "orca-server" } ca, err := security.LoadCA(dir) if err != nil { return fmt.Errorf("load CA: %w", err) } keyPEM, csrPEM, err := security.GenerateCSR(cn, sans) if err != nil { return fmt.Errorf("generate CSR: %w", err) } certPEM, err := ca.SignCSR(csrPEM) if err != nil { return fmt.Errorf("sign CSR: %w", err) } if err := security.WriteCert(ServerCertPath(), certPEM); err != nil { return fmt.Errorf("write cert: %w", err) } if err := security.WriteKey(ServerKeyPath(), keyPEM); err != nil { return fmt.Errorf("write key: %w", err) } fp := security.FingerprintOf(parseFirstCertDER(certPEM)) if _, err := fmt.Fprintln(cmd.OutOrStdout(), "✓ Server cert rotated"); err != nil { return err } log.Info("cert.renewed", slog.String("event", "cert.renewed"), slog.String("cert_fp", fp), ) return nil }, } cmd.Flags().StringVar(&cn, "cn", "orca-server", "server cert common name") cmd.Flags().StringSliceVar(&sans, "san", []string{"localhost", "127.0.0.1"}, "SAN entries (DNS or IP)") return cmd } func newFingerprintCmd(log *slog.Logger) *cobra.Command { var which string cmd := &cobra.Command{ Use: "fingerprint", Short: "Print the SHA-256 fingerprint of ca.crt or server.crt", RunE: func(cmd *cobra.Command, args []string) error { var path string switch which { case "ca", "": path = CACertPath() case "server": path = ServerCertPath() default: return fmt.Errorf("--which must be 'ca' or 'server'") } fp, err := security.Fingerprint(path) if err != nil { return err } if _, err := fmt.Fprintln(cmd.OutOrStdout(), fp); err != nil { return err } log.Debug("cert.fingerprint", slog.String("event", "cert.fingerprint"), slog.String("path", path), slog.String("cert_fp", fp), ) return nil }, } cmd.Flags().StringVar(&which, "which", "ca", "which cert: 'ca' or 'server'") return cmd } // parseFirstCertDER decodes the first CERTIFICATE PEM block in pemBytes // and returns the DER bytes. Used by the cert cli for fingerprint calc // after a fresh issuance. func parseFirstCertDER(pemBytes []byte) []byte { block, _ := pem.Decode(pemBytes) if block == nil { return nil } return block.Bytes }