131 lines
4.4 KiB
Go
131 lines
4.4 KiB
Go
// 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 <id> <key.pub>` writes <key>-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
|
|
}
|