// sshsigner.go — issuing SSH HOST certificates for already-enrolled hosts. // // This is the SSH counterpart of signer.go. Unlike enrolment (unauthenticated, // human-approved), an SSH host cert is issued AUTOMATICALLY to a host that // authenticates on the host plane with its fleet-CA mTLS identity (host.go) — // the cert IS the authorisation. The broker holds the step-ca provisioner // credential and drives `step ssh certificate`, so step-ca stays behind the // single front door and the host never holds a CA secret. Closes F-02 / P-17. // // BENCH-VERIFY: the exact `step ssh certificate` sign flags vary across step // versions (and the CA must have its SSH host CA enabled — see // serving/dot1x-bench/step-ca/ssh-ca-bootstrap.sh). Confirm against your // `step version`. The mock path returns a synthetic (non-cryptographic) cert line // so the host-plane HTTP flow is testable without a running step-ca. package main import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" ) // SSHSigner signs an OpenSSH host public key, returning the certificate as a // single authorized-keys/HostCertificate line. type SSHSigner interface { SignHost(ctx context.Context, pubKeyLine, keyID string, principals []string) (certLine string, err error) Kind() string } // --- production: step-ca SSH host CA via the `step` CLI --------------------- type stepSSHSigner struct { bin string caURL string root string provisioner string passwordFile string notAfter string } func (s *stepSSHSigner) Kind() string { return "step" } func (s *stepSSHSigner) SignHost(ctx context.Context, pubKeyLine, keyID string, principals []string) (string, error) { if strings.TrimSpace(pubKeyLine) == "" { return "", fmt.Errorf("empty public key") } if keyID == "" { return "", fmt.Errorf("empty key id") } if len(principals) == 0 { return "", fmt.Errorf("no principals requested") } dir, err := os.MkdirTemp("", "sshhost-*") if err != nil { return "", err } defer os.RemoveAll(dir) // `step ssh certificate --sign ` writes -cert.pub next to // the input; give the input a .pub name so the derived cert path is stable. pubPath := filepath.Join(dir, "hostkey.pub") certPath := filepath.Join(dir, "hostkey-cert.pub") if err := os.WriteFile(pubPath, []byte(strings.TrimRight(pubKeyLine, "\n")+"\n"), 0o600); err != nil { return "", err } args := []string{ "ssh", "certificate", "--host", // HOST certificate (not a user cert) "--sign", // sign the EXISTING public key (don't generate one) "--no-password", "--insecure", // non-interactive; key file is already on disk "--provisioner", s.provisioner, "--provisioner-password-file", s.passwordFile, "--ca-url", s.caURL, "--root", s.root, } for _, p := range principals { args = append(args, "--principal", p) } if s.notAfter != "" { args = append(args, "--not-after", s.notAfter) } args = append(args, keyID, pubPath) if _, err := runStep(ctx, s.bin, args...); err != nil { return "", fmt.Errorf("step ssh certificate: %w", err) } cert, err := os.ReadFile(certPath) if err != nil { return "", fmt.Errorf("read signed cert: %w", err) } return strings.TrimSpace(string(cert)), nil } // runStep executes `step ...`, returning stdout (stderr folded into the error). // We never log argv (it can carry a token) or the password-file contents. func runStep(ctx context.Context, bin string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, bin, args...) out, err := cmd.Output() if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(ee.Stderr))) } return "", err } return string(out), nil } // --- dev only: synthetic cert line ----------------------------------------- // mockSSHSigner returns a deterministic, NON-cryptographic cert line so the // host-plane HTTP path is testable without a running step-ca. LAB/TEST ONLY. type mockSSHSigner struct{} func (mockSSHSigner) Kind() string { return "mock" } func (mockSSHSigner) SignHost(_ context.Context, pubKeyLine, keyID string, principals []string) (string, error) { fields := strings.Fields(pubKeyLine) if len(fields) < 2 { return "", fmt.Errorf("malformed public key") } // Not a real certificate — just an identifiable placeholder blob. return fmt.Sprintf("ecdsa-sha2-nistp256-cert-v01@openssh.com MOCK-%s-%s %s", keyID, strings.Join(principals, ","), fields[len(fields)-1]), nil }