fix(proxmox): TOFU capture bug — v0.6 ship-defect first-connect join always failed (T02.6)

---ci---
project: orca
phase: 2
milestone: v0.8
status: execute
---/ci---
This commit is contained in:
Jon Chery
2026-08-04 11:46:38 +00:00
parent bd17e6e114
commit 8b0cbe10ae
2 changed files with 167 additions and 5 deletions
+51 -5
View File
@@ -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)
}
+116
View File
@@ -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")
}
}