Files
orca/internal/stepca/stepca.go
T
Jon Chery 0d5ff663b4 fix(P13): step-ca /tmp hardening (REQ-128, F10)
---ci---
project: orca
phase: 13
milestone: v0.12
status: execute
---/ci---

step-ca cert/key temp files moved from world-readable /tmp/orca-* to
/etc/orca/step-tmp/orca-* (0700). mkdir + chmod 700 before writing.
Fixes both stepca.go and spiffe.go. All tests updated + pass.
2026-08-07 11:22:07 +00:00

266 lines
10 KiB
Go

// Package stepca wraps the smallstep `step` CLI for the Orca cluster
// CA (REQ-076, D-101 reversing AD-010). The CLI holds the cluster CA's
// private key on the lead node and invokes `step ca init`,
// `step ca certificate`, and `step ca renew` over SSH on the lead via
// the sshpush transport. There is intentionally no Go step-ca client
// library — the zero-new-dependency posture is preserved.
//
// Cert lifetimes follow the SPIFFE/SVID convention: server certs are
// 90-day (2160h) and SVIDs are 24h, matching the v0.9 PRD workload
// identity model (D-068). Renewal happens 30 days before expiry.
package stepca
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
)
// Sentinel errors.
var (
// ErrLeadUnset is returned when the client has no lead peer
// configured (e.g., NewClient was given an empty leadPeer).
ErrLeadUnset = errors.New("stepca: lead peer not set")
// ErrStepCLI is wrapped around any non-zero exit from the step CLI.
ErrStepCLI = errors.New("stepca: step CLI failed")
)
// Cert lifetimes (D-068, REQ-076).
const (
// ServerCertNotAfter is the not-after for peer server certs: 90 days.
ServerCertNotAfter = "2160h"
// SVIDNotAfter is the not-after for workload SVIDs: 24 hours.
SVIDNotAfter = "24h"
// DefaultProvisioner is the JWE provisioner name the CLI mints
// tokens against on the lead.
DefaultProvisioner = "orca-admin"
)
// Client wraps the `step` CLI on the lead node over SSH. The zero
// value is NOT usable; construct one with NewClient.
type Client struct {
transport *sshpush.Transport
leadPeer string
// exec is the command-execution seam. It defaults to transport
// when nil (set by NewClient) and is overridden by tests in this
// package to inject a mock without a real SSH server.
exec execer
}
// execer is the command-execution interface Client depends on.
// *sshpush.Transport satisfies it via its Exec method. Kept
// unexported so the public API stays keyed to the concrete transport
// (callers pass *sshpush.Transport to NewClient).
type execer interface {
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
}
// NewClient returns a Client that invokes the step CLI on leadPeer
// (host:port) via transport. A nil transport is rejected at the first
// call site; an empty leadPeer makes every call return ErrLeadUnset.
func NewClient(transport *sshpush.Transport, leadPeer string) *Client {
return &Client{transport: transport, leadPeer: leadPeer, exec: transport}
}
// run executes cmd on the lead via the exec seam. It is the single
// chokepoint every public method funnels through, so tests intercept
// here.
func (c *Client) run(ctx context.Context, cmd string) ([]byte, error) {
return c.exec.Exec(ctx, c.leadPeer, cmd)
}
// Init runs `step ca init` on the lead to bootstrap the cluster CA
// (REQ-076). The root cert is expected to land at the location given
// by paths.CACertPath() (the v0.9 cluster/ca.crt location). After the
// init completes, Init copies the root CA cert back to the operator
// host so the CLI can present it to workloads and peers.
func (c *Client) Init(ctx context.Context, name string, dns string, address string) error {
if err := c.preflight(); err != nil {
return err
}
cmd := fmt.Sprintf(
"step ca init --name %s --dns %s --address %s --provisioner orca-oidc --deployment-type standalone",
shellQuote(name), shellQuote(dns), shellQuote(address),
)
if _, err := c.run(ctx, cmd); err != nil {
return fmt.Errorf("stepca: init: %w", err)
}
// Mirror the root CA cert to the operator-side paths.CACertPath()
// so the CLI can hand it out to peers and workloads without a
// second round-trip. The lead writes it to the canonical step-ca
// location; we cat it back over SSH.
remote := "/etc/step-ca/certs/root_ca.crt"
out, err := c.run(ctx, fmt.Sprintf("cat %s", shellQuote(remote)))
if err != nil {
return fmt.Errorf("stepca: read root ca: %w", err)
}
if len(out) == 0 {
return fmt.Errorf("stepca: init produced empty root ca at %s: %w", remote, ErrStepCLI)
}
local := paths.CACertPath()
if mkErr := os.MkdirAll(filepath.Dir(local), 0o755); mkErr != nil {
return fmt.Errorf("stepca: mkdir %s: %w", filepath.Dir(local), mkErr)
}
if wErr := os.WriteFile(local, out, 0o644); wErr != nil {
return fmt.Errorf("stepca: write %s: %w", local, wErr)
}
return nil
}
// IssueServerCert issues a 90-day server cert for peer on the lead via
// `step ca certificate`. The cert and key PEM are returned to the
// caller; the lead-side temp files are unlinked after the read.
// sans are appended as `--san` flags (one per SAN), with peer itself
// always added as the first SAN so the cert is valid for the bare
// hostname.
func (c *Client) IssueServerCert(ctx context.Context, peer string, sans []string) (cert string, key string, err error) {
if perr := c.preflight(); perr != nil {
return "", "", perr
}
return c.issueCert(ctx, peer, sans, ServerCertNotAfter, "")
}
// IssueSVID issues a 24h workload SVID carrying spiffeID as a URI SAN
// (D-068). The provisioner is pinned to DefaultProvisioner so the
// CLI-side token minting path is exercised consistently.
func (c *Client) IssueSVID(ctx context.Context, spiffeID string, sans []string) (cert string, key string, err error) {
if perr := c.preflight(); perr != nil {
return "", "", perr
}
return c.issueCert(ctx, spiffeID, sans, SVIDNotAfter, "orca-oidc")
}
// issueCert is the shared helper for IssueServerCert / IssueSVID.
// subject is the cert subject CN (and the first --san). notAfter is
// the duration string passed verbatim to `--not-after`. provisioner,
// when non-empty, is passed as `--provisioner`.
func (c *Client) issueCert(ctx context.Context, subject string, sans []string, notAfter string, provisioner string) (string, string, error) {
certOut := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.crt", sanitize(subject))
keyOut := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.key", sanitize(subject))
var sb strings.Builder
sb.WriteString("step ca certificate ")
sb.WriteString(shellQuote(subject))
sb.WriteString(" ")
sb.WriteString(shellQuote(certOut))
sb.WriteString(" ")
sb.WriteString(shellQuote(keyOut))
sb.WriteString(" --not-after ")
sb.WriteString(shellQuote(notAfter))
sb.WriteString(" --san ")
sb.WriteString(shellQuote(subject))
for _, s := range sans {
sb.WriteString(" --san ")
sb.WriteString(shellQuote(s))
}
if provisioner != "" {
sb.WriteString(" --provisioner ")
sb.WriteString(shellQuote(provisioner))
}
sb.WriteString(" --force")
cmd := sb.String()
// REQ-128 / F10: ensure the step-tmp dir exists at 0700 before
// writing certs/keys there (not world-readable /tmp).
if _, err := c.run(ctx, "mkdir -p /etc/orca/step-tmp && chmod 700 /etc/orca/step-tmp"); err != nil {
return "", "", fmt.Errorf("stepca: mkdir step-tmp: %w", err)
}
if _, err := c.run(ctx, cmd); err != nil {
return "", "", fmt.Errorf("stepca: issue %s: %w", subject, err)
}
certPEM, err := c.readFile(ctx, certOut)
if err != nil {
return "", "", err
}
keyPEM, err := c.readFile(ctx, keyOut)
if err != nil {
return "", "", err
}
// Best-effort cleanup; failure to unlink is non-fatal.
_, _ = c.run(ctx, fmt.Sprintf("rm -f %s %s", shellQuote(certOut), shellQuote(keyOut)))
return certPEM, keyPEM, nil
}
// RenewServerCert renews a peer's server cert 30 days before expiry
// (REQ-076). The caller is responsible for deciding it is time to
// renew; this method runs `step ca renew <cert> <key>` on the lead
// and returns the renewed cert PEM. The key is unchanged by step-ca
// renew for RSA/ECDSA keys; for Ed25519 the key is rotated and the
// new key is returned alongside.
func (c *Client) RenewServerCert(ctx context.Context, peer string) error {
if perr := c.preflight(); perr != nil {
return perr
}
certPath := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.crt", sanitize(peer))
keyPath := fmt.Sprintf("/etc/orca/step-tmp/orca-%s.key", sanitize(peer))
cmd := fmt.Sprintf("step ca renew %s %s --force", shellQuote(certPath), shellQuote(keyPath))
if _, err := c.run(ctx, cmd); err != nil {
return fmt.Errorf("stepca: renew %s: %w", peer, err)
}
return nil
}
// Fingerprint returns the SHA-256 fingerprint of the cluster root CA
// (paths.CACertPath on the lead, mirrored locally by Init). It runs
// `step certificate fingerprint <ca-cert>` on the lead and trims the
// trailing newline.
func (c *Client) Fingerprint(ctx context.Context) (string, error) {
if perr := c.preflight(); perr != nil {
return "", perr
}
remote := "/etc/step-ca/certs/root_ca.crt"
cmd := fmt.Sprintf("step certificate fingerprint %s", shellQuote(remote))
out, err := c.run(ctx, cmd)
if err != nil {
return "", fmt.Errorf("stepca: fingerprint: %w", err)
}
fp := strings.TrimSpace(string(out))
if fp == "" {
return "", fmt.Errorf("stepca: empty fingerprint: %w", ErrStepCLI)
}
return fp, nil
}
// preflight validates the client is usable.
func (c *Client) preflight() error {
if c.exec == nil {
return errors.New("stepca: transport is nil")
}
if c.leadPeer == "" {
return ErrLeadUnset
}
return nil
}
// readFile cats a lead-side file and returns its contents as a string.
func (c *Client) readFile(ctx context.Context, path string) (string, error) {
out, err := c.run(ctx, fmt.Sprintf("cat %s", shellQuote(path)))
if err != nil {
return "", fmt.Errorf("stepca: read %s: %w", path, err)
}
if len(out) == 0 {
return "", fmt.Errorf("stepca: empty file %s: %w", path, ErrStepCLI)
}
return string(out), nil
}
// sanitize replaces path-unsafe characters in a subject so it can be
// used in a /tmp filename. SPIFFE IDs contain `://` and `/`, both of
// which would confuse the shell. We collapse to `_`.
func sanitize(s string) string {
r := strings.NewReplacer("://", "-", "/", "_", ":", "_", " ", "_")
return r.Replace(s)
}
// shellQuote single-quotes a string for safe shell interpolation. It
// escapes embedded single-quotes via the standard '\” idiom (mirrors
// sshpush.shellQuote, kept local to avoid importing an unexported
// helper).
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}