Compare commits

..

3 Commits

14 changed files with 616 additions and 244 deletions

254
README.md
View File

@ -1,25 +1,22 @@
# sshkeeper # 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 — интерактивный терминальный интерфейс ## Install
- **CLI-команды** для скриптов и быстрых операций
- **Encrypted vault** (Argon2id + XChaCha20-Poly1305) для хранения паролей
- **Парольная авторизация** через PTY-wrapper (без передачи пароля в argv)
- **Подключение по ключу**, SSH-agent, key+passphrase
- **Группы и теги** для организации серверов
- **Шаблоны команд** для частых задач
- **Генерация OpenSSH config** из профилей
- **Импорт из ~/.ssh/config**
- **Тестирование подключения** без сохранения
## Установка
### Из исходников
```bash ```bash
git clone https://git.mirv.top/mirivlad/sshkeeper.git git clone https://git.mirv.top/mirivlad/sshkeeper.git
@ -27,177 +24,140 @@ cd sshkeeper
go build -o ~/.local/bin/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 ```bash
# Первый запуск — создание vault и мастер-пароля
sshkeeper init
# Или сразу запустить TUI (vault создастся автоматически)
sshkeeper sshkeeper
```
# Добавить сервер You can also initialize explicitly:
sshkeeper add myserver --host 10.0.0.1 --user admin --auth key
# Добавить сервер с паролем ```bash
sshkeeper add prod-web --host 10.0.0.5 --user deploy --auth password --password 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 list
sshkeeper show web
sshkeeper search prod
# Подключиться к серверу # Connect and test
sshkeeper connect myserver sshkeeper connect web
sshkeeper c myserver sshkeeper c web
sshkeeper test web
sshkeeper run web "uptime"
# Проверить подключение # Groups and templates
sshkeeper test myserver
# Запустить команду на сервере
sshkeeper run myserver "uptime"
# Группы
sshkeeper group list sshkeeper group list
sshkeeper template list web
# Редактировать сервер # OpenSSH config
sshkeeper edit myserver --host 10.0.0.2
# Удалить сервер
sshkeeper delete myserver
# Импорт из ~/.ssh/config
sshkeeper import
# Сгенерировать OpenSSH config
sshkeeper ssh-config generate sshkeeper ssh-config generate
sshkeeper ssh-config install-include 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 ## TUI
Запуск без аргументов открывает интерактивный терминальный интерфейс: Running `sshkeeper` without arguments opens the TUI.
```bash | Key | Action |
sshkeeper | --- | --- |
``` | 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:
| Клавиша | Действие | | Key | Action |
|---------|----------| | --- | --- |
| Enter | Подключиться к серверу | | Tab / Down | Next field |
| Ctrl+A | Добавить сервер | | Shift+Tab / Up | Previous field |
| Ctrl+E | Редактировать сервер | | `/` on Auth Method or Group | Pick from list |
| Ctrl+D | Удалить сервер | | Enter | Move to action / activate |
| Ctrl+T | Проверить подключение | | Esc | Back |
| 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` |
## Vault ## Vault
Vault — зашифрованное хранилище для паролей и passphrase. The vault stores SSH passwords and key passphrases encrypted on disk.
**Шифрование:** XChaCha20-Poly1305 - Cipher: XChaCha20-Poly1305.
**KDF:** Argon2id (4 MiB, 2 iterations) - 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 ```bash
# Разблокировать vault вручную
sshkeeper vault unlock
# Заблокировать
sshkeeper vault lock
# Сменить мастер-пароль
sshkeeper vault change-password
# Статус
sshkeeper vault status sshkeeper vault status
sshkeeper vault unlock
sshkeeper vault list
sshkeeper vault delete <alias> [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.
``` ## Data Locations
sshkeeper TUI (по умолчанию)
sshkeeper add [alias] Добавить сервер
sshkeeper list Список серверов
sshkeeper show <alias> Детали сервера
sshkeeper edit <alias> Редактировать
sshkeeper delete <alias> Удалить
sshkeeper connect <alias> Подключиться (c — алиас)
sshkeeper test <alias> Проверить подключение
sshkeeper search <query> Поиск
sshkeeper run <alias> <cmd> Выполнить команду
sshkeeper import Импорт из ~/.ssh/config
sshkeeper export Экспорт
sshkeeper group list Группы
sshkeeper vault [subcommand] Управление vault
sshkeeper ssh-config generate Генерация SSH config
```
## Сборка `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 ```bash
# Собрать
make build
# Установить в ~/.local/bin
make install
# Запуск без сборки
go run .
# Тесты (если есть)
go test ./... go test ./...
go build -o bin/sshkeeper .
``` ```
## Структура проекта `bin/` is ignored by git.
``` ## Project Layout
```text
sshkeeper/ sshkeeper/
├── main.go # Точка входа ├── cmd/ # Cobra CLI commands and TUI launcher
├── Makefile # Сборка ├── internal/config/ # XDG paths and config loading
├── cmd/ # CLI-команды ├── internal/db/ # SQLite migrations and CRUD
│ ├── root.go # Root command, initApp ├── internal/model/ # Domain models
│ ├── tui.go # TUI launcher ├── internal/ssh/ # OpenSSH command building, PTY prompt handling
│ ├── add.go, edit.go, ... # Команды ├── internal/tui/ # Bubble Tea UI
│ └── vault.go # Vault management ├── internal/vault/ # Encrypted vault
├── internal/ └── main.go
│ ├── 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
``` ```
## Лицензия ## License
MIT MIT

View File

@ -5,16 +5,16 @@ import (
"os" "os"
"syscall" "syscall"
"github.com/spf13/cobra"
"github.com/mirivlad/sshkeeper/internal/config" "github.com/mirivlad/sshkeeper/internal/config"
"github.com/mirivlad/sshkeeper/internal/db" "github.com/mirivlad/sshkeeper/internal/db"
"github.com/mirivlad/sshkeeper/internal/vault" "github.com/mirivlad/sshkeeper/internal/vault"
"github.com/spf13/cobra"
"golang.org/x/term" "golang.org/x/term"
) )
var ( var (
cfg *config.Config cfg *config.Config
appDB *db.DB appDB *db.DB
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@ -127,9 +127,8 @@ func initApp() {
break break
} }
} else { } else {
// Vault exists — need to unlock // Vault exists — unlock only for commands that may need secrets.
// Skip unlock for vault commands that handle their own unlock/lock if !commandRequiresStartupVaultUnlock(os.Args[1:]) {
if isVaultSubcommand() {
vaultInstance = v vaultInstance = v
return return
} }
@ -161,19 +160,21 @@ func initApp() {
} }
} }
// isVaultSubcommand checks if the current command is a vault subcommand func commandRequiresStartupVaultUnlock(args []string) bool {
func isVaultSubcommand() bool { if len(args) == 0 {
args := os.Args[1:] return true
for _, arg := range args {
if arg == "vault" {
return true
}
} }
// Skip for help
for _, arg := range args { for _, arg := range args {
if arg == "-h" || arg == "--help" { 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
}
} }

30
cmd/root_test.go Normal file
View File

@ -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)
}
})
}
}

View File

@ -28,7 +28,7 @@ var vaultCmd = &cobra.Command{
var vaultUnlockCmd = &cobra.Command{ var vaultUnlockCmd = &cobra.Command{
Use: "unlock", Use: "unlock",
Short: "Unlock the vault with master password", Short: "Verify the vault master password",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
v := getOrCreateVault() v := getOrCreateVault()
@ -69,16 +69,14 @@ var vaultUnlockCmd = &cobra.Command{
return fmt.Errorf("create vault: %w", err) return fmt.Errorf("create vault: %w", err)
} }
// Unlock immediately
if err := v.Unlock(string(pw1)); err != nil { if err := v.Unlock(string(pw1)); err != nil {
return fmt.Errorf("unlock vault: %w", err) 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 return nil
} }
// Unlock existing vault
fmt.Print("Master password: ") fmt.Print("Master password: ")
pw, err := term.ReadPassword(int(syscall.Stdin)) pw, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() fmt.Println()
@ -90,7 +88,7 @@ var vaultUnlockCmd = &cobra.Command{
return fmt.Errorf("unlock vault: %w", err) 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 return nil
}, },
} }
@ -111,11 +109,7 @@ var vaultStatusCmd = &cobra.Command{
Short: "Show vault status", Short: "Show vault status",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
v := getOrCreateVault() v := getOrCreateVault()
if v.IsUnlocked() { fmt.Println(formatVaultStatus(v.IsUnlocked(), vault.Exists(config.VaultPath(cfg.DataDir))))
fmt.Println("Vault: unlocked")
} else {
fmt.Println("Vault: locked")
}
return nil return nil
}, },
} }
@ -126,8 +120,8 @@ var vaultChangePasswordCmd = &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
v := getOrCreateVault() v := getOrCreateVault()
if !v.IsUnlocked() { if err := unlockVaultForCommand(v); err != nil {
return fmt.Errorf("vault is locked. Unlock first with 'sshkeeper vault unlock'") return err
} }
fmt.Print("New master password: ") fmt.Print("New master password: ")
@ -166,8 +160,8 @@ var vaultListCmd = &cobra.Command{
Short: "List stored secret metadata", Short: "List stored secret metadata",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
v := getOrCreateVault() v := getOrCreateVault()
if !v.IsUnlocked() { if err := unlockVaultForCommand(v); err != nil {
return fmt.Errorf("vault is locked. Unlock first with 'sshkeeper vault unlock'") return err
} }
output, err := formatVaultSecretsList(v) output, err := formatVaultSecretsList(v)
if err != nil { if err != nil {
@ -190,8 +184,8 @@ var vaultDeleteCmd = &cobra.Command{
} }
v := getOrCreateVault() v := getOrCreateVault()
if !v.IsUnlocked() { if err := unlockVaultForCommand(v); err != nil {
return fmt.Errorf("vault is locked. Unlock first with 'sshkeeper vault unlock'") return err
} }
if err := deleteVaultSecrets(v, alias, secretType); err != nil { if err := deleteVaultSecrets(v, alias, secretType); err != nil {
return err 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) { func formatVaultSecretsList(v *vault.Vault) (string, error) {
metas, err := v.ListSecrets() metas, err := v.ListSecrets()
if err != nil { if err != nil {

View File

@ -38,3 +38,20 @@ func TestFormatVaultSecretsListHandlesEmptyVault(t *testing.T) {
t.Fatalf("expected empty output message, got:\n%s", output) 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)
}
}

View File

@ -15,7 +15,7 @@
**Files:** **Files:**
- Modify: `internal/tui/app_test.go` - Modify: `internal/tui/app_test.go`
- [ ] **Step 1: Add a test for a constrained terminal height** - [x] **Step 1: Add a test for a constrained terminal height**
Add a test that creates more servers than can fit on screen, sets a small terminal size, renders the list, and verifies the selected details and footer are still visible. Add a test that creates more servers than can fit on screen, sets a small terminal size, renders the list, and verifies the selected details and footer are still visible.
@ -54,7 +54,7 @@ func TestServerListViewKeepsDetailsVisibleWithManyServers(t *testing.T) {
} }
``` ```
- [ ] **Step 2: Run the focused test and confirm it fails** - [x] **Step 2: Run the focused test and confirm it fails**
Run: Run:
@ -70,7 +70,7 @@ Expected: FAIL because the current table renders every server and can push the d
- Modify: `internal/tui/app.go` - Modify: `internal/tui/app.go`
- Modify: `internal/tui/app_test.go` - Modify: `internal/tui/app_test.go`
- [ ] **Step 1: Add focused tests for visible range calculation** - [x] **Step 1: Add focused tests for visible range calculation**
Add tests for a helper that computes the inclusive start and exclusive end indexes for rendered rows. Add tests for a helper that computes the inclusive start and exclusive end indexes for rendered rows.
@ -101,7 +101,7 @@ func TestVisibleServerRangeKeepsSelectionInsideWindow(t *testing.T) {
} }
``` ```
- [ ] **Step 2: Implement `visibleServerRange`** - [x] **Step 2: Implement `visibleServerRange`**
Add a small helper near `selectedServer`. Add a small helper near `selectedServer`.
@ -133,7 +133,7 @@ func visibleServerRange(total, selected, available int) (int, int) {
} }
``` ```
- [ ] **Step 3: Run helper tests** - [x] **Step 3: Run helper tests**
Run: Run:
@ -149,7 +149,7 @@ Expected: PASS.
- Modify: `internal/tui/app.go` - Modify: `internal/tui/app.go`
- Modify: `internal/tui/app_test.go` - Modify: `internal/tui/app_test.go`
- [ ] **Step 1: Reserve terminal space for fixed UI blocks** - [x] **Step 1: Reserve terminal space for fixed UI blocks**
Add a helper that decides how many server rows may be rendered while keeping the selected details and footer visible. Add a helper that decides how many server rows may be rendered while keeping the selected details and footer visible.
@ -168,7 +168,7 @@ func (m *tuiModel) visibleServerRows() int {
} }
``` ```
- [ ] **Step 2: Use the visible range in `viewServerList`** - [x] **Step 2: Use the visible range in `viewServerList`**
In `viewServerList`, replace the loop over all servers with a bounded loop: In `viewServerList`, replace the loop over all servers with a bounded loop:
@ -189,7 +189,7 @@ if len(m.servers) > end-start {
} }
``` ```
- [ ] **Step 3: Run long-list regression test** - [x] **Step 3: Run long-list regression test**
Run: Run:
@ -204,7 +204,7 @@ Expected: PASS.
**Files:** **Files:**
- Modify: `internal/tui/app_test.go` - Modify: `internal/tui/app_test.go`
- [ ] **Step 1: Add a test for moving selection beyond the first window** - [x] **Step 1: Add a test for moving selection beyond the first window**
Use the existing `m.list.Update` path by sending `tea.KeyDown` messages and confirm the rendered window follows the selected server. Use the existing `m.list.Update` path by sending `tea.KeyDown` messages and confirm the rendered window follows the selected server.
@ -240,7 +240,7 @@ func TestServerListViewScrollsWithSelection(t *testing.T) {
} }
``` ```
- [ ] **Step 2: Run TUI tests** - [x] **Step 2: Run TUI tests**
Run: Run:
@ -255,7 +255,7 @@ Expected: PASS.
**Files:** **Files:**
- No source edits expected. - No source edits expected.
- [ ] **Step 1: Run the full test suite** - [x] **Step 1: Run the full test suite**
Run: Run:
@ -265,7 +265,7 @@ env GOCACHE=/tmp/sshkeeper-go-cache go test ./...
Expected: all packages pass. Expected: all packages pass.
- [ ] **Step 2: Rebuild the project binary** - [x] **Step 2: Rebuild the project binary**
Run: Run:
@ -275,7 +275,7 @@ env GOCACHE=/tmp/sshkeeper-go-cache go build -o bin/sshkeeper .
Expected: exit code 0 and updated `bin/sshkeeper`. Expected: exit code 0 and updated `bin/sshkeeper`.
- [ ] **Step 3: Commit the implementation** - [x] **Step 3: Commit the implementation**
Run: Run:

View File

@ -8,9 +8,9 @@ import (
) )
type Config struct { type Config struct {
SSH SSHConfig `toml:"ssh"` SSH SSHConfig `toml:"ssh"`
Vault VaultConfig `toml:"vault"` Vault VaultConfig `toml:"vault"`
UI UIConfig `toml:"ui"` UI UIConfig `toml:"ui"`
// resolved paths // resolved paths
ConfigDir string `toml:"-"` ConfigDir string `toml:"-"`
@ -18,9 +18,9 @@ type Config struct {
} }
type SSHConfig struct { type SSHConfig struct {
Binary string `toml:"binary"` Binary string `toml:"binary"`
ConnectTimeoutSec int `toml:"connect_timeout_seconds"` ConnectTimeoutSec int `toml:"connect_timeout_seconds"`
TestCommand string `toml:"test_command"` TestCommand string `toml:"test_command"`
} }
type VaultConfig struct { type VaultConfig struct {
@ -50,25 +50,11 @@ func defaultConfig() *Config {
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := defaultConfig() cfg := defaultConfig()
// XDG paths configDir, dataDir, err := resolveDirs(os.Getenv("XDG_CONFIG_HOME"), os.Getenv("XDG_DATA_HOME"))
configDir := os.Getenv("XDG_CONFIG_HOME") if err != nil {
if configDir == "" { return nil, err
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
configDir = filepath.Join(home, ".config", "sshkeeper")
} }
cfg.ConfigDir = configDir 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 cfg.DataDir = dataDir
// Ensure dirs exist // Ensure dirs exist
@ -104,3 +90,19 @@ func Load() (*Config, error) {
return cfg, nil 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
}

View File

@ -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)
}
}

View File

@ -24,12 +24,14 @@ func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error
return ConnectWithPassword(cfg.SSH.Binary, args, password) return ConnectWithPassword(cfg.SSH.Binary, args, password)
case model.AuthKeyPassphrase: case model.AuthKeyPassphrase:
// For key+passphrase, let ssh-agent handle it or prompt normally passphrase, err := getVault(server.Alias, "key_passphrase")
// TODO: use ssh-agent or similar if err != nil {
fallthrough return fmt.Errorf("get key passphrase from vault: %w", err)
}
return ConnectWithPassword(cfg.SSH.Binary, args, passphrase)
default: default:
// key, agent, key+passphrase - direct execution // key and agent auth use direct OpenSSH execution.
cmd := exec.Command(cfg.SSH.Binary, args...) cmd := exec.Command(cfg.SSH.Binary, args...)
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout 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) 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: default:
// key, agent, key+passphrase // key and agent auth should not prompt during tests.
args = append(args, "-o", "BatchMode=yes") args = append(args, "-o", "BatchMode=yes")
args = append(args, cfg.SSH.TestCommand) args = append(args, cfg.SSH.TestCommand)

View File

@ -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)
}
}

View File

@ -417,7 +417,9 @@ func (m *tuiModel) viewServerList() string {
b.WriteString(helpStyle.Render(" No servers yet. Press Ctrl+A to add one.")) b.WriteString(helpStyle.Render(" No servers yet. Press Ctrl+A to add one."))
b.WriteString("\n") b.WriteString("\n")
} else { } else {
for _, server := range m.servers { selectedIndex := m.list.Index()
start, end := visibleServerRange(len(m.servers), selectedIndex, m.visibleServerRows())
for _, server := range m.servers[start:end] {
marker := " " marker := " "
rowStyle := normalStyle rowStyle := normalStyle
if server.Alias == selectedAlias { if server.Alias == selectedAlias {
@ -445,6 +447,10 @@ func (m *tuiModel) viewServerList() string {
b.WriteString(rowStyle.Render(row)) b.WriteString(rowStyle.Render(row))
b.WriteString("\n") b.WriteString("\n")
} }
if len(m.servers) > end-start {
b.WriteString(helpStyle.Render(fmt.Sprintf(" Showing %d-%d of %d", start+1, end, len(m.servers))))
b.WriteString("\n")
}
} }
b.WriteString("\n") b.WriteString("\n")
@ -465,6 +471,45 @@ func (m *tuiModel) selectedServer() *model.Server {
return nil return nil
} }
func (m *tuiModel) visibleServerRows() int {
if m.height <= 0 {
return len(m.servers)
}
const fixedRows = 16
rows := m.height - fixedRows
if rows < 3 {
return 3
}
return rows
}
func visibleServerRange(total, selected, available int) (int, int) {
if total <= 0 || available <= 0 {
return 0, 0
}
if available >= total {
return 0, total
}
if selected < 0 {
selected = 0
}
if selected >= total {
selected = total - 1
}
start := selected - available + 1
if start < 0 {
start = 0
}
end := start + available
if end > total {
end = total
start = end - available
}
return start, end
}
func (m *tuiModel) viewSelectedServer(server *model.Server) string { func (m *tuiModel) viewSelectedServer(server *model.Server) string {
displayName := server.DisplayName displayName := server.DisplayName
if displayName == "" { if displayName == "" {

View File

@ -1,6 +1,7 @@
package tui package tui
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -68,6 +69,94 @@ func TestServerListViewUsesDashboardLayout(t *testing.T) {
} }
} }
func TestServerListViewKeepsDetailsVisibleWithManyServers(t *testing.T) {
servers := make([]*model.Server, 45)
for i := range servers {
servers[i] = &model.Server{
Alias: fmt.Sprintf("server-%02d", i+1),
DisplayName: fmt.Sprintf("Server %02d", i+1),
Host: fmt.Sprintf("host-%02d.example.org", i+1),
Port: 22,
User: "mirivlad",
AuthMethod: model.AuthKey,
LastTestStatus: model.TestUnknown,
}
}
m := New(servers)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 18})
model := updated.(*tuiModel)
view := model.View()
if !strings.Contains(view, "Server 01") {
t.Fatalf("expected first selected server to be visible:\n%s", view)
}
if !strings.Contains(view, "Selected") {
t.Fatalf("expected selected server details to remain visible:\n%s", view)
}
if !strings.Contains(view, "Enter connect") {
t.Fatalf("expected footer to remain visible:\n%s", view)
}
if count := strings.Count(view, "server-"); count >= len(servers) {
t.Fatalf("expected bounded row rendering, rendered %d server aliases", count)
}
}
func TestVisibleServerRangeKeepsSelectionInsideWindow(t *testing.T) {
tests := []struct {
name string
total int
selected int
available int
wantStart int
wantEnd int
}{
{name: "first page", total: 40, selected: 0, available: 10, wantStart: 0, wantEnd: 10},
{name: "middle page", total: 40, selected: 20, available: 10, wantStart: 11, wantEnd: 21},
{name: "last page", total: 40, selected: 39, available: 10, wantStart: 30, wantEnd: 40},
{name: "all fit", total: 5, selected: 3, available: 10, wantStart: 0, wantEnd: 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, end := visibleServerRange(tt.total, tt.selected, tt.available)
if start != tt.wantStart || end != tt.wantEnd {
t.Fatalf("visibleServerRange() = %d, %d; want %d, %d", start, end, tt.wantStart, tt.wantEnd)
}
})
}
}
func TestServerListViewScrollsWithSelection(t *testing.T) {
servers := make([]*model.Server, 45)
for i := range servers {
servers[i] = &model.Server{
Alias: fmt.Sprintf("server-%02d", i+1),
DisplayName: fmt.Sprintf("Server %02d", i+1),
Host: fmt.Sprintf("host-%02d.example.org", i+1),
Port: 22,
User: "mirivlad",
AuthMethod: model.AuthKey,
}
}
m := New(servers)
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 18})
model := updated.(*tuiModel)
for i := 0; i < 20; i++ {
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = updated.(*tuiModel)
}
view := model.View()
if !strings.Contains(view, "Server 21") {
t.Fatalf("expected selected server to be visible after navigation:\n%s", view)
}
if !strings.Contains(view, "Showing") {
t.Fatalf("expected range hint for long server list:\n%s", view)
}
}
func TestEscClosesGroupListBeforeLeavingForm(t *testing.T) { func TestEscClosesGroupListBeforeLeavingForm(t *testing.T) {
oldGetGroups := GetGroups oldGetGroups := GetGroups
GetGroups = func() ([]string, error) { GetGroups = func() ([]string, error) {

View File

@ -61,6 +61,11 @@ type secretRecord struct {
plaintext []byte plaintext []byte
} }
type derivedKey struct {
key []byte
legacy bool
}
type SecretMeta struct { type SecretMeta struct {
ID string ID string
Alias string Alias string
@ -164,22 +169,19 @@ func (v *Vault) Unlock(masterPassword string) error {
return fmt.Errorf("decode salt: %w", err) return fmt.Errorf("decode salt: %w", err)
} }
key := argon2.IDKey([]byte(masterPassword), salt, uint32(vf.KDF.Iterations), uint32(vf.KDF.MemoryKiB), uint8(vf.KDF.Parallelism), keyLen) candidate, err := deriveValidKey([]byte(masterPassword), salt, vf)
if err != nil {
return err
}
key := candidate.key
if vf.Verifier != nil { if len(vf.Records) == 0 && vf.Verifier == nil {
if err := verifyRecord(key, *vf.Verifier); err != nil {
return fmt.Errorf("invalid master password")
}
} else if len(vf.Records) > 0 {
if _, err := decryptRecord(key, vf.Records[0]); err != nil {
return fmt.Errorf("invalid master password")
}
} else {
return fmt.Errorf("vault cannot verify master password; recreate empty vault") return fmt.Errorf("vault cannot verify master password; recreate empty vault")
} }
v.masterKey = key v.masterKey = key
v.records = make(map[string]secretRecord) v.records = make(map[string]secretRecord)
v.modified = candidate.legacy
for _, rec := range vf.Records { for _, rec := range vf.Records {
plaintext, err := decryptRecord(key, rec) plaintext, err := decryptRecord(key, rec)
@ -531,6 +533,44 @@ func decryptRecord(key []byte, rec Record) ([]byte, error) {
return plaintext, nil return plaintext, nil
} }
func deriveValidKey(masterPassword, salt []byte, vf VaultFile) (derivedKey, error) {
key := deriveKey(masterPassword, salt, vf.KDF.MemoryKiB, vf.KDF.Iterations, vf.KDF.Parallelism)
if canDecryptVaultFile(key, vf) {
return derivedKey{key: key}, nil
}
clearBytes(key)
if shouldTryLegacyKDF(vf) {
legacyMemoryKiB := vf.KDF.MemoryKiB * 1024
key = deriveKey(masterPassword, salt, legacyMemoryKiB, vf.KDF.Iterations, vf.KDF.Parallelism)
if canDecryptVaultFile(key, vf) {
return derivedKey{key: key, legacy: true}, nil
}
clearBytes(key)
}
return derivedKey{}, fmt.Errorf("invalid master password")
}
func shouldTryLegacyKDF(vf VaultFile) bool {
return vf.Verifier == nil && vf.KDF.MemoryKiB > 0 && vf.KDF.MemoryKiB <= 4096
}
func deriveKey(masterPassword, salt []byte, memoryKiB, iterations, parallelism int) []byte {
return argon2.IDKey(masterPassword, salt, uint32(iterations), uint32(memoryKiB), uint8(parallelism), keyLen)
}
func canDecryptVaultFile(key []byte, vf VaultFile) bool {
if vf.Verifier != nil {
return verifyRecord(key, *vf.Verifier) == nil
}
if len(vf.Records) > 0 {
_, err := decryptRecord(key, vf.Records[0])
return err == nil
}
return false
}
func newVerifierRecord(key []byte) (Record, error) { func newVerifierRecord(key []byte) (Record, error) {
rec, err := encryptRecord(key, verifierID, []byte(verifierPlaintext)) rec, err := encryptRecord(key, verifierID, []byte(verifierPlaintext))
if err != nil { if err != nil {
@ -568,22 +608,22 @@ func VerifyPassword(path string, masterPassword string) (bool, error) {
return false, err return false, err
} }
key := argon2.IDKey([]byte(masterPassword), salt, uint32(vf.KDF.Iterations), uint32(vf.KDF.MemoryKiB), uint8(vf.KDF.Parallelism), keyLen) candidate, err := deriveValidKey([]byte(masterPassword), salt, vf)
defer func() { if err != nil {
for i := range key {
key[i] = 0
}
}()
if vf.Verifier != nil {
return verifyRecord(key, *vf.Verifier) == nil, nil
}
if len(vf.Records) == 0 {
return false, nil return false, nil
} }
_, err = decryptRecord(key, vf.Records[0]) key := candidate.key
return err == nil, nil defer func() {
clearBytes(key)
}()
return true, nil
}
func clearBytes(data []byte) {
for i := range data {
data[i] = 0
}
} }
// Constant-time comparison to prevent timing attacks // Constant-time comparison to prevent timing attacks

View File

@ -97,6 +97,56 @@ func TestLegacyVaultWithRecordsStillVerifiesByFirstRecord(t *testing.T) {
} }
} }
func TestLegacyVaultWithPreReductionKDFStillUnlocks(t *testing.T) {
path := filepath.Join(t.TempDir(), "vault.bin")
salt := []byte("12345678901234567890123456789012")
key := argon2.IDKey([]byte("correct horse"), salt, 2, 1024, 1, keyLen)
rec, err := encryptRecord(key, "server:test:ssh_password", []byte("secret"))
if err != nil {
t.Fatalf("encrypt legacy record: %v", err)
}
data, err := json.Marshal(VaultFile{
Version: currentVersion,
KDF: KDFMeta{
Name: "argon2id",
MemoryKiB: 1,
Iterations: 2,
Parallelism: 1,
Salt: base64.StdEncoding.EncodeToString(salt),
},
Records: []Record{rec},
})
if err != nil {
t.Fatalf("marshal legacy vault: %v", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write legacy vault: %v", err)
}
ok, err := VerifyPassword(path, "correct horse")
if err != nil {
t.Fatalf("verify legacy vault: %v", err)
}
if !ok {
t.Fatal("expected legacy vault using pre-reduction KDF to accept correct password")
}
v := New(path)
if err := v.Unlock("correct horse"); err != nil {
t.Fatalf("unlock legacy vault using pre-reduction KDF: %v", err)
}
secret, err := v.Get("server:test:ssh_password")
if err != nil {
t.Fatalf("get legacy secret: %v", err)
}
if string(secret) != "secret" {
t.Fatalf("unexpected legacy secret: %q", secret)
}
}
func TestLegacyEmptyVaultWithoutVerifierCannotUnlock(t *testing.T) { func TestLegacyEmptyVaultWithoutVerifierCannotUnlock(t *testing.T) {
path := filepath.Join(t.TempDir(), "vault.bin") path := filepath.Join(t.TempDir(), "vault.bin")
salt := []byte("12345678901234567890123456789012") salt := []byte("12345678901234567890123456789012")