Files
orca/internal/stepca/stepca_test.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

366 lines
12 KiB
Go

package stepca
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
)
// mockExec is a record-and-replay execer for the stepca.Client. It
// stores every command it received keyed by a substring match, so a
// test can assert "Init ran `step ca init`" without coupling to
// exact-flag ordering. Each entry maps a substring the test expects
// to appear in the command to the output that should be returned.
type mockExec struct {
// responses is a list of (substring, output, err). The first
// matching entry wins; an entry with an empty substring matches
// any command (catch-all).
responses []mockResp
// calls records every command the client issued, in order.
calls []string
}
type mockResp struct {
match string
out []byte
err error
}
func (m *mockExec) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
m.calls = append(m.calls, cmd)
for _, r := range m.responses {
if r.match == "" || strings.Contains(cmd, r.match) {
return r.out, r.err
}
}
return nil, nil
}
// newMockClient returns a Client wired to a mockExec and an ORCA_HOME
// under a temp dir (so paths.CACertPath() resolves to a writable path
// during Init tests).
func newMockClient(t *testing.T, lead string) (*Client, *mockExec) {
t.Helper()
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
mx := &mockExec{}
c := NewClient(nil, lead)
c.exec = mx
return c, mx
}
func containsCall(t *testing.T, mx *mockExec, want string) {
t.Helper()
for _, c := range mx.calls {
if strings.Contains(c, want) {
return
}
}
t.Errorf("no exec call contained %q; calls were:\n%s", want, strings.Join(mx.calls, "\n"))
}
func TestNewClient_Defaults(t *testing.T) {
tr := sshpush.NewTransport("/tmp/key", "/tmp/kh")
c := NewClient(tr, "lead:22")
if c.leadPeer != "lead:22" {
t.Errorf("leadPeer = %q", c.leadPeer)
}
if c.transport != tr {
t.Error("transport not stored")
}
if c.exec == nil {
t.Error("exec seam is nil")
}
}
func TestClient_Preflight_LeadUnset(t *testing.T) {
c, _ := newMockClient(t, "")
if err := c.Init(context.Background(), "n", "d", "a"); !errors.Is(err, ErrLeadUnset) {
t.Errorf("Init with empty lead: err = %v, want ErrLeadUnset", err)
}
if _, _, err := c.IssueServerCert(context.Background(), "p", nil); !errors.Is(err, ErrLeadUnset) {
t.Errorf("IssueServerCert: err = %v, want ErrLeadUnset", err)
}
if _, _, err := c.IssueSVID(context.Background(), "spiffe://orca/x", nil); !errors.Is(err, ErrLeadUnset) {
t.Errorf("IssueSVID: err = %v, want ErrLeadUnset", err)
}
if err := c.RenewServerCert(context.Background(), "p"); !errors.Is(err, ErrLeadUnset) {
t.Errorf("RenewServerCert: err = %v, want ErrLeadUnset", err)
}
if _, err := c.Fingerprint(context.Background()); !errors.Is(err, ErrLeadUnset) {
t.Errorf("Fingerprint: err = %v, want ErrLeadUnset", err)
}
}
func TestClient_Preflight_NilExec(t *testing.T) {
c := &Client{leadPeer: "lead:22"} // exec is nil
if err := c.Init(context.Background(), "n", "d", "a"); err == nil {
t.Fatal("Init with nil exec: expected error, got nil")
}
}
func TestInit_Success(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
caPEM := []byte("-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----\n")
mx.responses = []mockResp{
{match: "step ca init", out: nil, err: nil},
{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: caPEM, err: nil},
}
if err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443"); err != nil {
t.Fatalf("Init: %v", err)
}
containsCall(t, mx, "step ca init --name 'orca'")
containsCall(t, mx, "--dns 'ca.orca.local'")
containsCall(t, mx, "--address ':8443'")
containsCall(t, mx, "--provisioner orca-oidc")
containsCall(t, mx, "--deployment-type standalone")
// Root CA mirrored to paths.CACertPath().
got, err := os.ReadFile(paths.CACertPath())
if err != nil {
t.Fatalf("read mirrored CA: %v", err)
}
if string(got) != string(caPEM) {
t.Errorf("mirrored CA = %q, want %q", got, caPEM)
}
}
func TestInit_StepCLIFails(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
stepErr := errors.New("step: non-zero exit 1")
mx.responses = []mockResp{
{match: "step ca init", out: nil, err: stepErr},
}
err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
if err == nil {
t.Fatal("Init: expected error, got nil")
}
if !strings.Contains(err.Error(), "stepca: init") {
t.Errorf("err = %v, want wrapped 'stepca: init'", err)
}
}
func TestInit_EmptyRootCA(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step ca init", out: nil, err: nil},
{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: nil, err: nil},
}
err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
if err == nil {
t.Fatal("Init with empty root CA: expected error, got nil")
}
if !errors.Is(err, ErrStepCLI) {
t.Errorf("err = %v, want ErrStepCLI", err)
}
}
func TestIssueServerCert_Success(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
certPEM := []byte("SERVER-CERT-PEM")
keyPEM := []byte("SERVER-KEY-PEM")
mx.responses = []mockResp{
{match: "step ca certificate", out: nil, err: nil},
{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: certPEM, err: nil},
{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: keyPEM, err: nil},
{match: "rm -f", out: nil, err: nil},
}
gotCert, gotKey, err := c.IssueServerCert(context.Background(), "peer1", []string{"peer1.orca.local", "10.0.0.1"})
if err != nil {
t.Fatalf("IssueServerCert: %v", err)
}
if gotCert != string(certPEM) {
t.Errorf("cert = %q", gotCert)
}
if gotKey != string(keyPEM) {
t.Errorf("key = %q", gotKey)
}
containsCall(t, mx, "step ca certificate 'peer1'")
containsCall(t, mx, "--not-after '2160h'")
containsCall(t, mx, "--san 'peer1.orca.local'")
containsCall(t, mx, "--san '10.0.0.1'")
// Server cert path must NOT pin a provisioner (uses default).
for _, call := range mx.calls {
if strings.HasPrefix(call, "step ca certificate") && strings.Contains(call, "--provisioner") {
t.Errorf("server cert should not pin provisioner; cmd: %s", call)
}
}
}
func TestIssueSVID_Success(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
spiffe := "spiffe://orca/ns/_defaults/job/web/alloc/0"
certPEM := []byte("SVID-CERT-PEM")
keyPEM := []byte("SVID-KEY-PEM")
mx.responses = []mockResp{
{match: "step ca certificate", out: nil, err: nil},
{match: "cat '/etc/orca/step-tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.crt'", out: certPEM, err: nil},
{match: "cat '/etc/orca/step-tmp/orca-spiffe-orca_ns__defaults_job_web_alloc_0.key'", out: keyPEM, err: nil},
{match: "rm -f", out: nil, err: nil},
}
gotCert, gotKey, err := c.IssueSVID(context.Background(), spiffe, []string{"web.orca.local"})
if err != nil {
t.Fatalf("IssueSVID: %v", err)
}
if gotCert != string(certPEM) || gotKey != string(keyPEM) {
t.Errorf("cert/key mismatch")
}
containsCall(t, mx, "step ca certificate")
containsCall(t, mx, "--not-after '24h'")
containsCall(t, mx, "--provisioner 'orca-oidc'")
// SPIFFE ID is both the subject AND a SAN.
containsCall(t, mx, "--san '"+spiffe+"'")
}
func TestIssueServerCert_StepFails(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step ca certificate", out: nil, err: errors.New("step: exit 1")},
}
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
if err == nil || !strings.Contains(err.Error(), "stepca: issue") {
t.Errorf("err = %v, want wrapped 'stepca: issue'", err)
}
}
func TestIssueServerCert_ReadCertFails(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step ca certificate", out: nil, err: nil},
{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: nil, err: errors.New("ssh: cat failed")},
{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: nil, err: nil},
}
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
if err == nil || !strings.Contains(err.Error(), "read") {
t.Errorf("err = %v, want wrapped 'read'", err)
}
}
func TestIssueServerCert_EmptyCert(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step ca certificate", out: nil, err: nil},
{match: "cat '/etc/orca/step-tmp/orca-peer1.crt'", out: nil, err: nil},
{match: "cat '/etc/orca/step-tmp/orca-peer1.key'", out: []byte("KEY"), err: nil},
{match: "rm -f", out: nil, err: nil},
}
_, _, err := c.IssueServerCert(context.Background(), "peer1", nil)
if err == nil || !errors.Is(err, ErrStepCLI) {
t.Errorf("err = %v, want ErrStepCLI", err)
}
}
func TestRenewServerCert_Success(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step ca renew", out: nil, err: nil},
}
if err := c.RenewServerCert(context.Background(), "peer1"); err != nil {
t.Fatalf("RenewServerCert: %v", err)
}
containsCall(t, mx, "step ca renew '/etc/orca/step-tmp/orca-peer1.crt' '/etc/orca/step-tmp/orca-peer1.key' --force")
}
func TestRenewServerCert_Fails(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step ca renew", out: nil, err: errors.New("step: renew failed")},
}
err := c.RenewServerCert(context.Background(), "peer1")
if err == nil || !strings.Contains(err.Error(), "stepca: renew") {
t.Errorf("err = %v, want wrapped 'stepca: renew'", err)
}
}
func TestFingerprint_Success(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step certificate fingerprint", out: []byte("a1b2c3d4e5f6\n"), err: nil},
}
fp, err := c.Fingerprint(context.Background())
if err != nil {
t.Fatalf("Fingerprint: %v", err)
}
if fp != "a1b2c3d4e5f6" {
t.Errorf("fp = %q, want a1b2c3d4e5f6 (trimmed)", fp)
}
containsCall(t, mx, "step certificate fingerprint '/etc/step-ca/certs/root_ca.crt'")
}
func TestFingerprint_Empty(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step certificate fingerprint", out: []byte(""), err: nil},
}
_, err := c.Fingerprint(context.Background())
if err == nil || !errors.Is(err, ErrStepCLI) {
t.Errorf("err = %v, want ErrStepCLI", err)
}
}
func TestFingerprint_Fails(t *testing.T) {
c, mx := newMockClient(t, "lead:22")
mx.responses = []mockResp{
{match: "step certificate fingerprint", out: nil, err: errors.New("ssh: exec failed")},
}
_, err := c.Fingerprint(context.Background())
if err == nil || !strings.Contains(err.Error(), "stepca: fingerprint") {
t.Errorf("err = %v, want wrapped 'stepca: fingerprint'", err)
}
}
func TestInit_MkdirFails(t *testing.T) {
// Point ORCA_HOME at a path that cannot be created under to
// force MkdirAll failure. We use a file as the parent.
dir := t.TempDir()
blocker := filepath.Join(dir, "block")
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
t.Fatalf("write blocker: %v", err)
}
t.Setenv("ORCA_HOME", filepath.Join(blocker, "sub"))
// Construct the client directly (not newMockClient, which
// resets ORCA_HOME to a fresh temp dir).
mx := &mockExec{}
caPEM := []byte("FAKE")
mx.responses = []mockResp{
{match: "step ca init", out: nil, err: nil},
{match: "cat '/etc/step-ca/certs/root_ca.crt'", out: caPEM, err: nil},
}
c := NewClient(nil, "lead:22")
c.exec = mx
err := c.Init(context.Background(), "orca", "ca.orca.local", ":8443")
if err == nil {
t.Fatal("Init: expected mkdir error, got nil")
}
if !strings.Contains(err.Error(), "mkdir") {
t.Errorf("err = %v, want 'mkdir'", err)
}
}
func TestShellQuote(t *testing.T) {
got := shellQuote("a'b")
want := "'a'\\''b'"
if got != want {
t.Errorf("shellQuote = %q, want %q", got, want)
}
}
func TestSanitize(t *testing.T) {
cases := []struct{ in, want string }{
{"spiffe://orca/ns/_defaults/job/web/alloc/0",
"spiffe-orca_ns__defaults_job_web_alloc_0"},
{"plain-host", "plain-host"},
{"a b", "a_b"},
}
for _, tc := range cases {
if got := sanitize(tc.in); got != tc.want {
t.Errorf("sanitize(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}