From bd17e6e1140b62fec28ad5f26e0a8bef87099dc1 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Tue, 4 Aug 2026 11:45:53 +0000 Subject: [PATCH] feat(proxmox): pinnedHostKeyCallback for --host-key-fingerprint (T02.5, REQ-058) ---ci--- project: orca phase: 2 milestone: v0.8 status: execute ---/ci--- --- internal/proxmox/bootstrap.go | 41 ++++++++++++++++--- internal/proxmox/bootstrap_test.go | 60 ++++++++++++++++++++++++++++ internal/proxmox/ssh_session_test.go | 22 +++++++--- 3 files changed, 112 insertions(+), 11 deletions(-) diff --git a/internal/proxmox/bootstrap.go b/internal/proxmox/bootstrap.go index 256e23a..013d614 100644 --- a/internal/proxmox/bootstrap.go +++ b/internal/proxmox/bootstrap.go @@ -25,6 +25,7 @@ import ( "context" "fmt" "log/slog" + "net" "strings" "time" @@ -124,12 +125,23 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) { return nil, fmt.Errorf("ssh key: %w", err) } - // Step 2: SSH dial with password auth + TOFU host-key capture (D-035). - // knownhosts.New reads ~/.orca/known_hosts; on first connect it - // captures the host key, on subsequent connects it verifies. - hostKeyCallback, err := knownhosts.New(certpaths.KnownHostsPath()) - if err != nil { - return nil, fmt.Errorf("known_hosts callback: %w", err) + // Step 2: SSH dial with password auth + host-key verification (D-035, + // REQ-058). When opts.HostKeyFingerprint is set (D-044), use a pinned + // callback that fails closed on mismatch (AD-028); otherwise fall + // back to the TOFU known_hosts capture path. + var hostKeyCallback ssh.HostKeyCallback + if opts.HostKeyFingerprint != "" { + cb, err := pinnedHostKeyCallback(opts.HostKeyFingerprint) + if err != nil { + return nil, fmt.Errorf("host-key fingerprint: %w", err) + } + hostKeyCallback = cb + } else { + cb, err := knownhosts.New(certpaths.KnownHostsPath()) + if err != nil { + return nil, fmt.Errorf("known_hosts callback: %w", err) + } + hostKeyCallback = cb } sshAddr := fmt.Sprintf("%s:%d", opts.Host, opts.SSHPort) @@ -211,6 +223,23 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) { // variable so tests can override it with a fake SSH server. var sshDialer sshDialerType = defaultSSHDialer{} +// pinnedHostKeyCallback returns an ssh.HostKeyCallback that pins the +// server's host key to the operator-supplied SHA256:base64 fingerprint +// (REQ-058, AD-028). It validates the `SHA256:` prefix up front (D-045) +// and fails closed on any mismatch. +func pinnedHostKeyCallback(expectedSHA256Base64 string) (ssh.HostKeyCallback, error) { + if !strings.HasPrefix(expectedSHA256Base64, "SHA256:") { + return nil, fmt.Errorf("pinnedHostKeyCallback: fingerprint must be SHA256:-prefixed (D-045), got %q", expectedSHA256Base64) + } + return func(_ string, _ net.Addr, key ssh.PublicKey) error { + got := security.SSHFingerprintSHA256(key) + if got != expectedSHA256Base64 { + return fmt.Errorf("REQ-058 host-key fingerprint mismatch: pinned=%s server=%s", expectedSHA256Base64, got) + } + return nil + }, nil +} + type sshDialerType interface { DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) } diff --git a/internal/proxmox/bootstrap_test.go b/internal/proxmox/bootstrap_test.go index cfd5483..4050102 100644 --- a/internal/proxmox/bootstrap_test.go +++ b/internal/proxmox/bootstrap_test.go @@ -13,6 +13,8 @@ import ( "time" "golang.org/x/crypto/ssh" + + "git.cloudinit.dev/coreci/orca/internal/security" ) func TestSudoersContent(t *testing.T) { @@ -488,3 +490,61 @@ func TestSSHSessionRunner_CombinedOutput_NewSessionError(t *testing.T) { t.Errorf("error should mention new session, got: %v", err) } } + +// TestPinnedHostKeyCallback_Match verifies the pinned callback returns +// nil when the server-presented key matches the operator-supplied +// fingerprint (T02.5, REQ-058). +func TestPinnedHostKeyCallback_Match(t *testing.T) { + srv := newFakeSSHServer(t) + defer srv.close() + host, port, _ := net.SplitHostPort(srv.addr()) + hostKey := srv.hostPublicKey() + if hostKey == nil { + t.Fatal("server host key is nil") + } + expectedFP := security.SSHFingerprintSHA256(hostKey) + + cb, err := pinnedHostKeyCallback(expectedFP) + if err != nil { + t.Fatalf("pinnedHostKeyCallback: %v", err) + } + if err := cb(host+":"+port, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil { + t.Errorf("match callback returned error: %v", err) + } +} + +// TestPinnedHostKeyCallback_Mismatch verifies the pinned callback fails +// closed on mismatch (T02.5, REQ-058). +func TestPinnedHostKeyCallback_Mismatch(t *testing.T) { + srv := newFakeSSHServer(t) + defer srv.close() + host, _, _ := net.SplitHostPort(srv.addr()) + hostKey := srv.hostPublicKey() + if hostKey == nil { + t.Fatal("server host key is nil") + } + + cb, err := pinnedHostKeyCallback("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatalf("pinnedHostKeyCallback: %v", err) + } + err = cb(host+":22", &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey) + if err == nil { + t.Fatal("expected mismatch error, got nil") + } + if !strings.Contains(err.Error(), "REQ-058") { + t.Errorf("mismatch error should mention REQ-058, got: %v", err) + } +} + +// TestPinnedHostKeyCallback_RejectsRawHex verifies the constructor +// rejects a non-SHA256:-prefixed fingerprint (T02.5, D-045). +func TestPinnedHostKeyCallback_RejectsRawHex(t *testing.T) { + _, err := pinnedHostKeyCallback("abcdef0123456789") + if err == nil { + t.Fatal("expected error for raw hex fingerprint, got nil") + } + if !strings.Contains(err.Error(), "SHA256:") { + t.Errorf("error should mention SHA256: prefix requirement, got: %v", err) + } +} diff --git a/internal/proxmox/ssh_session_test.go b/internal/proxmox/ssh_session_test.go index 178a87d..f5531f0 100644 --- a/internal/proxmox/ssh_session_test.go +++ b/internal/proxmox/ssh_session_test.go @@ -27,6 +27,7 @@ type fakeSSHServer struct { state map[string]string authDir string forceSudoersInvalid bool + hostSigner ssh.Signer } func newFakeSSHServer(t *testing.T) *fakeSSHServer { @@ -54,11 +55,12 @@ func newFakeSSHServer(t *testing.T) *fakeSSHServer { t.Fatalf("listen: %v", err) } srv := &fakeSSHServer{ - listener: ln, - config: config, - done: make(chan struct{}), - state: make(map[string]string), - authDir: t.TempDir(), + listener: ln, + config: config, + done: make(chan struct{}), + state: make(map[string]string), + authDir: t.TempDir(), + hostSigner: hostSigner, } go srv.serve() return srv @@ -66,6 +68,16 @@ func newFakeSSHServer(t *testing.T) *fakeSSHServer { func (s *fakeSSHServer) addr() string { return s.listener.Addr().String() } +// hostPublicKey returns the server's SSH host public key. Used by +// callback tests to compute the pinned fingerprint the operator would +// supply, and to feed the callback the exact key the server presents. +func (s *fakeSSHServer) hostPublicKey() ssh.PublicKey { + if s.hostSigner == nil { + return nil + } + return s.hostSigner.PublicKey() +} + func (s *fakeSSHServer) serve() { for { conn, err := s.listener.Accept()