adding ssh host signing
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user