Files
orca/internal/cli/cert.go
T
ciagent 31ccb52114 feat(P08): mTLS daemon + transport + cert CLI + doctor
Wave B/C/D of P01 mTLS implementation.

- internal/audit/audit.go — thin wrapper around engine.Audit for
  cert/handshake events (Action* and Result* constants; REQ-038).
- internal/certpaths/ — extracted path constants out of cli to break
  the cli<->doctor import cycle; cli re-exports the helpers for
  backward compat.
- internal/security/ca.go — public WriteCert/WriteKey helpers (0600
  for keys, 0644 for certs; REQ-033); used by the cert CLI and
  integration test.
- internal/daemon/tls.go — mTLS server with GetCertificate hot-swap
  callback. Plaintext HTTP remains the default for v0.1 compat;
  StartMTLS() flips the server into mTLS mode.
- internal/daemon/server.go — adds mtls *MTLSState field; MTLSActive()
  getter for health endpoints.
- internal/transport/mtls.go — mTLS client with VerifyPeerCertificate
  for pinned peer identity; DialContext for raw TLS.
- internal/transport/handshake_log.go — structured slog helpers for
  handshake ok/fail (REQ-038 fields: event, result, peer, cert_fp).
- internal/cli/cert.go — orca cert {ca-init,gen,show,renew,fingerprint}
  subcommands; file mode enforcement at every entry; redacted cert
  show (REQ-035).
- internal/cli/doctor.go — orca doctor {cert,network,db} subcommands
  (REQ-032); --json output supported.
- internal/cli/node.go — adds --ca-fingerprint to orca node join
  (REQ-026); fails fast on mismatch.
- internal/doctor/doctor.go — 6 checks: cert.ca, cert.server,
  cert.expiry, cert.fingerprint, network stub, db stub.
- internal/doctor/doctor_test.go — happy + sad path coverage.
- internal/security/integration_test.go — end-to-end: CA-init, CSR
  generation, mTLS handshake, mismatch failure, rotation alarm,
  redaction, file mode enforcement.

All tests pass with -race; gofmt -l . clean; go vet ./... clean.

---ci---
project: orca
phase: 8
milestone: v0.2
status: execute
---/ci---
2026-06-03 21:33:41 +00:00

256 lines
7.9 KiB
Go

// 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
}