diff --git a/internal/proxmox/bootstrap.go b/internal/proxmox/bootstrap.go index 013d614..e797503 100644 --- a/internal/proxmox/bootstrap.go +++ b/internal/proxmox/bootstrap.go @@ -22,10 +22,13 @@ package proxmox import ( + "bytes" "context" + "errors" "fmt" "log/slog" "net" + "os" "strings" "time" @@ -127,8 +130,12 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) { // 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. + // callback that fails closed on mismatch (AD-028); otherwise use the + // TOFU known_hosts capture callback (D-035). The TOFU wrapper fixes + // the v0.6 ship-defect where knownhosts.New returned KeyError{Want:[]} + // on first connect WITHOUT writing the captured key, so the first + // `orca node join --type proxmox` always failed. + sshAddr := fmt.Sprintf("%s:%d", opts.Host, opts.SSHPort) var hostKeyCallback ssh.HostKeyCallback if opts.HostKeyFingerprint != "" { cb, err := pinnedHostKeyCallback(opts.HostKeyFingerprint) @@ -137,14 +144,13 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) { } hostKeyCallback = cb } else { - cb, err := knownhosts.New(certpaths.KnownHostsPath()) + cb, err := tofuHostKeyCallback(sshAddr) if err != nil { - return nil, fmt.Errorf("known_hosts callback: %w", err) + return nil, fmt.Errorf("tofu host-key callback: %w", err) } hostKeyCallback = cb } - sshAddr := fmt.Sprintf("%s:%d", opts.Host, opts.SSHPort) sshConfig := &ssh.ClientConfig{ User: opts.SSHUser, Auth: []ssh.AuthMethod{ssh.Password(opts.Password)}, @@ -240,6 +246,46 @@ func pinnedHostKeyCallback(expectedSHA256Base64 string) (ssh.HostKeyCallback, er }, nil } +// tofuHostKeyCallback returns an ssh.HostKeyCallback that wraps the +// standard knownhosts.New verifier with TOFU first-connect capture +// (D-035). On a host-unknown KeyError{Want:[]} it writes the +// server-presented key to certpaths.KnownHostsPath() atomically +// (security.WriteAtomic, AD-029) and allows the dial to proceed; on a +// mismatch (Want non-empty) it fails closed (MITM detection). This +// fixes the v0.6 ship-defect where knownhosts.New returned +// KeyError{Want:[]} on first connect WITHOUT writing the captured key, +// so the first `orca node join --type proxmox` always failed. +func tofuHostKeyCallback(addr string) (ssh.HostKeyCallback, error) { + cb, err := knownhosts.New(certpaths.KnownHostsPath()) + if err != nil { + return nil, err + } + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + err := cb(hostname, remote, key) + if err == nil { + return nil + } + var keyErr *knownhosts.KeyError + if errors.As(err, &keyErr) && len(keyErr.Want) == 0 { + line := knownhosts.Line([]string{knownhosts.Normalize(addr)}, key) + path := certpaths.KnownHostsPath() + existing, readErr := os.ReadFile(path) + if readErr != nil && !os.IsNotExist(readErr) { + return fmt.Errorf("tofu read known_hosts: %w", readErr) + } + if len(existing) > 0 && !bytes.HasSuffix(existing, []byte("\n")) { + existing = append(existing, '\n') + } + updated := append(existing, []byte(line)...) + if writeErr := security.WriteAtomic(path, 0o600, updated); writeErr != nil { + return fmt.Errorf("tofu write known_hosts: %w", writeErr) + } + return nil + } + return err + }, 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 4050102..30cfc3e 100644 --- a/internal/proxmox/bootstrap_test.go +++ b/internal/proxmox/bootstrap_test.go @@ -3,6 +3,8 @@ package proxmox import ( "bytes" "context" + "crypto/ed25519" + "crypto/rand" "errors" "log/slog" "net" @@ -13,6 +15,7 @@ import ( "time" "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" "git.cloudinit.dev/coreci/orca/internal/security" ) @@ -548,3 +551,116 @@ func TestPinnedHostKeyCallback_RejectsRawHex(t *testing.T) { t.Errorf("error should mention SHA256: prefix requirement, got: %v", err) } } + +// TestTOFUHostKeyCallback_FirstConnectCapturesKey verifies that on +// first connect (empty known_hosts) the TOFU callback captures the +// server key, writes it to known_hosts, and allows the dial (T02.6 — +// v0.6 ship-defect fix). +func TestTOFUHostKeyCallback_FirstConnectCapturesKey(t *testing.T) { + home := setupORCAHome(t) // empty known_hosts + srv := newFakeSSHServer(t) + defer srv.close() + host, port, _ := net.SplitHostPort(srv.addr()) + addr := host + ":" + port + hostKey := srv.hostPublicKey() + if hostKey == nil { + t.Fatal("server host key is nil") + } + + cb, err := tofuHostKeyCallback(addr) + if err != nil { + t.Fatalf("tofuHostKeyCallback: %v", err) + } + if err := cb(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil { + t.Fatalf("first-connect callback returned error: %v", err) + } + data, err := os.ReadFile(filepath.Join(home, "known_hosts")) + if err != nil { + t.Fatalf("read known_hosts: %v", err) + } + if len(data) == 0 { + t.Fatal("known_hosts is empty — TOFU capture did not write the key (v0.6 ship-defect not fixed)") + } + if !strings.Contains(string(data), knownhosts.Normalize(addr)) { + t.Errorf("known_hosts missing the normalized addr %q: %s", knownhosts.Normalize(addr), data) + } + if !strings.Contains(string(data), hostKey.Type()) { + t.Errorf("known_hosts missing the host key type %q: %s", hostKey.Type(), data) + } +} + +// TestTOFUHostKeyCallback_SecondConnectMatches verifies that on a +// second connect (known_hosts already has the key) the TOFU callback +// matches and returns nil (T02.6). +func TestTOFUHostKeyCallback_SecondConnectMatches(t *testing.T) { + setupORCAHome(t) + srv := newFakeSSHServer(t) + defer srv.close() + host, port, _ := net.SplitHostPort(srv.addr()) + addr := host + ":" + port + hostKey := srv.hostPublicKey() + if hostKey == nil { + t.Fatal("server host key is nil") + } + + // First connect: capture + write. + cb1, err := tofuHostKeyCallback(addr) + if err != nil { + t.Fatalf("tofuHostKeyCallback #1: %v", err) + } + if err := cb1(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil { + t.Fatalf("first connect: %v", err) + } + + // Second connect: the fresh knownhosts.New reads the written key. + cb2, err := tofuHostKeyCallback(addr) + if err != nil { + t.Fatalf("tofuHostKeyCallback #2: %v", err) + } + if err := cb2(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil { + t.Fatalf("second connect should match, got: %v", err) + } +} + +// TestTOFUHostKeyCallback_MismatchFails verifies that on a mismatch +// (known_hosts has a different key) the TOFU callback fails closed +// (MITM detection) (T02.6). +func TestTOFUHostKeyCallback_MismatchFails(t *testing.T) { + setupORCAHome(t) + srv := newFakeSSHServer(t) + defer srv.close() + host, port, _ := net.SplitHostPort(srv.addr()) + addr := host + ":" + port + hostKey := srv.hostPublicKey() + if hostKey == nil { + t.Fatal("server host key is nil") + } + + // Capture the real key first so known_hosts is populated. + cb1, err := tofuHostKeyCallback(addr) + if err != nil { + t.Fatalf("tofuHostKeyCallback #1: %v", err) + } + if err := cb1(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostKey); err != nil { + t.Fatalf("first connect: %v", err) + } + + // Generate a different key + present it: callback must fail. + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ed25519 gen: %v", err) + } + altKey, err := ssh.NewPublicKey(pub) + if err != nil { + t.Fatalf("new pub: %v", err) + } + + cb2, err := tofuHostKeyCallback(addr) + if err != nil { + t.Fatalf("tofuHostKeyCallback #2: %v", err) + } + err = cb2(addr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, altKey) + if err == nil { + t.Fatal("expected mismatch error, got nil") + } +}