// 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 ` 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 ` 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, "'", "'\\''") + "'" }