Compare commits
No commits in common. "4b996032a9bf3c5cf60b92d12f26e66d51a4e5f4" and "e1d709396be8478d5f8d00c487ec34cf788d3b8c" have entirely different histories.
4b996032a9
...
e1d709396b
266
README.md
266
README.md
|
|
@ -1,22 +1,25 @@
|
||||||
# sshkeeper
|
# sshkeeper
|
||||||
|
|
||||||
`sshkeeper` is a Linux console manager for SSH profiles, secrets, and quick
|
Консольный менеджер SSH-подключений для Linux. Управляет профилями серверов, секретами и запускает SSH-сессии через системный OpenSSH.
|
||||||
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.
|
|
||||||
|
|
||||||
## Features
|
**sshkeeper не заменяет OpenSSH.** Он управляет профилями подключений, секретами и удобным запуском SSH-сессий.
|
||||||
|
|
||||||
- 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`.
|
|
||||||
|
|
||||||
## Install
|
- **TUI-интерфейс** на Bubble Tea — интерактивный терминальный интерфейс
|
||||||
|
- **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
|
||||||
|
|
@ -24,140 +27,177 @@ cd sshkeeper
|
||||||
go build -o ~/.local/bin/sshkeeper .
|
go build -o ~/.local/bin/sshkeeper .
|
||||||
```
|
```
|
||||||
|
|
||||||
Requirements: Go 1.25+, Linux x86_64, system OpenSSH.
|
Требования: Go 1.25+, Linux x86_64
|
||||||
|
|
||||||
## First Run
|
## Быстрый старт
|
||||||
|
|
||||||
Run the TUI or any command. On the first run, `sshkeeper` creates its config,
|
```bash
|
||||||
database, and vault, then asks for a master password.
|
# Первый запуск — создание vault и мастер-пароля
|
||||||
|
sshkeeper init
|
||||||
|
|
||||||
|
# Или сразу запустить TUI (vault создастся автоматически)
|
||||||
|
sshkeeper
|
||||||
|
|
||||||
|
# Добавить сервер
|
||||||
|
sshkeeper add myserver --host 10.0.0.1 --user admin --auth key
|
||||||
|
|
||||||
|
# Добавить сервер с паролем
|
||||||
|
sshkeeper add prod-web --host 10.0.0.5 --user deploy --auth password --password
|
||||||
|
|
||||||
|
# Показать список серверов
|
||||||
|
sshkeeper list
|
||||||
|
|
||||||
|
# Подключиться к серверу
|
||||||
|
sshkeeper connect myserver
|
||||||
|
sshkeeper c myserver
|
||||||
|
|
||||||
|
# Проверить подключение
|
||||||
|
sshkeeper test myserver
|
||||||
|
|
||||||
|
# Запустить команду на сервере
|
||||||
|
sshkeeper run myserver "uptime"
|
||||||
|
|
||||||
|
# Группы
|
||||||
|
sshkeeper group list
|
||||||
|
|
||||||
|
# Редактировать сервер
|
||||||
|
sshkeeper edit myserver --host 10.0.0.2
|
||||||
|
|
||||||
|
# Удалить сервер
|
||||||
|
sshkeeper delete myserver
|
||||||
|
|
||||||
|
# Импорт из ~/.ssh/config
|
||||||
|
sshkeeper import
|
||||||
|
|
||||||
|
# Сгенерировать OpenSSH config
|
||||||
|
sshkeeper ssh-config generate
|
||||||
|
sshkeeper ssh-config install-include
|
||||||
|
```
|
||||||
|
|
||||||
|
## TUI
|
||||||
|
|
||||||
|
Запуск без аргументов открывает интерактивный терминальный интерфейс:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sshkeeper
|
sshkeeper
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also initialize explicitly:
|
Клавиши (работают на любой раскладке — используются Ctrl+комбинации):
|
||||||
|
|
||||||
```bash
|
| Клавиша | Действие |
|
||||||
sshkeeper init
|
|---------|----------|
|
||||||
```
|
| Enter | Подключиться к серверу |
|
||||||
|
| Ctrl+A | Добавить сервер |
|
||||||
|
| Ctrl+E | Редактировать сервер |
|
||||||
|
| Ctrl+D | Удалить сервер |
|
||||||
|
| Ctrl+T | Проверить подключение |
|
||||||
|
| Ctrl+F | Поиск |
|
||||||
|
| Ctrl+Q / Ctrl+C | Выход |
|
||||||
|
|
||||||
## Common CLI Commands
|
В форме добавления/редактирования:
|
||||||
|
|
||||||
```bash
|
| Клавиша | Действие |
|
||||||
# Add profiles
|
|---------|----------|
|
||||||
sshkeeper add web --host 10.0.0.10 --user deploy --auth key
|
| Tab/↓ | Следующее поле |
|
||||||
sshkeeper add prod --host 10.0.0.20 --user root --auth password
|
| Shift+Tab/↑ | Предыдущее поле |
|
||||||
sshkeeper add bastion --host bastion.example.org --user admin --auth key_passphrase --identity-file ~/.ssh/id_rsa
|
| Enter | Перейти к кнопке / активировать |
|
||||||
|
| Esc | Назад |
|
||||||
|
|
||||||
# Inspect profiles
|
Кнопки **[Test]** и **[Save]**:
|
||||||
sshkeeper list
|
- **Test** — проверяет подключение без сохранения
|
||||||
sshkeeper show web
|
- **Save** — сохраняет профиль (не требует тест)
|
||||||
sshkeeper search prod
|
|
||||||
|
|
||||||
# Connect and test
|
## Хранение данных
|
||||||
sshkeeper connect web
|
|
||||||
sshkeeper c web
|
|
||||||
sshkeeper test web
|
|
||||||
sshkeeper run web "uptime"
|
|
||||||
|
|
||||||
# Groups and templates
|
XDG-совместимые Пути:
|
||||||
sshkeeper group list
|
|
||||||
sshkeeper template list web
|
|
||||||
|
|
||||||
# OpenSSH config
|
| Файл | Путь |
|
||||||
sshkeeper ssh-config generate
|
|------|------|
|
||||||
sshkeeper ssh-config install-include
|
| База данных | `~/.local/share/sshkeeper/sshkeeper.db` |
|
||||||
```
|
| Vault | `~/.local/share/sshkeeper/vault.bin` |
|
||||||
|
| Конфиг | `~/.config/sshkeeper/config.toml` |
|
||||||
Commands that only read profile metadata, such as `list`, `show`, `search`,
|
| SSH config | `~/.ssh/config.d/sshkeeper.conf` |
|
||||||
`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.
|
|
||||||
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
In add/edit forms:
|
|
||||||
|
|
||||||
| 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
|
||||||
|
|
||||||
The vault stores SSH passwords and key passphrases encrypted on disk.
|
Vault — зашифрованное хранилище для паролей и passphrase.
|
||||||
|
|
||||||
- Cipher: XChaCha20-Poly1305.
|
**Шифрование:** XChaCha20-Poly1305
|
||||||
- KDF: Argon2id, currently 64 MiB memory, 3 iterations.
|
**KDF:** Argon2id (4 MiB, 2 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.
|
|
||||||
|
|
||||||
Useful commands:
|
При первом запуске sshkeeper создаёт vault и запрашивает мастер-пароль. При последующих запусках — запрашивает мастер-пароль для разблокировки.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sshkeeper vault status
|
# Разблокировать vault вручную
|
||||||
sshkeeper vault unlock
|
sshkeeper vault unlock
|
||||||
sshkeeper vault list
|
|
||||||
sshkeeper vault delete <alias> [ssh_password|key_passphrase]
|
# Заблокировать
|
||||||
|
sshkeeper vault lock
|
||||||
|
|
||||||
|
# Сменить мастер-пароль
|
||||||
sshkeeper vault change-password
|
sshkeeper vault change-password
|
||||||
|
|
||||||
|
# Статус
|
||||||
|
sshkeeper vault status
|
||||||
```
|
```
|
||||||
|
|
||||||
`vault list`, `vault delete`, and `vault change-password` ask for the master
|
## CLI-команды
|
||||||
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/
|
||||||
├── cmd/ # Cobra CLI commands and TUI launcher
|
├── main.go # Точка входа
|
||||||
├── internal/config/ # XDG paths and config loading
|
├── Makefile # Сборка
|
||||||
├── internal/db/ # SQLite migrations and CRUD
|
├── cmd/ # CLI-команды
|
||||||
├── internal/model/ # Domain models
|
│ ├── root.go # Root command, initApp
|
||||||
├── internal/ssh/ # OpenSSH command building, PTY prompt handling
|
│ ├── tui.go # TUI launcher
|
||||||
├── internal/tui/ # Bubble Tea UI
|
│ ├── add.go, edit.go, ... # Команды
|
||||||
├── internal/vault/ # Encrypted vault
|
│ └── vault.go # Vault management
|
||||||
└── main.go
|
├── 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## Лицензия
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
|
||||||
35
cmd/root.go
35
cmd/root.go
|
|
@ -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,8 +127,9 @@ func initApp() {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Vault exists — unlock only for commands that may need secrets.
|
// Vault exists — need to unlock
|
||||||
if !commandRequiresStartupVaultUnlock(os.Args[1:]) {
|
// Skip unlock for vault commands that handle their own unlock/lock
|
||||||
|
if isVaultSubcommand() {
|
||||||
vaultInstance = v
|
vaultInstance = v
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -160,21 +161,19 @@ func initApp() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func commandRequiresStartupVaultUnlock(args []string) bool {
|
// isVaultSubcommand checks if the current command is a vault subcommand
|
||||||
if len(args) == 0 {
|
func isVaultSubcommand() bool {
|
||||||
return true
|
args := os.Args[1:]
|
||||||
}
|
|
||||||
|
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
if arg == "-h" || arg == "--help" {
|
if arg == "vault" {
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Skip for help
|
||||||
switch args[0] {
|
for _, arg := range args {
|
||||||
case "connect", "c", "run", "run-template", "test", "add", "edit", "delete":
|
if arg == "-h" || arg == "--help" {
|
||||||
return true
|
return true
|
||||||
default:
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
54
cmd/vault.go
54
cmd/vault.go
|
|
@ -28,7 +28,7 @@ var vaultCmd = &cobra.Command{
|
||||||
|
|
||||||
var vaultUnlockCmd = &cobra.Command{
|
var vaultUnlockCmd = &cobra.Command{
|
||||||
Use: "unlock",
|
Use: "unlock",
|
||||||
Short: "Verify the vault master password",
|
Short: "Unlock the vault with master password",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
v := getOrCreateVault()
|
v := getOrCreateVault()
|
||||||
|
|
||||||
|
|
@ -69,14 +69,16 @@ 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. Commands will ask for the master password when they need secrets.")
|
fmt.Println("Vault created and unlocked.")
|
||||||
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()
|
||||||
|
|
@ -88,7 +90,7 @@ var vaultUnlockCmd = &cobra.Command{
|
||||||
return fmt.Errorf("unlock vault: %w", err)
|
return fmt.Errorf("unlock vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Master password accepted. Vault unlock is process-local; commands will ask again when they need secrets.")
|
fmt.Println("Vault unlocked.")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -109,7 +111,11 @@ 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()
|
||||||
fmt.Println(formatVaultStatus(v.IsUnlocked(), vault.Exists(config.VaultPath(cfg.DataDir))))
|
if v.IsUnlocked() {
|
||||||
|
fmt.Println("Vault: unlocked")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Vault: locked")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -120,8 +126,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 err := unlockVaultForCommand(v); err != nil {
|
if !v.IsUnlocked() {
|
||||||
return err
|
return fmt.Errorf("vault is locked. Unlock first with 'sshkeeper vault unlock'")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Print("New master password: ")
|
fmt.Print("New master password: ")
|
||||||
|
|
@ -160,8 +166,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 err := unlockVaultForCommand(v); err != nil {
|
if !v.IsUnlocked() {
|
||||||
return err
|
return fmt.Errorf("vault is locked. Unlock first with 'sshkeeper vault unlock'")
|
||||||
}
|
}
|
||||||
output, err := formatVaultSecretsList(v)
|
output, err := formatVaultSecretsList(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -184,8 +190,8 @@ var vaultDeleteCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
v := getOrCreateVault()
|
v := getOrCreateVault()
|
||||||
if err := unlockVaultForCommand(v); err != nil {
|
if !v.IsUnlocked() {
|
||||||
return err
|
return fmt.Errorf("vault is locked. Unlock first with 'sshkeeper vault unlock'")
|
||||||
}
|
}
|
||||||
if err := deleteVaultSecrets(v, alias, secretType); err != nil {
|
if err := deleteVaultSecrets(v, alias, secretType); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -202,34 +208,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -38,20 +38,3 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `internal/tui/app_test.go`
|
- Modify: `internal/tui/app_test.go`
|
||||||
|
|
||||||
- [x] **Step 1: Add a test for a constrained terminal height**
|
- [ ] **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) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [x] **Step 2: Run the focused test and confirm it fails**
|
- [ ] **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`
|
||||||
|
|
||||||
- [x] **Step 1: Add focused tests for visible range calculation**
|
- [ ] **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) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [x] **Step 2: Implement `visibleServerRange`**
|
- [ ] **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) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [x] **Step 3: Run helper tests**
|
- [ ] **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`
|
||||||
|
|
||||||
- [x] **Step 1: Reserve terminal space for fixed UI blocks**
|
- [ ] **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 {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [x] **Step 2: Use the visible range in `viewServerList`**
|
- [ ] **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 {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [x] **Step 3: Run long-list regression test**
|
- [ ] **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`
|
||||||
|
|
||||||
- [x] **Step 1: Add a test for moving selection beyond the first window**
|
- [ ] **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) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [x] **Step 2: Run TUI tests**
|
- [ ] **Step 2: Run TUI tests**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
|
|
@ -255,7 +255,7 @@ Expected: PASS.
|
||||||
**Files:**
|
**Files:**
|
||||||
- No source edits expected.
|
- No source edits expected.
|
||||||
|
|
||||||
- [x] **Step 1: Run the full test suite**
|
- [ ] **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.
|
||||||
|
|
||||||
- [x] **Step 2: Rebuild the project binary**
|
- [ ] **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`.
|
||||||
|
|
||||||
- [x] **Step 3: Commit the implementation**
|
- [ ] **Step 3: Commit the implementation**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,11 +50,25 @@ func defaultConfig() *Config {
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
cfg := defaultConfig()
|
cfg := defaultConfig()
|
||||||
|
|
||||||
configDir, dataDir, err := resolveDirs(os.Getenv("XDG_CONFIG_HOME"), os.Getenv("XDG_DATA_HOME"))
|
// XDG paths
|
||||||
if err != nil {
|
configDir := os.Getenv("XDG_CONFIG_HOME")
|
||||||
return nil, err
|
if configDir == "" {
|
||||||
|
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
|
||||||
|
|
@ -90,19 +104,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -24,14 +24,12 @@ 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:
|
||||||
passphrase, err := getVault(server.Alias, "key_passphrase")
|
// For key+passphrase, let ssh-agent handle it or prompt normally
|
||||||
if err != nil {
|
// TODO: use ssh-agent or similar
|
||||||
return fmt.Errorf("get key passphrase from vault: %w", err)
|
fallthrough
|
||||||
}
|
|
||||||
return ConnectWithPassword(cfg.SSH.Binary, args, passphrase)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// key and agent auth use direct OpenSSH execution.
|
// key, agent, key+passphrase - direct 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
|
||||||
|
|
@ -58,16 +56,8 @@ 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 and agent auth should not prompt during tests.
|
// key, agent, key+passphrase
|
||||||
args = append(args, "-o", "BatchMode=yes")
|
args = append(args, "-o", "BatchMode=yes")
|
||||||
args = append(args, cfg.SSH.TestCommand)
|
args = append(args, cfg.SSH.TestCommand)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -417,9 +417,7 @@ 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 {
|
||||||
selectedIndex := m.list.Index()
|
for _, server := range m.servers {
|
||||||
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 {
|
||||||
|
|
@ -447,10 +445,6 @@ 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")
|
||||||
|
|
@ -471,45 +465,6 @@ 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 == "" {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -69,94 +68,6 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,6 @@ 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
|
||||||
|
|
@ -169,19 +164,22 @@ func (v *Vault) Unlock(masterPassword string) error {
|
||||||
return fmt.Errorf("decode salt: %w", err)
|
return fmt.Errorf("decode salt: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate, err := deriveValidKey([]byte(masterPassword), salt, vf)
|
key := argon2.IDKey([]byte(masterPassword), salt, uint32(vf.KDF.Iterations), uint32(vf.KDF.MemoryKiB), uint8(vf.KDF.Parallelism), keyLen)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
key := candidate.key
|
|
||||||
|
|
||||||
if len(vf.Records) == 0 && vf.Verifier == nil {
|
if 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)
|
||||||
|
|
@ -533,44 +531,6 @@ 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 {
|
||||||
|
|
@ -608,22 +568,22 @@ func VerifyPassword(path string, masterPassword string) (bool, error) {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
candidate, err := deriveValidKey([]byte(masterPassword), salt, vf)
|
key := argon2.IDKey([]byte(masterPassword), salt, uint32(vf.KDF.Iterations), uint32(vf.KDF.MemoryKiB), uint8(vf.KDF.Parallelism), keyLen)
|
||||||
if err != nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
key := candidate.key
|
|
||||||
defer func() {
|
defer func() {
|
||||||
clearBytes(key)
|
for i := range key {
|
||||||
|
key[i] = 0
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return true, nil
|
if vf.Verifier != nil {
|
||||||
}
|
return verifyRecord(key, *vf.Verifier) == nil, nil
|
||||||
|
|
||||||
func clearBytes(data []byte) {
|
|
||||||
for i := range data {
|
|
||||||
data[i] = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(vf.Records) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
_, err = decryptRecord(key, vf.Records[0])
|
||||||
|
return err == nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constant-time comparison to prevent timing attacks
|
// Constant-time comparison to prevent timing attacks
|
||||||
|
|
|
||||||
|
|
@ -97,56 +97,6 @@ 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")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue