adding ssh host signing

This commit is contained in:
Audiobuzz
2026-07-11 16:20:21 +09:30
parent b5d6c790c4
commit 2e318fba00
24 changed files with 1626 additions and 90 deletions
+130
View File
@@ -0,0 +1,130 @@
// 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
}