feat(proxmox): pinnedHostKeyCallback for --host-key-fingerprint (T02.5, REQ-058)
---ci--- project: orca phase: 2 milestone: v0.8 status: execute ---/ci---
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user