20523ac045
---ci--- project: orca phase: 7 milestone: v0.12 status: execute ---/ci--- R-021 invariant: no passwords, no Orca-issued tokens, no CA-key passphrases anywhere in the system. Removed: - proxmox/bootstrap.go: ssh.Password auth -> ssh.PublicKeys (key-based). --password/ removed from node join; replaced with --ssh-key (default: orca SSH key). Pre-staged key required. - stepca/stepca.go: --password-file /dev/stdin removed from Init and issueCert. Provisioner changed to 'orca-oidc' (OIDC provisioner). - identity/spiffe.go: --password-file removed from MintSVID. Provisioner changed to 'orca-oidc'. Tests: all proxmox, stepca, identity, cli tests updated + pass. 3 new password-rejection regression tests. Fake SSH server gains PublicKeyCallback. go vet clean. Full build green.
261 lines
9.7 KiB
Go
261 lines
9.7 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("/tmp/orca-%s.crt", sanitize(subject))
|
|
keyOut := fmt.Sprintf("/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()
|
|
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("/tmp/orca-%s.crt", sanitize(peer))
|
|
keyPath := fmt.Sprintf("/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, "'", "'\\''") + "'"
|
|
}
|