Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4192be5d1 | |||
| 11da458883 | |||
| d66b3b9a0a | |||
| 2dcb14377a | |||
| 13e6762f0f | |||
| 325a5662f4 | |||
| 8b0cbe10ae | |||
| bd17e6e114 | |||
| 7cb12c52ce | |||
| 08481d35ce | |||
| 00869c6f5b | |||
| aa3462826b |
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"phase": 1,
|
||||
"phase": 2,
|
||||
"stage": "verify",
|
||||
"milestone": "v0.8",
|
||||
"milestone_slug": "coverage-trust-hardening",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-04T00:58:00Z",
|
||||
"updated_at": "2026-08-04T01:10:00Z",
|
||||
"milestone_complete": false,
|
||||
"next_milestone": null
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Phase 2 Verification — v0.8 Coverage & Trust Hardening
|
||||
|
||||
**Phase**: P02 — SSH trust hardening
|
||||
**Milestone**: v0.8
|
||||
**REQs**: REQ-058, REQ-059 (+ latent TOFU bugfix closure)
|
||||
**Date**: 2026-08-04
|
||||
**Result**: ✅ PASS (all 4 layers)
|
||||
|
||||
## Layer 1 — Structural ✅
|
||||
|
||||
- `go build ./...` PASS
|
||||
- `go vet ./...` PASS
|
||||
- No TODOs/stubs in new production code
|
||||
- All new exports resolve: `security.SSHFingerprintSHA256`, `security.WriteAtomic`, `proxmox.TOFUHostKeyCallback`, `proxmox.ResetHostKey`, `proxmox.pinnedHostKeyCallback`, `proxmox.Options.HostKeyFingerprint`, `cli.nodeKeyResetCmd`
|
||||
- Backward compatible: existing `BootstrapProxmox` callers work (the TOFU fix changed failure→success on first connect, which is the bugfix)
|
||||
|
||||
## Layer 2 — Behavioral ✅
|
||||
|
||||
- `go test ./internal/proxmox/... ./internal/cli/... ./internal/doctor/... ./internal/security/...` PASS
|
||||
- `go test -race ./internal/proxmox/... ./internal/doctor/...` PASS
|
||||
- Coverage held post-P02: proxmox 86.5% (was 87.1% in P01 — marginal change from new code paths), cli 76.7% (was 76.2%), doctor 70.4% (unchanged)
|
||||
- T02.10: all 7 end-to-end integration cases PASS (pinned correct/wrong, TOFU first/second/mismatch, key-reset+re-pin, pre-populated migration path)
|
||||
- T02.11: `--host-key-fingerprint` non-proxmox validation PASS
|
||||
|
||||
## Layer 3 — Security ✅
|
||||
|
||||
- **REQ-058**: `--host-key-fingerprint` fails closed on mismatch (pinnedHostKeyCallback returns error on any mismatch; bootstrap aborts before any SSH session command runs). SHA256: prefix validated up front. No downgrade to TOFU when pin supplied.
|
||||
- **REQ-059**: `orca node key-reset` is local-only (D-046) — only rewrites `~/.orca/known_hosts` via `security.WriteAtomic` (atomic temp+rename, AD-029); does NOT touch remote authorized_keys. Audit-logs `node.key_reset` with actor+node+host.
|
||||
- **TOFU bugfix (T02.6, v0.6 ship-defect)**: first-connect now captures + writes the key (was silently failing). Mismatch detection preserved (MITM protection). The `TOFUHostKeyCallback` is shared between bootstrap (T02.6) and doctor (T02.9) — GRILL condition #2 parity satisfied.
|
||||
- STRIDE: no new spoofing surface (pin is operator-supplied, fail-closed); no tampering (atomic rewrite); no repudiation (audit log); no info disclosure (fingerprint is a hash, not the key); no DoS (no network change); no elevation (local file ops only).
|
||||
- No secrets in test code (fake SSH keys generated in-test).
|
||||
|
||||
## Layer 4 — Quality ✅
|
||||
|
||||
- Tests follow existing conventions (table-driven, `fakeSSHServer` fixture reused, `sshDialer`/`sessionRunner` seams injected)
|
||||
- `TOFUHostKeyCallback` extracted to a shared helper (no duplication between bootstrap + doctor) — clean coupling (proxmox doesn't import doctor)
|
||||
- P0 issues: none. P1+ issues: none flagged.
|
||||
|
||||
## Requirement Coverage
|
||||
|
||||
| REQ | Status | Evidence |
|
||||
|-----|--------|----------|
|
||||
| REQ-058 | ✅ Complete | `--host-key-fingerprint` flag (T02.3) + `pinnedHostKeyCallback` (T02.5) + `Result.HostKeyFingerprint` (T02.7) + e2e tests (T02.10) + validation (T02.11) |
|
||||
| REQ-059 | ✅ Complete | `orca node key-reset <node>` (T02.8) + `proxmox.ResetHostKey` atomic rewrite + audit log + e2e test (T02.10 case 6) |
|
||||
| (TOFU bugfix) | ✅ Complete | T02.6 fixes v0.6 ship-defect (first-connect `knownhosts.New` KeyError{Want:[]} treated as dial failure); T02.9 doctor parity |
|
||||
|
||||
## GRILL Conditions Check
|
||||
|
||||
- **#1 (T02.6 labeled v0.6 ship-defect)**: ✅ commit `8b0cbe1` summary "TOFU capture bug — v0.6 ship-defect first-connect join always failed"
|
||||
- **#2 (T02.9 doctor parity)**: ✅ both bootstrap (`8b0cbe1`) and doctor (`2dcb143`) use the shared `proxmox.TOFUHostKeyCallback` wrapper
|
||||
|
||||
## Lessons
|
||||
|
||||
- The v0.6 TOFU bug was a latent ship-defect: `knownhosts.New` returns `KeyError{Want:[]}` on first connect without writing, and the original code treated this as a dial failure. This means first-connect Proxmox join has been broken since v0.6 shipped — a strong argument for P01's coverage uplift (the 5.1% proxmox coverage hid this). v0.8 P03's `verify-reqs` would not have caught this (it's code-vs-doc drift, not doc-vs-doc) — P04 audit is the backstop.
|
||||
- Extracting `TOFUHostKeyCallback` to a shared helper was the right call for GRILL condition #2 — duplicating the wrapper in doctor would have created drift risk.
|
||||
+84
-19
@@ -45,18 +45,19 @@ func nodeRegistry() (*engine.NodeRegistry, func() error, error) {
|
||||
}
|
||||
|
||||
var (
|
||||
joinName string
|
||||
joinAddr string
|
||||
joinCAFinger string
|
||||
joinType string
|
||||
joinHost string
|
||||
joinSSHUser string
|
||||
joinPassword string
|
||||
joinSSHPort int
|
||||
proxmoxUser string
|
||||
proxmoxRole string
|
||||
leaveID string
|
||||
nodeWatch bool
|
||||
joinName string
|
||||
joinAddr string
|
||||
joinCAFinger string
|
||||
joinType string
|
||||
joinHost string
|
||||
joinSSHUser string
|
||||
joinPassword string
|
||||
joinSSHPort int
|
||||
joinHostKeyFP string
|
||||
proxmoxUser string
|
||||
proxmoxRole string
|
||||
leaveID string
|
||||
nodeWatch bool
|
||||
)
|
||||
|
||||
var nodeCmd = &cobra.Command{
|
||||
@@ -76,6 +77,9 @@ Node types (via --type):
|
||||
(deploys orca pubkey, creates orca user + PVE role +
|
||||
sudoers allowlist; requires --host + --password)`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if joinHostKeyFP != "" && joinType != "proxmox" {
|
||||
return fmt.Errorf("--host-key-fingerprint requires --type proxmox today")
|
||||
}
|
||||
if joinType == "proxmox" {
|
||||
return joinProxmox(cmd)
|
||||
}
|
||||
@@ -156,13 +160,14 @@ func joinProxmox(cmd *cobra.Command) error {
|
||||
defer cancel()
|
||||
|
||||
result, err := proxmox.BootstrapProxmox(ctx, proxmox.Options{
|
||||
Host: joinHost,
|
||||
SSHUser: joinSSHUser,
|
||||
Password: password,
|
||||
ProxmoxUser: proxmoxUser,
|
||||
ProxmoxRole: proxmoxRole,
|
||||
SSHPort: joinSSHPort,
|
||||
Logger: newLogger(),
|
||||
Host: joinHost,
|
||||
SSHUser: joinSSHUser,
|
||||
Password: password,
|
||||
ProxmoxUser: proxmoxUser,
|
||||
ProxmoxRole: proxmoxRole,
|
||||
SSHPort: joinSSHPort,
|
||||
HostKeyFingerprint: joinHostKeyFP,
|
||||
Logger: newLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("proxmox bootstrap: %w", err)
|
||||
@@ -341,6 +346,64 @@ func renderNodeTable(nodes []*model.Node) string {
|
||||
return out
|
||||
}
|
||||
|
||||
var nodeKeyResetCmd = &cobra.Command{
|
||||
Use: "key-reset <node>",
|
||||
Short: "Reset the SSH known_hosts entry for a node",
|
||||
Long: `Remove the pinned SSH host key for <node> from the local known_hosts
|
||||
file. The next connect re-pins the key via TOFU or --host-key-fingerprint.
|
||||
|
||||
LOCAL ONLY (D-046): does not touch the remote host's authorized_keys.
|
||||
|
||||
<node> is the node name (for proxmox nodes, this is the host address).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeArg := args[0]
|
||||
|
||||
registry, closer, err := nodeRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
nodes, err := registry.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list nodes: %w", err)
|
||||
}
|
||||
var node *model.Node
|
||||
for _, n := range nodes {
|
||||
if n.Name == nodeArg || n.ID == nodeArg {
|
||||
node = n
|
||||
break
|
||||
}
|
||||
}
|
||||
if node == nil {
|
||||
return fmt.Errorf("node %q not found in the registry", nodeArg)
|
||||
}
|
||||
host := node.Name
|
||||
|
||||
if err := proxmox.ResetHostKey(host); err != nil {
|
||||
return fmt.Errorf("reset host key: %w", err)
|
||||
}
|
||||
|
||||
// Audit-log the reset (REQ-059): actor=cli, action=node.key_reset.
|
||||
db, dbCloser, dbErr := openDB()
|
||||
if dbErr == nil {
|
||||
defer dbCloser()
|
||||
audit := engine.NewAudit(store.NewAuditRepo(db), newLogger())
|
||||
audit.Record(ctx, "cli", "node.key_reset", node.ID, "success", nil, map[string]any{
|
||||
"node": node.Name,
|
||||
"host": host,
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "✓ Host key reset for %s (next connect will re-pin via TOFU or --host-key-fingerprint)\n", node.Name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required for --type localhost)")
|
||||
nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)")
|
||||
@@ -352,11 +415,13 @@ func init() {
|
||||
nodeJoinCmd.Flags().IntVar(&joinSSHPort, "ssh-port", 22, "SSH port for proxmox bootstrap (default 22)")
|
||||
nodeJoinCmd.Flags().StringVar(&proxmoxUser, "proxmox-user", "orca", "Linux system user to create on the proxmox host (config-overridable)")
|
||||
nodeJoinCmd.Flags().StringVar(&proxmoxRole, "proxmox-role", "OrcaOperator", "PVE custom role to create (config-overridable)")
|
||||
nodeJoinCmd.Flags().StringVar(&joinHostKeyFP, "host-key-fingerprint", "", "SSH host key SHA256:base64 fingerprint (pre-pin; supersedes TOFU for --type proxmox)")
|
||||
nodeLeaveCmd.Flags().StringVar(&leaveID, "id", "", "node id")
|
||||
nodeListCmd.Flags().BoolVar(&nodeWatch, "watch", false, "stream nodes until Ctrl-C (table refresh or --json per-event)")
|
||||
|
||||
nodeCmd.AddCommand(nodeJoinCmd)
|
||||
nodeCmd.AddCommand(nodeLeaveCmd)
|
||||
nodeCmd.AddCommand(nodeListCmd)
|
||||
nodeCmd.AddCommand(nodeKeyResetCmd)
|
||||
rootCmd.AddCommand(nodeCmd)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -318,3 +320,177 @@ func padHex(n int) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestNodeKeyReset removes the target node's known_hosts lines, leaves
|
||||
// other hosts' lines intact, and inserts an audit row (T02.8, REQ-059).
|
||||
func TestNodeKeyReset(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed a proxmox node whose Name is the host address (matches the
|
||||
// key-reset RunE, which uses node.Name as the known_hosts match key).
|
||||
seedProxmoxNode(t, "10.0.0.1", "10.0.0.1:8443")
|
||||
|
||||
// Pre-populate known_hosts: 2 lines for the target + 1 for another host.
|
||||
knownHosts := certpaths.KnownHostsPath()
|
||||
if err := os.MkdirAll(filepath.Dir(knownHosts), 0o755); err != nil {
|
||||
t.Fatalf("mkdir known_hosts dir: %v", err)
|
||||
}
|
||||
original := []byte("[10.0.0.1]:22 ssh-ed25519 AAAAKEY1 host1\n" +
|
||||
"10.0.0.1 ssh-ed25519 AAAAKEY1ALT host1-alt\n" +
|
||||
"[10.0.0.2]:22 ssh-ed25519 AAAAKEY2 host2\n")
|
||||
if err := os.WriteFile(knownHosts, original, 0o600); err != nil {
|
||||
t.Fatalf("write known_hosts: %v", err)
|
||||
}
|
||||
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"node", "key-reset", "10.0.0.1"})
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
t.Fatalf("node key-reset: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Host key reset for 10.0.0.1") {
|
||||
t.Errorf("output missing reset confirmation: %s", out)
|
||||
}
|
||||
|
||||
// known_hosts: target's 2 lines removed, other host's line intact.
|
||||
data, err := os.ReadFile(knownHosts)
|
||||
if err != nil {
|
||||
t.Fatalf("read known_hosts: %v", err)
|
||||
}
|
||||
result := string(data)
|
||||
if strings.Contains(result, "AAAAKEY1") {
|
||||
t.Errorf("target key line 1 not removed: %s", result)
|
||||
}
|
||||
if strings.Contains(result, "AAAAKEY1ALT") {
|
||||
t.Errorf("target key line 2 not removed: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "AAAAKEY2") {
|
||||
t.Errorf("other host's line was removed (should be intact): %s", result)
|
||||
}
|
||||
|
||||
// Audit row inserted with action=node.key_reset.
|
||||
db, err := store.Open(certpaths.DBPath())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
entries, err := store.NewAuditRepo(db).List(context.Background(), 50)
|
||||
if err != nil {
|
||||
t.Fatalf("list audit: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.Action == "node.key_reset" && strings.Contains(e.Resource, "10.0.0.1") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("audit row for node.key_reset not inserted: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeKeyReset_NodeNotFound verifies key-reset errors when the
|
||||
// node is not in the registry (T02.8).
|
||||
func TestNodeKeyReset_NodeNotFound(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{"node", "key-reset", "no.such.host"})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown node, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("error should mention not found, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedProxmoxNode(t *testing.T, name, addr string) string {
|
||||
t.Helper()
|
||||
db, err := store.Open(certpaths.DBPath())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
repo := store.NewNodeRepo(db)
|
||||
ctx := context.Background()
|
||||
n := &model.Node{
|
||||
ID: "node-" + name,
|
||||
Name: name,
|
||||
Address: addr,
|
||||
State: model.NodeStateReady,
|
||||
JoinedAt: time.Now().UTC(),
|
||||
LastSeen: time.Now().UTC(),
|
||||
Kind: string(model.NodeKindProxmox),
|
||||
OS: "pve",
|
||||
}
|
||||
if err := repo.Insert(ctx, n); err != nil {
|
||||
t.Fatalf("insert proxmox node: %v", err)
|
||||
}
|
||||
return n.ID
|
||||
}
|
||||
|
||||
// TestNodeJoinHostKeyFingerprintRequiresProxmox verifies T02.11:
|
||||
// `orca node join --type linux --host-key-fingerprint SHA256:...`
|
||||
// fails with a clear error from the D-044 RunE check. Exercises the
|
||||
// cobra Execute() error path end-to-end.
|
||||
func TestNodeJoinHostKeyFingerprintRequiresProxmox(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{
|
||||
"node", "join",
|
||||
"--type", "linux",
|
||||
"--name", "linux-node",
|
||||
"--host-key-fingerprint", "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --host-key-fingerprint without --type proxmox, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--host-key-fingerprint requires --type proxmox") {
|
||||
t.Errorf("error should mention the --host-key-fingerprint/--type proxmox requirement, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNodeJoinHostKeyFingerprintProxmoxAccepted verifies that
|
||||
// --host-key-fingerprint IS accepted for --type proxmox (the RunE check
|
||||
// does not reject a proxmox-type join that pins the host key). This is
|
||||
// the negative-space companion to TestNodeJoinHostKeyFingerprintRequiresProxmox
|
||||
// (T02.11): the validation must only reject non-proxmox types.
|
||||
//
|
||||
// We can't run the full bootstrap without a real SSH server, so we
|
||||
// assert that the RunE check passes (no "requires --type proxmox"
|
||||
// error) and the failure — if any — comes from a later stage (missing
|
||||
// --host / password), not the D-044 guard.
|
||||
func TestNodeJoinHostKeyFingerprintProxmoxAccepted(t *testing.T) {
|
||||
_, cleanup := initTestEnv(t)
|
||||
defer cleanup()
|
||||
resetRootFlags(t)
|
||||
var buf bytes.Buffer
|
||||
rootCmd.SetOut(&buf)
|
||||
rootCmd.SetErr(&buf)
|
||||
rootCmd.SetArgs([]string{
|
||||
"node", "join",
|
||||
"--type", "proxmox",
|
||||
"--host-key-fingerprint", "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
})
|
||||
err := rootCmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected a later-stage error (missing --host), got nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), "requires --type proxmox") {
|
||||
t.Errorf("D-044 guard wrongly rejected proxmox type: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-10
@@ -26,11 +26,11 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/osdetect"
|
||||
"git.cloudinit.dev/coreci/orca/internal/proxmox"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
"git.cloudinit.dev/coreci/orca/internal/transport"
|
||||
@@ -409,7 +409,19 @@ func probeProxmoxPVEVersion(ctx context.Context, host string) error {
|
||||
return fmt.Errorf("parse SSH key: %w", err)
|
||||
}
|
||||
|
||||
hostKeyCallback, err := knownhosts.New(certpaths.KnownHostsPath())
|
||||
// Extract host from the node address (orca stores host:8443;
|
||||
// SSH needs host:22). We dial the SSH port, not the orca daemon port.
|
||||
sshHost := host
|
||||
if strings.Contains(host, ":") {
|
||||
sshHost = strings.SplitN(host, ":", 2)[0]
|
||||
}
|
||||
sshAddr := sshHost + ":22"
|
||||
|
||||
// Use the shared TOFU capture-fix wrapper (T02.9 — GRILL condition
|
||||
// #2: doctor parity with bootstrap). Without this, a first-connect
|
||||
// proxmox node (entry missing from known_hosts) fails the doctor
|
||||
// probe even though it joined fine — the v0.6 ship-defect.
|
||||
hostKeyCallback, err := proxmox.TOFUHostKeyCallback(sshAddr, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("known_hosts: %w", err)
|
||||
}
|
||||
@@ -421,14 +433,6 @@ func probeProxmoxPVEVersion(ctx context.Context, host string) error {
|
||||
Timeout: 3 * time.Second,
|
||||
}
|
||||
|
||||
// Extract host from the node address (orca stores host:8443;
|
||||
// SSH needs host:22). We dial the SSH port, not the orca daemon port.
|
||||
sshHost := host
|
||||
if strings.Contains(host, ":") {
|
||||
sshHost = strings.SplitN(host, ":", 2)[0]
|
||||
}
|
||||
sshAddr := sshHost + ":22"
|
||||
|
||||
dialer := &netDialer{}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", sshAddr, config)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,14 +2,21 @@ package doctor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/certpaths"
|
||||
"git.cloudinit.dev/coreci/orca/internal/model"
|
||||
"git.cloudinit.dev/coreci/orca/internal/osdetect"
|
||||
"git.cloudinit.dev/coreci/orca/internal/proxmox"
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
"git.cloudinit.dev/coreci/orca/internal/store"
|
||||
)
|
||||
@@ -396,3 +403,90 @@ func init() {
|
||||
// Suppress slog noise during tests.
|
||||
_ = os.Setenv("ORCA_LOG_LEVEL", "error")
|
||||
}
|
||||
|
||||
// TestProxmoxCheck_FirstConnectCapturesKey verifies that the doctor
|
||||
// proxmox probe uses the shared TOFU capture-fix wrapper
|
||||
// (proxmox.TOFUHostKeyCallback), which captures the host key on first
|
||||
// connect instead of failing with KeyError{Want:[]} (T02.9 — GRILL
|
||||
// condition #2: doctor parity with bootstrap). Before T02.9, the bare
|
||||
// knownhosts.New callback returned KeyError{Want:[]} on a missing
|
||||
// entry and the doctor probe reported FAIL even though the node had
|
||||
// joined successfully — the v0.6 ship-defect.
|
||||
//
|
||||
// We exercise the exact wrapper doctor.go calls against a real SSH
|
||||
// server on an ephemeral port (the probe hardcodes :22, which we
|
||||
// cannot bind in CI). This proves the doctor's chosen callback captures
|
||||
// on first connect rather than failing — the parity guarantee.
|
||||
func TestProxmoxCheck_FirstConnectCapturesKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", dir)
|
||||
|
||||
// Empty known_hosts (first-connect scenario).
|
||||
if err := os.WriteFile(certpaths.KnownHostsPath(), []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("create known_hosts: %v", err)
|
||||
}
|
||||
|
||||
// Start a fake SSH server on an ephemeral port whose host key is
|
||||
// NOT yet in known_hosts.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
_, srvPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519 gen: %v", err)
|
||||
}
|
||||
hostSigner, err := ssh.NewSignerFromKey(srvPriv)
|
||||
if err != nil {
|
||||
t.Fatalf("ssh signer: %v", err)
|
||||
}
|
||||
srvConfig := &ssh.ServerConfig{NoClientAuth: true}
|
||||
srvConfig.AddHostKey(hostSigner)
|
||||
go func() {
|
||||
for {
|
||||
nconn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
_, chans, reqs, err := ssh.NewServerConn(c, srvConfig)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go ssh.DiscardRequests(reqs)
|
||||
for nc := range chans {
|
||||
nc.Reject(ssh.UnknownChannelType, "none")
|
||||
}
|
||||
}(nconn)
|
||||
}
|
||||
}()
|
||||
|
||||
sshAddr := ln.Addr().String()
|
||||
host, _, _ := net.SplitHostPort(sshAddr)
|
||||
|
||||
// The doctor probe now builds its HostKeyCallback via
|
||||
// proxmox.TOFUHostKeyCallback(sshAddr, nil). On first connect
|
||||
// (empty known_hosts) this must capture + write the key and return
|
||||
// nil, NOT a KeyError — the v0.6 ship-defect fix.
|
||||
cb, err := proxmox.TOFUHostKeyCallback(sshAddr, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("TOFUHostKeyCallback: %v", err)
|
||||
}
|
||||
if err := cb(sshAddr, &net.TCPAddr{IP: net.ParseIP(host), Port: 22}, hostSigner.PublicKey()); err != nil {
|
||||
t.Fatalf("first-connect doctor callback should capture (not fail): %v", err)
|
||||
}
|
||||
|
||||
// The captured key must now be in known_hosts.
|
||||
data, err := os.ReadFile(certpaths.KnownHostsPath())
|
||||
if err != nil {
|
||||
t.Fatalf("read known_hosts: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("known_hosts is empty — doctor capture-fix did not write the key (T02.9)")
|
||||
}
|
||||
if !strings.Contains(string(data), hostSigner.PublicKey().Type()) {
|
||||
t.Errorf("known_hosts missing the captured host key type: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,13 @@
|
||||
package proxmox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -68,6 +72,11 @@ type Options struct {
|
||||
ProxmoxRole string
|
||||
// SSHPort is the SSH port (default 22).
|
||||
SSHPort int
|
||||
// HostKeyFingerprint is the operator-pinned SSH host key fingerprint
|
||||
// in `SHA256:base64` form (REQ-058, D-044). When non-empty, the
|
||||
// bootstrap dialer uses a pinned-host-key callback instead of the
|
||||
// TOFU known_hosts capture path. Empty falls back to TOFU.
|
||||
HostKeyFingerprint string
|
||||
// Logger receives audit-log entries. If nil, slog.Default() is used.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
@@ -119,15 +128,30 @@ 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 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 capturedHostKey ssh.PublicKey
|
||||
var hostKeyCallback ssh.HostKeyCallback
|
||||
if opts.HostKeyFingerprint != "" {
|
||||
cb, err := pinnedHostKeyCallback(opts.HostKeyFingerprint, &capturedHostKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("host-key fingerprint: %w", err)
|
||||
}
|
||||
hostKeyCallback = cb
|
||||
} else {
|
||||
cb, err := TOFUHostKeyCallback(sshAddr, &capturedHostKey)
|
||||
if err != nil {
|
||||
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)},
|
||||
@@ -147,10 +171,16 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
|
||||
sessionRunner = &sshSessionRunner{client: conn}
|
||||
}
|
||||
|
||||
hostKeyFP := ""
|
||||
if capturedHostKey != nil {
|
||||
hostKeyFP = security.SSHFingerprintSHA256(capturedHostKey)
|
||||
}
|
||||
|
||||
log.Info("proxmox.ssh_connected",
|
||||
slog.String("event", "proxmox.ssh_connected"),
|
||||
slog.String("host", opts.Host),
|
||||
slog.String("ssh_user", opts.SSHUser),
|
||||
slog.String("host_key_fingerprint", hostKeyFP),
|
||||
)
|
||||
|
||||
// Step 3: Deploy orca pubkey to ~orca/.ssh/authorized_keys (idempotent).
|
||||
@@ -197,8 +227,9 @@ func BootstrapProxmox(ctx context.Context, opts Options) (*Result, error) {
|
||||
)
|
||||
|
||||
return &Result{
|
||||
NodeName: opts.Host,
|
||||
NodeAddress: opts.Host + ":8443",
|
||||
NodeName: opts.Host,
|
||||
NodeAddress: opts.Host + ":8443",
|
||||
HostKeyFingerprint: hostKeyFP,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -206,6 +237,78 @@ 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. The capturedKey out-param records
|
||||
// the verified server key so the caller can populate Result.
|
||||
func pinnedHostKeyCallback(expectedSHA256Base64 string, capturedKey *ssh.PublicKey) (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)
|
||||
}
|
||||
if capturedKey != nil {
|
||||
*capturedKey = key
|
||||
}
|
||||
return nil
|
||||
}, 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). The
|
||||
// capturedKey out-param records the verified/captured server key so
|
||||
// the caller can populate Result. 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.
|
||||
//
|
||||
// Exported so the doctor proxmox probe (T02.9) can reuse the same
|
||||
// capture-fix wrapper for parity (GRILL condition #2).
|
||||
func TOFUHostKeyCallback(addr string, capturedKey *ssh.PublicKey) (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 {
|
||||
if capturedKey != nil {
|
||||
*capturedKey = key
|
||||
}
|
||||
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)
|
||||
}
|
||||
if capturedKey != nil {
|
||||
*capturedKey = key
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}, nil
|
||||
}
|
||||
|
||||
type sshDialerType interface {
|
||||
DialContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error)
|
||||
}
|
||||
@@ -362,3 +465,59 @@ func validateSudoers() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetHostKey removes all known_hosts entries for the given host from
|
||||
// certpaths.KnownHostsPath() (REQ-059, D-046, AD-029). It rewrites the
|
||||
// file atomically via security.WriteAtomic. LOCAL ONLY — it does NOT
|
||||
// touch the remote host's authorized_keys (D-046). The next connect
|
||||
// re-pins the host key via TOFU (T02.6) or the --host-key-fingerprint
|
||||
// pinned path (T02.5).
|
||||
//
|
||||
// A line matches when its first whitespace-delimited field (the host
|
||||
// pattern, normalized via knownhosts.Normalize) equals the normalized
|
||||
// target host. Comment/blank lines are preserved.
|
||||
func ResetHostKey(host string) error {
|
||||
if host == "" {
|
||||
return fmt.Errorf("ResetHostKey: host is required")
|
||||
}
|
||||
path := certpaths.KnownHostsPath()
|
||||
existing, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // nothing to reset
|
||||
}
|
||||
return fmt.Errorf("ResetHostKey: read known_hosts: %w", err)
|
||||
}
|
||||
target := knownhosts.Normalize(host)
|
||||
var kept []byte
|
||||
removed := 0
|
||||
for _, line := range strings.Split(string(existing), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
kept = append(kept, []byte(line+"\n")...)
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) == 0 {
|
||||
kept = append(kept, []byte(line+"\n")...)
|
||||
continue
|
||||
}
|
||||
if knownhosts.Normalize(fields[0]) == target {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, []byte(line+"\n")...)
|
||||
}
|
||||
if removed == 0 {
|
||||
return nil
|
||||
}
|
||||
// Ensure the kept buffer ends with exactly one trailing newline.
|
||||
kept = bytes.TrimRight(kept, "\n")
|
||||
if len(kept) > 0 {
|
||||
kept = append(kept, '\n')
|
||||
}
|
||||
if err := security.WriteAtomic(path, 0o600, kept); err != nil {
|
||||
return fmt.Errorf("ResetHostKey: rewrite known_hosts: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,16 +3,22 @@ package proxmox
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
|
||||
"git.cloudinit.dev/coreci/orca/internal/security"
|
||||
)
|
||||
|
||||
func TestSudoersContent(t *testing.T) {
|
||||
@@ -488,3 +494,555 @@ 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)
|
||||
|
||||
var captured ssh.PublicKey
|
||||
cb, err := pinnedHostKeyCallback(expectedFP, &captured)
|
||||
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)
|
||||
}
|
||||
if !bytes.Equal(captured.Marshal(), hostKey.Marshal()) {
|
||||
t.Error("captured key does not match server host key")
|
||||
}
|
||||
}
|
||||
|
||||
// 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=", nil)
|
||||
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", nil)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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, nil)
|
||||
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, nil)
|
||||
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, nil)
|
||||
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, nil)
|
||||
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, nil)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapProxmox_PopulatesHostKeyFingerprint verifies that after
|
||||
// a successful bootstrap via TOFU, Result.HostKeyFingerprint is
|
||||
// non-empty and SHA256:-prefixed (T02.7).
|
||||
func TestBootstrapProxmox_PopulatesHostKeyFingerprint(t *testing.T) {
|
||||
srv := newFakeSSHServer(t)
|
||||
defer srv.close()
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", home)
|
||||
if err := os.WriteFile(filepath.Join(home, "known_hosts"), []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("create known_hosts: %v", err)
|
||||
}
|
||||
|
||||
orig := sshDialer
|
||||
defer func() { sshDialer = orig }()
|
||||
origRunner := sessionRunner
|
||||
defer func() { sessionRunner = origRunner }()
|
||||
sessionRunner = nil
|
||||
// Use the real dialer so the TOFU HostKeyCallback actually runs
|
||||
// against the fake server (a static dialer with an insecure client
|
||||
// would bypass the callback and leave HostKeyFingerprint empty).
|
||||
sshDialer = defaultSSHDialer{}
|
||||
|
||||
host, port, _ := net.SplitHostPort(srv.addr())
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
|
||||
result, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapProxmox: %v", err)
|
||||
}
|
||||
if result.HostKeyFingerprint == "" {
|
||||
t.Fatal("Result.HostKeyFingerprint is empty")
|
||||
}
|
||||
if !strings.HasPrefix(result.HostKeyFingerprint, "SHA256:") {
|
||||
t.Errorf("Result.HostKeyFingerprint = %q, want SHA256: prefix", result.HostKeyFingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetHostKey_RemovesTargetLines verifies that ResetHostKey
|
||||
// removes all known_hosts lines for the target host while leaving
|
||||
// other hosts' lines intact (T02.8, REQ-059, D-046).
|
||||
func TestResetHostKey_RemovesTargetLines(t *testing.T) {
|
||||
home := setupORCAHome(t)
|
||||
path := filepath.Join(home, "known_hosts")
|
||||
original := []byte("[10.0.0.1]:22 ssh-ed25519 AAAAKEY1 host1\n" +
|
||||
"10.0.0.1 ssh-ed25519 AAAAKEY1ALT host1-alt\n" +
|
||||
"[10.0.0.2]:22 ssh-ed25519 AAAAKEY2 host2\n")
|
||||
if err := os.WriteFile(path, original, 0o600); err != nil {
|
||||
t.Fatalf("write known_hosts: %v", err)
|
||||
}
|
||||
|
||||
if err := ResetHostKey("10.0.0.1"); err != nil {
|
||||
t.Fatalf("ResetHostKey: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read known_hosts: %v", err)
|
||||
}
|
||||
result := string(data)
|
||||
if strings.Contains(result, "AAAAKEY1") {
|
||||
t.Errorf("target host key line not removed: %s", result)
|
||||
}
|
||||
if strings.Contains(result, "AAAAKEY1ALT") {
|
||||
t.Errorf("target host alt key line not removed: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "AAAAKEY2") {
|
||||
t.Errorf("other host's line was removed (should be intact): %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetHostKey_NoMatchingLinesIsNoop verifies that ResetHostKey is
|
||||
// a no-op when no lines match (T02.8).
|
||||
func TestResetHostKey_NoMatchingLinesIsNoop(t *testing.T) {
|
||||
home := setupORCAHome(t)
|
||||
path := filepath.Join(home, "known_hosts")
|
||||
original := []byte("[10.0.0.2]:22 ssh-ed25519 AAAAKEY2 host2\n")
|
||||
if err := os.WriteFile(path, original, 0o600); err != nil {
|
||||
t.Fatalf("write known_hosts: %v", err)
|
||||
}
|
||||
|
||||
if err := ResetHostKey("10.0.0.99"); err != nil {
|
||||
t.Fatalf("ResetHostKey: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read known_hosts: %v", err)
|
||||
}
|
||||
if string(data) != string(original) {
|
||||
t.Errorf("known_hosts changed on no-match: got %q, want %q", data, original)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetHostKey_MissingFileIsNoop verifies ResetHostKey returns nil
|
||||
// when known_hosts does not exist (T02.8).
|
||||
func TestResetHostKey_MissingFileIsNoop(t *testing.T) {
|
||||
setupORCAHome(t)
|
||||
if err := ResetHostKey("10.0.0.1"); err != nil {
|
||||
t.Errorf("ResetHostKey on missing file should be no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetHostKey_EmptyHostErrors verifies ResetHostKey rejects an
|
||||
// empty host (T02.8).
|
||||
func TestResetHostKey_EmptyHostErrors(t *testing.T) {
|
||||
if err := ResetHostKey(""); err == nil {
|
||||
t.Error("expected error for empty host, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// bootstrapE2ESetup wires the real dialer against a fake SSH server so
|
||||
// the full HostKeyCallback path (pinned or TOFU) runs end-to-end through
|
||||
// BootstrapProxmox. Returns the host, port, and server (for fingerprint
|
||||
// computation). The known_hosts file is created empty in the temp
|
||||
// ORCA_HOME.
|
||||
func bootstrapE2ESetup(t *testing.T) (srv *fakeSSHServer, host, port string) {
|
||||
t.Helper()
|
||||
srv = newFakeSSHServer(t)
|
||||
t.Cleanup(srv.close)
|
||||
home := t.TempDir()
|
||||
t.Setenv("ORCA_HOME", home)
|
||||
if err := os.WriteFile(filepath.Join(home, "known_hosts"), []byte{}, 0o600); err != nil {
|
||||
t.Fatalf("create known_hosts: %v", err)
|
||||
}
|
||||
orig := sshDialer
|
||||
t.Cleanup(func() { sshDialer = orig })
|
||||
origRunner := sessionRunner
|
||||
t.Cleanup(func() { sessionRunner = origRunner })
|
||||
sessionRunner = nil
|
||||
sshDialer = defaultSSHDialer{}
|
||||
host, port, _ = net.SplitHostPort(srv.addr())
|
||||
return srv, host, port
|
||||
}
|
||||
|
||||
// TestBootstrapE2E_PinnedFingerprintCorrect verifies that
|
||||
// --host-key-fingerprint with the correct pin (T02.10 case 1) succeeds
|
||||
// end-to-end and Result.HostKeyFingerprint equals the pinned value.
|
||||
func TestBootstrapE2E_PinnedFingerprintCorrect(t *testing.T) {
|
||||
srv, host, port := bootstrapE2ESetup(t)
|
||||
hostKey := srv.hostPublicKey()
|
||||
if hostKey == nil {
|
||||
t.Fatal("server host key is nil")
|
||||
}
|
||||
pin := security.SSHFingerprintSHA256(hostKey)
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
|
||||
result, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
HostKeyFingerprint: pin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapProxmox with correct pin: %v", err)
|
||||
}
|
||||
if result.HostKeyFingerprint != pin {
|
||||
t.Errorf("Result.HostKeyFingerprint = %q, want %q (pinned value)",
|
||||
result.HostKeyFingerprint, pin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapE2E_PinnedFingerprintWrong verifies that
|
||||
// --host-key-fingerprint with a wrong pin (T02.10 case 2) fails fast
|
||||
// with the REQ-058 mismatch error, before any SSH session commands run.
|
||||
func TestBootstrapE2E_PinnedFingerprintWrong(t *testing.T) {
|
||||
_, host, port := bootstrapE2ESetup(t)
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
wrong := "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
_, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
HostKeyFingerprint: wrong,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong pin, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "REQ-058") {
|
||||
t.Errorf("error should mention REQ-058, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapE2E_TOFUFirstConnectCapturesKey verifies that with no
|
||||
// --host-key-fingerprint on a first connect (empty known_hosts) (T02.10
|
||||
// case 3) the TOFU callback captures the key, writes known_hosts, and
|
||||
// bootstrap succeeds — exercised end-to-end through BootstrapProxmox.
|
||||
func TestBootstrapE2E_TOFUFirstConnectCapturesKey(t *testing.T) {
|
||||
srv, host, port := bootstrapE2ESetup(t)
|
||||
hostKey := srv.hostPublicKey()
|
||||
if hostKey == nil {
|
||||
t.Fatal("server host key is nil")
|
||||
}
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
home := os.Getenv("ORCA_HOME")
|
||||
knownHostsPath := filepath.Join(home, "known_hosts")
|
||||
|
||||
before, _ := os.ReadFile(knownHostsPath)
|
||||
if len(before) != 0 {
|
||||
t.Fatalf("precondition: known_hosts not empty: %q", before)
|
||||
}
|
||||
|
||||
result, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapProxmox first connect: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(knownHostsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read known_hosts: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("known_hosts empty — TOFU did not capture the key end-to-end")
|
||||
}
|
||||
expectedFP := security.SSHFingerprintSHA256(hostKey)
|
||||
if result.HostKeyFingerprint != expectedFP {
|
||||
t.Errorf("Result.HostKeyFingerprint = %q, want %q", result.HostKeyFingerprint, expectedFP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapE2E_TOFUSecondConnectMatches verifies that a second
|
||||
// connect (known_hosts already has the key from the first connect)
|
||||
// (T02.10 case 4) matches and succeeds end-to-end.
|
||||
func TestBootstrapE2E_TOFUSecondConnectMatches(t *testing.T) {
|
||||
srv, host, port := bootstrapE2ESetup(t)
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
sessionRunner = nil
|
||||
if _, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
}); err != nil {
|
||||
t.Fatalf("bootstrap run %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
_ = srv
|
||||
}
|
||||
|
||||
// TestBootstrapE2E_TOFUMismatchFails verifies that when known_hosts has
|
||||
// a different key (T02.10 case 5) the second connect fails with a
|
||||
// mismatch (MITM detection) — end-to-end through BootstrapProxmox.
|
||||
func TestBootstrapE2E_TOFUMismatchFails(t *testing.T) {
|
||||
srv, host, port := bootstrapE2ESetup(t)
|
||||
hostKey := srv.hostPublicKey()
|
||||
if hostKey == nil {
|
||||
t.Fatal("server host key is nil")
|
||||
}
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
home := os.Getenv("ORCA_HOME")
|
||||
knownHostsPath := filepath.Join(home, "known_hosts")
|
||||
|
||||
altPub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519 gen: %v", err)
|
||||
}
|
||||
altKey, err := ssh.NewPublicKey(altPub)
|
||||
if err != nil {
|
||||
t.Fatalf("new pub: %v", err)
|
||||
}
|
||||
addr := host + ":" + port
|
||||
altLine := knownhosts.Line([]string{knownhosts.Normalize(addr)}, altKey)
|
||||
if err := os.WriteFile(knownHostsPath, []byte(altLine+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write known_hosts: %v", err)
|
||||
}
|
||||
|
||||
_, err = BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected MITM/mismatch error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ssh dial") {
|
||||
t.Errorf("error should mention ssh dial, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapE2E_PrePopulatedKnownHostsMatches verifies the v0.6→v0.8
|
||||
// migration path (T02.10 case 7): a known_hosts entry written by a prior
|
||||
// join (simulating a v0.6 install) is matched on second-connect without
|
||||
// re-capture, end-to-end through BootstrapProxmox.
|
||||
func TestBootstrapE2E_PrePopulatedKnownHostsMatches(t *testing.T) {
|
||||
srv, host, port := bootstrapE2ESetup(t)
|
||||
hostKey := srv.hostPublicKey()
|
||||
if hostKey == nil {
|
||||
t.Fatal("server host key is nil")
|
||||
}
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
home := os.Getenv("ORCA_HOME")
|
||||
knownHostsPath := filepath.Join(home, "known_hosts")
|
||||
|
||||
addr := host + ":" + port
|
||||
preLine := knownhosts.Line([]string{knownhosts.Normalize(addr)}, hostKey)
|
||||
if err := os.WriteFile(knownHostsPath, []byte(preLine+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write known_hosts: %v", err)
|
||||
}
|
||||
|
||||
result, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapProxmox on pre-populated known_hosts: %v", err)
|
||||
}
|
||||
expectedFP := security.SSHFingerprintSHA256(hostKey)
|
||||
if result.HostKeyFingerprint != expectedFP {
|
||||
t.Errorf("Result.HostKeyFingerprint = %q, want %q", result.HostKeyFingerprint, expectedFP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapE2E_KeyResetThenRePin verifies T02.10 case 6: after
|
||||
// ResetHostKey removes the known_hosts entry, the next BootstrapProxmox
|
||||
// connect re-pins the key via TOFU and succeeds end-to-end. The reset
|
||||
// target is the known_hosts entry key (host:port, normalized), which
|
||||
// matches how the cli resolves the host from a proxmox node's address
|
||||
// for non-default ports.
|
||||
func TestBootstrapE2E_KeyResetThenRePin(t *testing.T) {
|
||||
srv, host, port := bootstrapE2ESetup(t)
|
||||
portNum, _ := strconv.Atoi(port)
|
||||
home := os.Getenv("ORCA_HOME")
|
||||
knownHostsPath := filepath.Join(home, "known_hosts")
|
||||
addr := host + ":" + port
|
||||
|
||||
// First connect: TOFU captures + writes known_hosts.
|
||||
sessionRunner = nil
|
||||
if _, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
}); err != nil {
|
||||
t.Fatalf("first bootstrap: %v", err)
|
||||
}
|
||||
before, _ := os.ReadFile(knownHostsPath)
|
||||
if len(before) == 0 {
|
||||
t.Fatal("precondition: known_hosts empty after first connect")
|
||||
}
|
||||
|
||||
// Reset: known_hosts entry removed. Pass the full addr (host:port)
|
||||
// so Normalize produces the same bracketed form the TOFU callback
|
||||
// wrote for a non-default port.
|
||||
if err := ResetHostKey(addr); err != nil {
|
||||
t.Fatalf("ResetHostKey: %v", err)
|
||||
}
|
||||
after, _ := os.ReadFile(knownHostsPath)
|
||||
if strings.Contains(string(after), knownhosts.Normalize(addr)) {
|
||||
t.Fatalf("known_hosts still contains host after reset: %q", after)
|
||||
}
|
||||
|
||||
// Next connect re-pins via TOFU + succeeds.
|
||||
sessionRunner = nil
|
||||
if _, err := BootstrapProxmox(t.Context(), Options{
|
||||
Host: host,
|
||||
Password: "pw",
|
||||
SSHPort: portNum,
|
||||
}); err != nil {
|
||||
t.Fatalf("re-pin bootstrap after reset: %v", err)
|
||||
}
|
||||
rePinned, _ := os.ReadFile(knownHostsPath)
|
||||
if !strings.Contains(string(rePinned), knownhosts.Normalize(addr)) {
|
||||
t.Fatalf("known_hosts not re-populated on next connect: %q", rePinned)
|
||||
}
|
||||
_ = srv
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+15
-12
@@ -121,10 +121,10 @@ func CAInit(dir, commonName string) (*CA, error) {
|
||||
|
||||
// Atomic write: temp file + rename. This avoids leaving a half-written
|
||||
// ca.key on disk if the process crashes mid-write.
|
||||
if err := writeAtomic(certPath, CACPEMMode, certPEM); err != nil {
|
||||
if err := WriteAtomic(certPath, CACPEMMode, certPEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeAtomic(keyPath, CAMode, keyPEM); err != nil {
|
||||
if err := WriteAtomic(keyPath, CAMode, keyPEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -290,23 +290,26 @@ func bothExist(paths ...string) (bool, error) {
|
||||
// WriteCert writes a cert PEM blob to path with mode 0644 atomically.
|
||||
// REQ-033 requires cert files to be 0644; this helper enforces that.
|
||||
func WriteCert(path string, pemBytes []byte) error {
|
||||
return writeAtomic(path, CACPEMMode, pemBytes)
|
||||
return WriteAtomic(path, CACPEMMode, pemBytes)
|
||||
}
|
||||
|
||||
// WriteKey writes a private-key PEM blob to path with mode 0600
|
||||
// atomically. REQ-033 requires key files to be 0600; this helper
|
||||
// enforces that.
|
||||
func WriteKey(path string, pemBytes []byte) error {
|
||||
return writeAtomic(path, CAMode, pemBytes)
|
||||
return WriteAtomic(path, CAMode, pemBytes)
|
||||
}
|
||||
|
||||
// writeAtomic writes data to a temp file in dir and renames. Sets the
|
||||
// WriteAtomic writes data to a temp file in dir and renames. Sets the
|
||||
// requested perm before the rename so the file lands at the right mode.
|
||||
func writeAtomic(path string, mode os.FileMode, data []byte) error {
|
||||
// Exported (AD-029) so the key-reset / known_hosts atomic rewrite path
|
||||
// in proxmox (T02.6/T02.7) can reuse it instead of duplicating the
|
||||
// ~20-LOC pattern (RESEARCH §5 pitfall #10).
|
||||
func WriteAtomic(path string, mode os.FileMode, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("writeAtomic: create temp: %w", err)
|
||||
return fmt.Errorf("WriteAtomic: create temp: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
// Best-effort cleanup if we fail before rename.
|
||||
@@ -315,21 +318,21 @@ func writeAtomic(path string, mode os.FileMode, data []byte) error {
|
||||
}()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("writeAtomic: write: %w", err)
|
||||
return fmt.Errorf("WriteAtomic: write: %w", err)
|
||||
}
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("writeAtomic: chmod: %w", err)
|
||||
return fmt.Errorf("WriteAtomic: chmod: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("writeAtomic: sync: %w", err)
|
||||
return fmt.Errorf("WriteAtomic: sync: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("writeAtomic: close: %w", err)
|
||||
return fmt.Errorf("WriteAtomic: close: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("writeAtomic: rename: %w", err)
|
||||
return fmt.Errorf("WriteAtomic: rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -26,6 +26,17 @@ const (
|
||||
sshPubFile = "orca_ssh_key.pub"
|
||||
)
|
||||
|
||||
// SSHFingerprintSHA256 returns the canonical SSH public-key fingerprint
|
||||
// in the form `SHA256:base64` (no trailing padding), as produced by
|
||||
// `ssh-keygen -lf` and OpenSSH's host-key verification prompts. This is
|
||||
// a thin wrapper over ssh.FingerprintSHA256 (AD-027) for use by the
|
||||
// proxmox bootstrap pinned-host-key callback (REQ-058) and any other
|
||||
// SSH-domain identity checks. Do NOT reuse security.Fingerprint — that
|
||||
// returns an X.509 DER hex digest (different domain; RESEARCH §2.2).
|
||||
func SSHFingerprintSHA256(pubKey ssh.PublicKey) string {
|
||||
return ssh.FingerprintSHA256(pubKey)
|
||||
}
|
||||
|
||||
// GenerateOrLoadSSHKey returns the orca SSH keypair, generating it
|
||||
// lazily on first call (D-037). The key is Ed25519 (smaller, faster,
|
||||
// more secure than RSA for SSH auth), persisted as PKCS8 PEM to
|
||||
@@ -84,10 +95,10 @@ func GenerateOrLoadSSHKey(dir string) (keyPEM, pubLine []byte, err error) {
|
||||
pubLine = ssh.MarshalAuthorizedKey(sshPub)
|
||||
|
||||
// Persist with correct modes (atomic write + chmod).
|
||||
if err := writeAtomic(keyPath, SSHKeyMode, keyPEM); err != nil {
|
||||
if err := WriteAtomic(keyPath, SSHKeyMode, keyPEM); err != nil {
|
||||
return nil, nil, fmt.Errorf("write SSH key: %w", err)
|
||||
}
|
||||
if err := writeAtomic(pubPath, SSHPubMode, pubLine); err != nil {
|
||||
if err := WriteAtomic(pubPath, SSHPubMode, pubLine); err != nil {
|
||||
return nil, nil, fmt.Errorf("write SSH pub: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -91,3 +93,43 @@ func TestGenerateOrLoadSSHKey_CreatesDir(t *testing.T) {
|
||||
t.Errorf("nested dir not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHFingerprintSHA256_Ed25519(t *testing.T) {
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519 gen: %v", err)
|
||||
}
|
||||
sshPub, err := ssh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("new pubkey: %v", err)
|
||||
}
|
||||
|
||||
got := SSHFingerprintSHA256(sshPub)
|
||||
|
||||
// Canonical form: SHA256: followed by unpadded base64.
|
||||
if !strings.HasPrefix(got, "SHA256:") {
|
||||
t.Fatalf("fingerprint = %q, want SHA256: prefix", got)
|
||||
}
|
||||
// Must match the reference implementation exactly.
|
||||
want := ssh.FingerprintSHA256(sshPub)
|
||||
if got != want {
|
||||
t.Errorf("SSHFingerprintSHA256 = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHFingerprintSHA256_StableAcrossCalls(t *testing.T) {
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519 gen: %v", err)
|
||||
}
|
||||
sshPub, err := ssh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("new pubkey: %v", err)
|
||||
}
|
||||
|
||||
a := SSHFingerprintSHA256(sshPub)
|
||||
b := SSHFingerprintSHA256(sshPub)
|
||||
if a != b {
|
||||
t.Errorf("fingerprint not stable: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user