From 4b996032a9bf3c5cf60b92d12f26e66d51a4e5f4 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Thu, 28 May 2026 13:38:53 +0800 Subject: [PATCH] fix: improve vault and key passphrase flow --- README.md | 254 ++++++++++++++------------------- cmd/root.go | 33 ++--- cmd/root_test.go | 30 ++++ cmd/vault.go | 54 ++++--- cmd/vault_test.go | 17 +++ internal/config/config.go | 46 +++--- internal/config/config_test.go | 23 +++ internal/ssh/command.go | 20 ++- internal/ssh/command_test.go | 83 +++++++++++ 9 files changed, 354 insertions(+), 206 deletions(-) create mode 100644 cmd/root_test.go create mode 100644 internal/config/config_test.go create mode 100644 internal/ssh/command_test.go diff --git a/README.md b/README.md index 47a2dde..16bd8ac 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,22 @@ # sshkeeper -Консольный менеджер SSH-подключений для Linux. Управляет профилями серверов, секретами и запускает SSH-сессии через системный OpenSSH. +`sshkeeper` is a Linux console manager for SSH profiles, secrets, and quick +OpenSSH launches. It does not replace OpenSSH; it keeps connection metadata in a +local SQLite database, keeps passwords/passphrases in an encrypted vault, and +starts the system `ssh` client with the right options. -**sshkeeper не заменяет OpenSSH.** Он управляет профилями подключений, секретами и удобным запуском SSH-сессий. +## Features -## Возможности +- Bubble Tea TUI for daily interactive use. +- CLI commands for scripting and quick edits. +- Encrypted vault for SSH passwords and key passphrases. +- Password and key-passphrase auth through a PTY prompt handler, without putting + secrets in command-line arguments. +- Key, SSH-agent, password, and key+passphrase auth modes. +- Groups, tags, command templates, search, and OpenSSH config generation. +- Import from `~/.ssh/config`. -- **TUI-интерфейс** на Bubble Tea — интерактивный терминальный интерфейс -- **CLI-команды** для скриптов и быстрых операций -- **Encrypted vault** (Argon2id + XChaCha20-Poly1305) для хранения паролей -- **Парольная авторизация** через PTY-wrapper (без передачи пароля в argv) -- **Подключение по ключу**, SSH-agent, key+passphrase -- **Группы и теги** для организации серверов -- **Шаблоны команд** для частых задач -- **Генерация OpenSSH config** из профилей -- **Импорт из ~/.ssh/config** -- **Тестирование подключения** без сохранения - -## Установка - -### Из исходников +## Install ```bash git clone https://git.mirv.top/mirivlad/sshkeeper.git @@ -27,177 +24,140 @@ cd sshkeeper go build -o ~/.local/bin/sshkeeper . ``` -Требования: Go 1.25+, Linux x86_64 +Requirements: Go 1.25+, Linux x86_64, system OpenSSH. -## Быстрый старт +## First Run + +Run the TUI or any command. On the first run, `sshkeeper` creates its config, +database, and vault, then asks for a master password. ```bash -# Первый запуск — создание vault и мастер-пароля -sshkeeper init - -# Или сразу запустить TUI (vault создастся автоматически) sshkeeper +``` -# Добавить сервер -sshkeeper add myserver --host 10.0.0.1 --user admin --auth key +You can also initialize explicitly: -# Добавить сервер с паролем -sshkeeper add prod-web --host 10.0.0.5 --user deploy --auth password --password +```bash +sshkeeper init +``` -# Показать список серверов +## Common CLI Commands + +```bash +# Add profiles +sshkeeper add web --host 10.0.0.10 --user deploy --auth key +sshkeeper add prod --host 10.0.0.20 --user root --auth password +sshkeeper add bastion --host bastion.example.org --user admin --auth key_passphrase --identity-file ~/.ssh/id_rsa + +# Inspect profiles sshkeeper list +sshkeeper show web +sshkeeper search prod -# Подключиться к серверу -sshkeeper connect myserver -sshkeeper c myserver +# Connect and test +sshkeeper connect web +sshkeeper c web +sshkeeper test web +sshkeeper run web "uptime" -# Проверить подключение -sshkeeper test myserver - -# Запустить команду на сервере -sshkeeper run myserver "uptime" - -# Группы +# Groups and templates sshkeeper group list +sshkeeper template list web -# Редактировать сервер -sshkeeper edit myserver --host 10.0.0.2 - -# Удалить сервер -sshkeeper delete myserver - -# Импорт из ~/.ssh/config -sshkeeper import - -# Сгенерировать OpenSSH config +# OpenSSH config sshkeeper ssh-config generate sshkeeper ssh-config install-include ``` +Commands that only read profile metadata, such as `list`, `show`, `search`, +`config path`, `group list`, and `export`, do not require the master password. +Commands that need secrets ask for the master password in that process. + ## TUI -Запуск без аргументов открывает интерактивный терминальный интерфейс: +Running `sshkeeper` without arguments opens the TUI. -```bash -sshkeeper -``` +| Key | Action | +| --- | --- | +| Enter | Connect to selected server | +| Ctrl+A | Add server | +| Ctrl+E | Edit server | +| Ctrl+D | Delete server | +| Ctrl+T | Test connection | +| Ctrl+F | Search | +| Ctrl+Q / Ctrl+C | Quit | -Клавиши (работают на любой раскладке — используются Ctrl+комбинации): +In add/edit forms: -| Клавиша | Действие | -|---------|----------| -| Enter | Подключиться к серверу | -| Ctrl+A | Добавить сервер | -| Ctrl+E | Редактировать сервер | -| Ctrl+D | Удалить сервер | -| Ctrl+T | Проверить подключение | -| Ctrl+F | Поиск | -| Ctrl+Q / Ctrl+C | Выход | - -В форме добавления/редактирования: - -| Клавиша | Действие | -|---------|----------| -| Tab/↓ | Следующее поле | -| Shift+Tab/↑ | Предыдущее поле | -| Enter | Перейти к кнопке / активировать | -| Esc | Назад | - -Кнопки **[Test]** и **[Save]**: -- **Test** — проверяет подключение без сохранения -- **Save** — сохраняет профиль (не требует тест) - -## Хранение данных - -XDG-совместимые Пути: - -| Файл | Путь | -|------|------| -| База данных | `~/.local/share/sshkeeper/sshkeeper.db` | -| Vault | `~/.local/share/sshkeeper/vault.bin` | -| Конфиг | `~/.config/sshkeeper/config.toml` | -| SSH config | `~/.ssh/config.d/sshkeeper.conf` | +| Key | Action | +| --- | --- | +| Tab / Down | Next field | +| Shift+Tab / Up | Previous field | +| `/` on Auth Method or Group | Pick from list | +| Enter | Move to action / activate | +| Esc | Back | ## Vault -Vault — зашифрованное хранилище для паролей и passphrase. +The vault stores SSH passwords and key passphrases encrypted on disk. -**Шифрование:** XChaCha20-Poly1305 -**KDF:** Argon2id (4 MiB, 2 iterations) +- Cipher: XChaCha20-Poly1305. +- KDF: Argon2id, currently 64 MiB memory, 3 iterations. +- Existing legacy vault files remain readable. +- Unlock state is process-local. `sshkeeper vault unlock` verifies the master + password, but it does not keep future shell commands unlocked. -При первом запуске sshkeeper создаёт vault и запрашивает мастер-пароль. При последующих запусках — запрашивает мастер-пароль для разблокировки. +Useful commands: ```bash -# Разблокировать vault вручную -sshkeeper vault unlock - -# Заблокировать -sshkeeper vault lock - -# Сменить мастер-пароль -sshkeeper vault change-password - -# Статус sshkeeper vault status +sshkeeper vault unlock +sshkeeper vault list +sshkeeper vault delete [ssh_password|key_passphrase] +sshkeeper vault change-password ``` -## CLI-команды +`vault list`, `vault delete`, and `vault change-password` ask for the master +password themselves because they need to decrypt the vault in the current +process. -``` -sshkeeper TUI (по умолчанию) -sshkeeper add [alias] Добавить сервер -sshkeeper list Список серверов -sshkeeper show Детали сервера -sshkeeper edit Редактировать -sshkeeper delete Удалить -sshkeeper connect Подключиться (c — алиас) -sshkeeper test Проверить подключение -sshkeeper search Поиск -sshkeeper run Выполнить команду -sshkeeper import Импорт из ~/.ssh/config -sshkeeper export Экспорт -sshkeeper group list Группы -sshkeeper vault [subcommand] Управление vault -sshkeeper ssh-config generate Генерация SSH config -``` +## Data Locations -## Сборка +`sshkeeper` uses XDG-style app directories: + +| Data | Default path | +| --- | --- | +| Config | `~/.config/sshkeeper/config.toml` | +| Database | `~/.local/share/sshkeeper/sshkeeper.db` | +| Vault | `~/.local/share/sshkeeper/vault.bin` | +| Generated OpenSSH config | `~/.ssh/config.d/sshkeeper.conf` | + +If `XDG_CONFIG_HOME` or `XDG_DATA_HOME` are set, sshkeeper stores data under +`$XDG_CONFIG_HOME/sshkeeper` and `$XDG_DATA_HOME/sshkeeper`. + +## Build And Test ```bash -# Собрать -make build - -# Установить в ~/.local/bin -make install - -# Запуск без сборки -go run . - -# Тесты (если есть) go test ./... +go build -o bin/sshkeeper . ``` -## Структура проекта +`bin/` is ignored by git. -``` +## Project Layout + +```text sshkeeper/ -├── main.go # Точка входа -├── Makefile # Сборка -├── cmd/ # CLI-команды -│ ├── root.go # Root command, initApp -│ ├── tui.go # TUI launcher -│ ├── add.go, edit.go, ... # Команды -│ └── vault.go # Vault management -├── internal/ -│ ├── config/ # Конфигурация, XDG paths -│ ├── db/ # SQLite, migrations, CRUD -│ ├── model/ # Модели данных -│ ├── vault/ # Encrypted vault (Argon2id + XChaCha20-Poly1305) -│ ├── ssh/ # SSH connect, test, PTY-wrapper, import, configgen -│ └── tui/ # Bubble Tea TUI -└── go.mod +├── cmd/ # Cobra CLI commands and TUI launcher +├── internal/config/ # XDG paths and config loading +├── internal/db/ # SQLite migrations and CRUD +├── internal/model/ # Domain models +├── internal/ssh/ # OpenSSH command building, PTY prompt handling +├── internal/tui/ # Bubble Tea UI +├── internal/vault/ # Encrypted vault +└── main.go ``` -## Лицензия +## License MIT diff --git a/cmd/root.go b/cmd/root.go index 448acbd..e8a125f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,16 +5,16 @@ import ( "os" "syscall" - "github.com/spf13/cobra" "github.com/mirivlad/sshkeeper/internal/config" "github.com/mirivlad/sshkeeper/internal/db" "github.com/mirivlad/sshkeeper/internal/vault" + "github.com/spf13/cobra" "golang.org/x/term" ) var ( - cfg *config.Config - appDB *db.DB + cfg *config.Config + appDB *db.DB ) var rootCmd = &cobra.Command{ @@ -127,9 +127,8 @@ func initApp() { break } } else { - // Vault exists — need to unlock - // Skip unlock for vault commands that handle their own unlock/lock - if isVaultSubcommand() { + // Vault exists — unlock only for commands that may need secrets. + if !commandRequiresStartupVaultUnlock(os.Args[1:]) { vaultInstance = v return } @@ -161,19 +160,21 @@ func initApp() { } } -// isVaultSubcommand checks if the current command is a vault subcommand -func isVaultSubcommand() bool { - args := os.Args[1:] - for _, arg := range args { - if arg == "vault" { - return true - } +func commandRequiresStartupVaultUnlock(args []string) bool { + if len(args) == 0 { + return true } - // Skip for help + for _, arg := range args { if arg == "-h" || arg == "--help" { - return true + return false } } - return false + + switch args[0] { + case "connect", "c", "run", "run-template", "test", "add", "edit", "delete": + return true + default: + return false + } } diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..bfe3044 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,30 @@ +package cmd + +import "testing" + +func TestCommandRequiresStartupVaultUnlock(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + {name: "root tui", args: nil, want: true}, + {name: "connect", args: []string{"connect", "prod"}, want: true}, + {name: "short connect alias", args: []string{"c", "prod"}, want: true}, + {name: "add can store secrets", args: []string{"add", "prod"}, want: true}, + {name: "vault handles its own unlock", args: []string{"vault", "list"}, want: false}, + {name: "list only reads database", args: []string{"list"}, want: false}, + {name: "show only reads database", args: []string{"show", "prod"}, want: false}, + {name: "search only reads database", args: []string{"search", "prod"}, want: false}, + {name: "config path only reads config", args: []string{"config", "path"}, want: false}, + {name: "help", args: []string{"--help"}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := commandRequiresStartupVaultUnlock(tt.args); got != tt.want { + t.Fatalf("commandRequiresStartupVaultUnlock(%v) = %v; want %v", tt.args, got, tt.want) + } + }) + } +} diff --git a/cmd/vault.go b/cmd/vault.go index c85ab83..51f3048 100644 --- a/cmd/vault.go +++ b/cmd/vault.go @@ -28,7 +28,7 @@ var vaultCmd = &cobra.Command{ var vaultUnlockCmd = &cobra.Command{ Use: "unlock", - Short: "Unlock the vault with master password", + Short: "Verify the vault master password", RunE: func(cmd *cobra.Command, args []string) error { v := getOrCreateVault() @@ -69,16 +69,14 @@ var vaultUnlockCmd = &cobra.Command{ return fmt.Errorf("create vault: %w", err) } - // Unlock immediately if err := v.Unlock(string(pw1)); err != nil { return fmt.Errorf("unlock vault: %w", err) } - fmt.Println("Vault created and unlocked.") + fmt.Println("Vault created. Commands will ask for the master password when they need secrets.") return nil } - // Unlock existing vault fmt.Print("Master password: ") pw, err := term.ReadPassword(int(syscall.Stdin)) fmt.Println() @@ -90,7 +88,7 @@ var vaultUnlockCmd = &cobra.Command{ return fmt.Errorf("unlock vault: %w", err) } - fmt.Println("Vault unlocked.") + fmt.Println("Master password accepted. Vault unlock is process-local; commands will ask again when they need secrets.") return nil }, } @@ -111,11 +109,7 @@ var vaultStatusCmd = &cobra.Command{ Short: "Show vault status", RunE: func(cmd *cobra.Command, args []string) error { v := getOrCreateVault() - if v.IsUnlocked() { - fmt.Println("Vault: unlocked") - } else { - fmt.Println("Vault: locked") - } + fmt.Println(formatVaultStatus(v.IsUnlocked(), vault.Exists(config.VaultPath(cfg.DataDir)))) return nil }, } @@ -126,8 +120,8 @@ var vaultChangePasswordCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { v := getOrCreateVault() - if !v.IsUnlocked() { - return fmt.Errorf("vault is locked. Unlock first with 'sshkeeper vault unlock'") + if err := unlockVaultForCommand(v); err != nil { + return err } fmt.Print("New master password: ") @@ -166,8 +160,8 @@ var vaultListCmd = &cobra.Command{ Short: "List stored secret metadata", RunE: func(cmd *cobra.Command, args []string) error { v := getOrCreateVault() - if !v.IsUnlocked() { - return fmt.Errorf("vault is locked. Unlock first with 'sshkeeper vault unlock'") + if err := unlockVaultForCommand(v); err != nil { + return err } output, err := formatVaultSecretsList(v) if err != nil { @@ -190,8 +184,8 @@ var vaultDeleteCmd = &cobra.Command{ } v := getOrCreateVault() - if !v.IsUnlocked() { - return fmt.Errorf("vault is locked. Unlock first with 'sshkeeper vault unlock'") + if err := unlockVaultForCommand(v); err != nil { + return err } if err := deleteVaultSecrets(v, alias, secretType); err != nil { return err @@ -208,6 +202,34 @@ var vaultDeleteCmd = &cobra.Command{ }, } +func unlockVaultForCommand(v *vault.Vault) error { + if v.IsUnlocked() { + return nil + } + + fmt.Print("Master password: ") + pw, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return fmt.Errorf("read password: %w", err) + } + + if err := v.Unlock(string(pw)); err != nil { + return fmt.Errorf("unlock vault: %w", err) + } + return nil +} + +func formatVaultStatus(unlocked bool, exists bool) string { + if !exists { + return "Vault: not found" + } + if unlocked { + return "Vault: unlocked in current process" + } + return "Vault: locked (vault commands unlock per command)" +} + func formatVaultSecretsList(v *vault.Vault) (string, error) { metas, err := v.ListSecrets() if err != nil { diff --git a/cmd/vault_test.go b/cmd/vault_test.go index b84829f..8072b3e 100644 --- a/cmd/vault_test.go +++ b/cmd/vault_test.go @@ -38,3 +38,20 @@ func TestFormatVaultSecretsListHandlesEmptyVault(t *testing.T) { t.Fatalf("expected empty output message, got:\n%s", output) } } + +func TestFormatVaultStatusExplainsProcessLocalUnlock(t *testing.T) { + locked := formatVaultStatus(false, true) + if !strings.Contains(locked, "locked") || !strings.Contains(locked, "per command") { + t.Fatalf("expected locked status to explain per-command unlock, got %q", locked) + } + + unlocked := formatVaultStatus(true, true) + if !strings.Contains(unlocked, "unlocked") || !strings.Contains(unlocked, "current process") { + t.Fatalf("expected unlocked status to mention current process, got %q", unlocked) + } + + missing := formatVaultStatus(false, false) + if !strings.Contains(missing, "not found") { + t.Fatalf("expected missing status, got %q", missing) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 24504d6..c0fea49 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,9 +8,9 @@ import ( ) type Config struct { - SSH SSHConfig `toml:"ssh"` + SSH SSHConfig `toml:"ssh"` Vault VaultConfig `toml:"vault"` - UI UIConfig `toml:"ui"` + UI UIConfig `toml:"ui"` // resolved paths ConfigDir string `toml:"-"` @@ -18,9 +18,9 @@ type Config struct { } type SSHConfig struct { - Binary string `toml:"binary"` - ConnectTimeoutSec int `toml:"connect_timeout_seconds"` - TestCommand string `toml:"test_command"` + Binary string `toml:"binary"` + ConnectTimeoutSec int `toml:"connect_timeout_seconds"` + TestCommand string `toml:"test_command"` } type VaultConfig struct { @@ -50,25 +50,11 @@ func defaultConfig() *Config { func Load() (*Config, error) { cfg := defaultConfig() - // XDG paths - configDir := os.Getenv("XDG_CONFIG_HOME") - if configDir == "" { - home, err := os.UserHomeDir() - if err != nil { - return nil, err - } - configDir = filepath.Join(home, ".config", "sshkeeper") + configDir, dataDir, err := resolveDirs(os.Getenv("XDG_CONFIG_HOME"), os.Getenv("XDG_DATA_HOME")) + if err != nil { + return nil, err } cfg.ConfigDir = configDir - - dataDir := os.Getenv("XDG_DATA_HOME") - if dataDir == "" { - home, err := os.UserHomeDir() - if err != nil { - return nil, err - } - dataDir = filepath.Join(home, ".local", "share", "sshkeeper") - } cfg.DataDir = dataDir // Ensure dirs exist @@ -104,3 +90,19 @@ func Load() (*Config, error) { return cfg, nil } + +func resolveDirs(configRoot, dataRoot string) (string, string, error) { + if configRoot == "" || dataRoot == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", "", err + } + if configRoot == "" { + configRoot = filepath.Join(home, ".config") + } + if dataRoot == "" { + dataRoot = filepath.Join(home, ".local", "share") + } + } + return filepath.Join(configRoot, "sshkeeper"), filepath.Join(dataRoot, "sshkeeper"), nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..684404f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,23 @@ +package config + +import ( + "path/filepath" + "testing" +) + +func TestResolveDirsUsesAppSubdirectoriesUnderXDGRoots(t *testing.T) { + configRoot := filepath.Join(t.TempDir(), "config") + dataRoot := filepath.Join(t.TempDir(), "data") + + configDir, dataDir, err := resolveDirs(configRoot, dataRoot) + if err != nil { + t.Fatalf("resolve dirs: %v", err) + } + + if configDir != filepath.Join(configRoot, "sshkeeper") { + t.Fatalf("config dir = %q; want app dir under XDG config root", configDir) + } + if dataDir != filepath.Join(dataRoot, "sshkeeper") { + t.Fatalf("data dir = %q; want app dir under XDG data root", dataDir) + } +} diff --git a/internal/ssh/command.go b/internal/ssh/command.go index 7aa088f..dc38fc3 100644 --- a/internal/ssh/command.go +++ b/internal/ssh/command.go @@ -24,12 +24,14 @@ func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error return ConnectWithPassword(cfg.SSH.Binary, args, password) case model.AuthKeyPassphrase: - // For key+passphrase, let ssh-agent handle it or prompt normally - // TODO: use ssh-agent or similar - fallthrough + passphrase, err := getVault(server.Alias, "key_passphrase") + if err != nil { + return fmt.Errorf("get key passphrase from vault: %w", err) + } + return ConnectWithPassword(cfg.SSH.Binary, args, passphrase) default: - // key, agent, key+passphrase - direct execution + // key and agent auth use direct OpenSSH execution. cmd := exec.Command(cfg.SSH.Binary, args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -56,8 +58,16 @@ func Test(cfg *config.Config, server *model.Server, getVault VaultFunc) (bool, s } return testWithPassword(cfg, args, password) + case model.AuthKeyPassphrase: + args = append(args, "-o", "NumberOfPasswordPrompts=1") + passphrase, err := getVault(server.Alias, "key_passphrase") + if err != nil { + return false, fmt.Sprintf("vault error: %v", err) + } + return testWithPassword(cfg, args, passphrase) + default: - // key, agent, key+passphrase + // key and agent auth should not prompt during tests. args = append(args, "-o", "BatchMode=yes") args = append(args, cfg.SSH.TestCommand) diff --git a/internal/ssh/command_test.go b/internal/ssh/command_test.go new file mode 100644 index 0000000..5746ece --- /dev/null +++ b/internal/ssh/command_test.go @@ -0,0 +1,83 @@ +package ssh + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mirivlad/sshkeeper/internal/config" + "github.com/mirivlad/sshkeeper/internal/model" +) + +func TestKeyPassphraseTestUsesVaultSecret(t *testing.T) { + script := filepath.Join(t.TempDir(), "fake-ssh") + if err := os.WriteFile(script, []byte(`#!/bin/sh +printf 'Enter passphrase for key: ' +IFS= read -r passphrase +if [ "$passphrase" = "key-secret" ]; then + echo SSHKEEPER_OK + exit 0 +fi +echo denied +exit 1 +`), 0o700); err != nil { + t.Fatalf("write fake ssh: %v", err) + } + + cfg := &config.Config{ + SSH: config.SSHConfig{ + Binary: script, + ConnectTimeoutSec: 2, + TestCommand: "echo SSHKEEPER_OK", + }, + } + server := &model.Server{ + Alias: "prod", + Host: "example.org", + Port: 22, + User: "root", + AuthMethod: model.AuthKeyPassphrase, + IdentityFile: "/tmp/test-key", + } + + ok, errText := Test(cfg, server, func(alias string, secretType string) (string, error) { + if alias != "prod" || secretType != "key_passphrase" { + return "", fmt.Errorf("unexpected secret lookup %s %s", alias, secretType) + } + return "key-secret", nil + }) + + if !ok { + t.Fatalf("expected key passphrase test to pass, error: %s", errText) + } +} + +func TestKeyPassphraseTestReportsVaultError(t *testing.T) { + cfg := &config.Config{ + SSH: config.SSHConfig{ + Binary: "ssh", + ConnectTimeoutSec: 1, + TestCommand: "echo SSHKEEPER_OK", + }, + } + server := &model.Server{ + Alias: "prod", + Host: "example.org", + Port: 22, + User: "root", + AuthMethod: model.AuthKeyPassphrase, + } + + ok, errText := Test(cfg, server, func(alias string, secretType string) (string, error) { + return "", fmt.Errorf("missing secret") + }) + + if ok { + t.Fatal("expected key passphrase test to fail when vault lookup fails") + } + if !strings.Contains(errText, "vault error: missing secret") { + t.Fatalf("expected vault error, got %q", errText) + } +}