From 856a90d2a1fb14ac64da7ff0fbffef1909047eba Mon Sep 17 00:00:00 2001 From: Christopher Jones <56381413+ntwrknrd@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:25:48 -0400 Subject: [PATCH] feat(hostkey): make tofu trust first-seen keys and add prompt shortcuts host_key_policy: tofu only changed Accept once, so every new host still prompted. It now adds a first-seen key to known_hosts without asking and prints the fingerprint; changed keys still prompt. The new-key prompt also accepts Enter to reject, o to accept once, and a to accept always. --- internal/config/example_config.yaml | 2 + internal/connect/hostkey.go | 8 +- internal/connect/hostkey_prepare.go | 15 +++- internal/connect/hostkey_prepare_test.go | 39 ++++++++++ internal/ui/prompts.go | 73 +++++++++++++++++++ internal/ui/prompts_test.go | 25 +++++++ .../connections-logs-troubleshooting.md | 12 ++- 7 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 internal/ui/prompts_test.go diff --git a/internal/config/example_config.yaml b/internal/config/example_config.yaml index 4869470..dfa0af5 100644 --- a/internal/config/example_config.yaml +++ b/internal/config/example_config.yaml @@ -220,6 +220,8 @@ ssh: # 0s disables idle disconnects. idle_timeout: 0s security: + # pin prompts for every new host key; tofu trusts first-seen keys and + # still prompts when a known key changes. host_key_policy: pin accept_once_mode: pin compat_persist_probes: false diff --git a/internal/connect/hostkey.go b/internal/connect/hostkey.go index 327c5ad..ca127ae 100644 --- a/internal/connect/hostkey.go +++ b/internal/connect/hostkey.go @@ -23,11 +23,11 @@ func promptNewHostKey(prompt connector.HostKeyPrompt) connector.HostKeyAction { fmt.Print(renderNewHostKeyPromptText(prompt)) options := []string{ - "Reject (disconnect)", - "Accept once (this session only)", - "Accept always (add to known_hosts)", + "Reject (disconnect) [enter]", + "Accept once (this session only) [o]", + "Accept always (add to known_hosts) [a]", } - idx, err := ui.SelectIndex("Host key verification", options, prompt.Stdin) + idx, err := ui.SelectIndexWithShortcuts("Host key verification", options, map[rune]int{'o': 1, 'a': 2}, prompt.Stdin) if err != nil || idx < 0 { return connector.HostKeyReject } diff --git a/internal/connect/hostkey_prepare.go b/internal/connect/hostkey_prepare.go index 2efdea3..7f567b5 100644 --- a/internal/connect/hostkey_prepare.go +++ b/internal/connect/hostkey_prepare.go @@ -31,7 +31,9 @@ type scannedHostKey struct { var negotiatedHostKeyRE = regexp.MustCompile(`(?m)Server host key: ([^\s]+) (SHA256:[A-Za-z0-9+/=]+)`) func runHostKeyPreparation(ctx context.Context, resolved *ResolvedHost, sshArgs []string, cfg *config.Config, opts Options, changed bool, proxyEnv []string) (*connector.HostKeyPreparation, error) { - if opts.capture != nil && opts.capture.HostKeyPrompt == nil { + // TOFU trusts a host's first key without asking; a changed key still prompts. + tofu := !changed && hostKeyPolicyTOFU(cfg) + if opts.capture != nil && opts.capture.HostKeyPrompt == nil && !tofu { return nil, fmt.Errorf("host %s requires interactive host-key verification", resolved.Hostname) } slog.Debug("preparing host key", "host", resolved.Hostname) @@ -41,6 +43,13 @@ func runHostKeyPreparation(ctx context.Context, resolved *ResolvedHost, sshArgs } slog.Debug("host key scanned", "host", resolved.Hostname, "key_type", key.KeyType, "fingerprint", key.Fingerprint) decision := func() (*connector.HostKeyPreparation, error) { + if tofu { + if err := appendKnownHosts(key.Line); err != nil { + return nil, err + } + fmt.Fprintf(os.Stderr, "nssh: trusted new host key for %s (%s %s)\n", resolved.Hostname, key.KeyType, key.Fingerprint) + return nil, nil + } promptFn := hostKeyPromptFunc() if opts.capture != nil { promptFn = opts.capture.HostKeyPrompt @@ -87,6 +96,10 @@ func runHostKeyPreparation(ctx context.Context, resolved *ResolvedHost, sshArgs return decision() } +func hostKeyPolicyTOFU(cfg *config.Config) bool { + return cfg != nil && strings.EqualFold(strings.TrimSpace(cfg.SSH.Security.HostKeyPolicy), "tofu") +} + func writeTemporaryKnownHosts(line, algorithm string) (*connector.HostKeyPreparation, error) { file, err := os.CreateTemp("", "nssh-known-hosts-*") if err != nil { diff --git a/internal/connect/hostkey_prepare_test.go b/internal/connect/hostkey_prepare_test.go index 3f0afdf..e7b39b9 100644 --- a/internal/connect/hostkey_prepare_test.go +++ b/internal/connect/hostkey_prepare_test.go @@ -434,3 +434,42 @@ func TestRunHostKeyPreparationRejectsHostKey(t *testing.T) { t.Fatalf("prep = %+v, want nil", prep) } } + +func TestRunHostKeyPreparationTOFUTrustsNewKeyWithoutPrompt(t *testing.T) { + oldPrompt := hostKeyPromptFunc + oldScan := scanHostKeyFunc + defer func() { + hostKeyPromptFunc = oldPrompt + scanHostKeyFunc = oldScan + }() + + home := t.TempDir() + t.Setenv("HOME", home) + line := "edge01 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBcLcMSBE8+TJuxrHFujWBrOCcXrl+/sTqONstg2Jcg7" + prompted := false + hostKeyPromptFunc = func() connector.HostKeyPromptFunc { + return func(connector.HostKeyPrompt) connector.HostKeyAction { + prompted = true + return connector.HostKeyReject + } + } + scanHostKeyFunc = func(context.Context, *ResolvedHost, []string, *config.Config, Options, []string) (scannedHostKey, error) { + return scannedHostKey{KeyType: "ssh-ed25519", Algorithm: "ssh-ed25519", Fingerprint: "SHA256:test", Line: line}, nil + } + cfg := config.DefaultConfig() + cfg.SSH.Security.HostKeyPolicy = "tofu" + resolved := &ResolvedHost{Hostname: "edge01", Port: 22} + + prep, err := runHostKeyPreparation(context.Background(), resolved, nil, cfg, Options{}, false, nil) + if err != nil || prep != nil || prompted { + t.Fatalf("new key: prep=%v err=%v prompted=%v", prep, err, prompted) + } + data, err := os.ReadFile(filepath.Join(home, ".ssh", "known_hosts")) + if err != nil || string(data) != line+"\n" { + t.Fatalf("known_hosts = %q, err %v", data, err) + } + + if _, err := runHostKeyPreparation(context.Background(), resolved, nil, cfg, Options{}, true, nil); err == nil || !prompted { + t.Fatalf("changed key: err=%v prompted=%v, want prompt and reject", err, prompted) + } +} diff --git a/internal/ui/prompts.go b/internal/ui/prompts.go index 2ae6e39..b8c20c7 100644 --- a/internal/ui/prompts.go +++ b/internal/ui/prompts.go @@ -4,7 +4,9 @@ import ( "errors" "fmt" "io" + "unicode" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) @@ -143,6 +145,77 @@ func SelectIndex(title string, options []string, input io.Reader) (int, error) { return selected, nil } +// SelectIndexWithShortcuts shows a selection list whose options can also be +// chosen with a single key. Enter selects the highlighted option, which starts +// on the first one. Filtering is disabled so letter keys stay shortcuts. +func SelectIndexWithShortcuts(title string, options []string, shortcuts map[rune]int, input io.Reader) (int, error) { + if len(options) == 0 { + return -1, nil + } + + var selected int + huhOpts := make([]huh.Option[int], len(options)) + for i, opt := range options { + huhOpts[i] = huh.NewOption(opt, i) + } + sel := huh.NewSelect[int](). + Title(title). + Options(huhOpts...). + Filtering(false). + Value(&selected) + form := huh.NewForm(huh.NewGroup(sel)).WithTheme(huhTheme()) + form.SubmitCmd = tea.Quit + form.CancelCmd = tea.Interrupt + + model := &shortcutSelect{form: form, shortcuts: shortcuts, chosen: -1} + teaOpts := []tea.ProgramOption{} + if input != nil { + teaOpts = append(teaOpts, tea.WithInput(input)) + } + if _, err := tea.NewProgram(model, teaOpts...).Run(); err != nil { + if errors.Is(err, tea.ErrInterrupted) { + return -1, huh.ErrUserAborted + } + return -1, err + } + if model.chosen >= 0 { + return model.chosen, nil + } + if form.State != huh.StateCompleted { + return -1, huh.ErrUserAborted + } + return selected, nil +} + +type shortcutSelect struct { + form *huh.Form + shortcuts map[rune]int + chosen int +} + +func (m *shortcutSelect) Init() tea.Cmd { return m.form.Init() } + +func (m *shortcutSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok && key.Type == tea.KeyRunes && len(key.Runes) == 1 { + if idx, ok := m.shortcuts[unicode.ToLower(key.Runes[0])]; ok { + m.chosen = idx + return m, tea.Quit + } + } + model, cmd := m.form.Update(msg) + if form, ok := model.(*huh.Form); ok { + m.form = form + } + return m, cmd +} + +func (m *shortcutSelect) View() string { + if m.chosen >= 0 || m.form.State != huh.StateNormal { + return "" + } + return m.form.View() +} + // Confirm shows a yes/no confirmation prompt. // Returns the user's choice. func Confirm(title string, defaultValue bool) (bool, error) { diff --git a/internal/ui/prompts_test.go b/internal/ui/prompts_test.go new file mode 100644 index 0000000..5442655 --- /dev/null +++ b/internal/ui/prompts_test.go @@ -0,0 +1,25 @@ +package ui + +import ( + "strings" + "testing" +) + +func TestSelectIndexWithShortcuts(t *testing.T) { + options := []string{"Reject", "Accept once", "Accept always"} + shortcuts := map[rune]int{'o': 1, 'a': 2} + for input, want := range map[string]int{ + "\r": 0, // enter selects the default first option + "o": 1, + "A": 2, + "j\r": 1, // arrow-style navigation still works + } { + got, err := SelectIndexWithShortcuts("Host key verification", options, shortcuts, strings.NewReader(input)) + if err != nil { + t.Fatalf("input %q: %v", input, err) + } + if got != want { + t.Fatalf("input %q: got %d, want %d", input, got, want) + } + } +} diff --git a/skills/nssh/references/connections-logs-troubleshooting.md b/skills/nssh/references/connections-logs-troubleshooting.md index 1ff160e..d02b15f 100644 --- a/skills/nssh/references/connections-logs-troubleshooting.md +++ b/skills/nssh/references/connections-logs-troubleshooting.md @@ -170,9 +170,15 @@ ssh: OpenSSH trust-on-first-use behavior. Password-backed setup scans the presented key before using the target credential; a managed password-backed proxy may use its own credential to reach the target during that scan. New-key prompts offer -reject, accept once, or accept always. Changed-key prompts label both acceptance -choices dangerous; accept-always removes the stale entry and writes the verified -replacement only after explicit confirmation. +reject, accept once, or accept always; Enter rejects, `o` accepts once, and `a` +accepts always. Changed-key prompts have no single-key shortcuts and label both +acceptance choices dangerous; accept-always removes the stale entry and writes +the verified replacement only after explicit confirmation. + +`host_key_policy = "tofu"` trusts a host's first key without prompting: nssh +adds the scanned key to `~/.ssh/known_hosts` and prints its fingerprint. A +changed key still prompts. The preset also sets `accept_once_mode` to +`accept-new` and persists compatibility probes. On recognized legacy SSH algorithm failures, nssh can select supported algorithm floors and persist them under the owning provider YAML host