Initial commit: dial — interactive SSH host picker

A fast, read-only ~/.ssh/config picker that exec's the system ssh client.
Includes: static high-contrast picker with recency/frequency/name sort and
tag grouping, `dial keygen` (native ed25519/RSA + optional config entry), and
an optional offline YubiKey launch gate with one-time recovery codes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:18:23 +10:00
commit 8af44dfcd2
31 changed files with 4219 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
// Package connect turns a selected host alias into a handover to the system's
// real ssh client. It is deliberately decoupled from the picker: the picker's
// job ends at "here is an alias", and connect's job is "hand the terminal to
// ssh <alias>". This keeps the select-a-host logic decoupled from the handover.
package connect
// Command describes an external client invocation that dial hands the terminal
// over to. dial never wraps or proxies the session; it exec's the real binary.
type Command struct {
Bin string // program to run, resolved via PATH (e.g. "ssh", "sftp")
Args []string // arguments after the program name (argv0 is added by Exec)
}
// SSH opens an interactive shell session on alias, letting the system ssh
// client re-resolve everything (ProxyJump, agent forwarding, tty, etc.).
func SSH(alias string) Command {
return Command{Bin: "ssh", Args: []string{alias}}
}
// Exec hands terminal control to the command. On Unix this replaces the current
// process image, so it only returns if the exec itself fails (e.g. the binary
// is missing). On Windows it runs the child with inherited stdio and exits with
// the child's status, so it likewise does not return on success.
func (c Command) Exec() error { return execCommand(c) }
+21
View File
@@ -0,0 +1,21 @@
//go:build !windows
package connect
import (
"os"
"os/exec"
"syscall"
)
// execCommand replaces the current process with the target binary via execve.
// This is what makes ssh's tty/ProxyJump/agent-forwarding "just work": dial is
// gone, and ssh owns the terminal directly with no wrapping parent.
func execCommand(c Command) error {
path, err := exec.LookPath(c.Bin)
if err != nil {
return err
}
argv := append([]string{c.Bin}, c.Args...)
return syscall.Exec(path, argv, os.Environ())
}
+33
View File
@@ -0,0 +1,33 @@
//go:build windows
package connect
import (
"errors"
"os"
"os/exec"
)
// execCommand runs the target as a child with inherited stdio because Windows
// has no execve-style process replacement. dial waits and then exits with the
// child's status, so from the user's perspective control still passes cleanly
// to ssh.exe (found on PATH; typically %WINDIR%\System32\OpenSSH).
func execCommand(c Command) error {
path, err := exec.LookPath(c.Bin)
if err != nil {
return err
}
cmd := exec.Command(path, c.Args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
os.Exit(ee.ExitCode())
}
return err
}
os.Exit(0)
return nil // unreachable
}
+238
View File
@@ -0,0 +1,238 @@
// Package keygen creates SSH key pairs natively in Go (no ssh-keygen
// dependency) and, optionally, appends a matching Host block to ~/.ssh/config.
//
// It is a rebuild of an older create_ssh_keypair.sh, fixing that script's
// cross-platform and safety problems:
// - no macOS-only `sed -i ''` (public-key comments are handled in Go);
// - the .pub file is kept, not deleted after display;
// - ed25519 by default, RSA-4096 as an explicit opt-out;
// - config appends are quoted/validated, back up the file first, and refuse
// to create a duplicate Host alias.
package keygen
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/ssh"
"gitea.apointless.space/bsncubed/dial/internal/sshconf"
)
// Algorithm selects the key type.
type Algorithm string
const (
AlgoED25519 Algorithm = "ed25519"
AlgoRSA Algorithm = "rsa"
rsaBits = 4096
)
// Result holds the encoded key material ready to write to disk.
type Result struct {
PrivatePEM []byte // OpenSSH-format private key (optionally passphrase-encrypted)
PublicLine []byte // authorized_keys line, comment appended, trailing newline
}
// Generate creates a key pair of the given algorithm. comment is embedded in
// the private key and appended to the public line. A non-empty passphrase
// encrypts the private key at rest.
func Generate(algo Algorithm, comment string, passphrase []byte) (Result, error) {
var privKey crypto.PrivateKey
var pubKey crypto.PublicKey
switch algo {
case AlgoRSA:
k, err := rsa.GenerateKey(rand.Reader, rsaBits)
if err != nil {
return Result{}, err
}
privKey, pubKey = k, &k.PublicKey
case AlgoED25519, "":
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return Result{}, err
}
privKey, pubKey = priv, pub
default:
return Result{}, fmt.Errorf("unknown algorithm %q", algo)
}
var block *pem.Block
var err error
if len(passphrase) > 0 {
block, err = ssh.MarshalPrivateKeyWithPassphrase(privKey, comment, passphrase)
} else {
block, err = ssh.MarshalPrivateKey(privKey, comment)
}
if err != nil {
return Result{}, fmt.Errorf("encoding private key: %w", err)
}
sshPub, err := ssh.NewPublicKey(pubKey)
if err != nil {
return Result{}, err
}
pubLine := strings.TrimRight(string(ssh.MarshalAuthorizedKey(sshPub)), "\n")
if comment != "" {
pubLine += " " + comment
}
pubLine += "\n"
return Result{PrivatePEM: pem.EncodeToMemory(block), PublicLine: []byte(pubLine)}, nil
}
// WriteKeyPair writes the private key (0600) and public key (0644) into sshDir
// as <name> and <name>.pub. It refuses to overwrite existing files so a stray
// re-run can't clobber a key still in use.
func WriteKeyPair(sshDir, name string, r Result) (privPath, pubPath string, err error) {
if err = os.MkdirAll(sshDir, 0o700); err != nil {
return "", "", err
}
privPath = filepath.Join(sshDir, name)
pubPath = privPath + ".pub"
if exists(privPath) || exists(pubPath) {
return privPath, pubPath, fmt.Errorf("key files for %q already exist; refusing to overwrite", name)
}
if err = os.WriteFile(privPath, r.PrivatePEM, 0o600); err != nil {
return privPath, pubPath, err
}
if err = os.WriteFile(pubPath, r.PublicLine, 0o644); err != nil {
return privPath, pubPath, err
}
return privPath, pubPath, nil
}
// HostEntry describes a Host block to append to ~/.ssh/config.
type HostEntry struct {
Alias string
HostName string
User string
Port string
IdentityFile string
Tags []string
}
// AliasExists reports whether the config already declares the given Host alias.
func AliasExists(configPath, alias string) (bool, error) {
hosts, err := sshconf.Parse(configPath)
if err != nil {
return false, err
}
for _, h := range hosts {
if h.Alias == alias {
return true, nil
}
}
return false, nil
}
// AppendHostEntry appends a Host block to configPath. It validates fields
// (rejecting whitespace/newlines that could break or inject config), backs up
// an existing config first, and refuses to duplicate an existing alias.
func AppendHostEntry(configPath string, e HostEntry) (backup string, err error) {
if err := validateEntry(e); err != nil {
return "", err
}
if exists(configPath) {
dup, err := AliasExists(configPath, e.Alias)
if err != nil {
return "", err
}
if dup {
return "", fmt.Errorf("Host %q already exists in %s", e.Alias, configPath)
}
backup = fmt.Sprintf("%s.%s.bak", configPath, time.Now().Format("20060102-150405"))
if err := copyFile(configPath, backup); err != nil {
return "", fmt.Errorf("backing up config: %w", err)
}
} else if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
return "", err
}
var b strings.Builder
b.WriteString("\n")
if len(e.Tags) > 0 {
b.WriteString("# tag: " + strings.Join(e.Tags, ", ") + "\n")
}
b.WriteString("Host " + e.Alias + "\n")
if e.HostName != "" {
b.WriteString(" HostName " + e.HostName + "\n")
}
if e.User != "" {
b.WriteString(" User " + e.User + "\n")
}
if e.Port != "" && e.Port != "22" {
b.WriteString(" Port " + e.Port + "\n")
}
if e.IdentityFile != "" {
b.WriteString(" IdentityFile " + e.IdentityFile + "\n")
b.WriteString(" IdentitiesOnly yes\n")
}
f, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return backup, err
}
defer f.Close()
if _, err := f.WriteString(b.String()); err != nil {
return backup, err
}
return backup, nil
}
func validateEntry(e HostEntry) error {
if strings.TrimSpace(e.Alias) == "" {
return fmt.Errorf("host alias is required")
}
fields := map[string]string{
"alias": e.Alias, "hostname": e.HostName, "user": e.User,
"port": e.Port, "identityfile": e.IdentityFile,
}
for name, v := range fields {
if strings.ContainsAny(v, "\n\r") {
return fmt.Errorf("%s contains a newline", name)
}
}
if strings.ContainsAny(e.Alias, " \t") {
return fmt.Errorf("host alias %q must not contain spaces", e.Alias)
}
for _, t := range e.Tags {
if strings.ContainsAny(t, "\n\r,") {
return fmt.Errorf("tag %q must not contain newlines or commas", t)
}
}
return nil
}
func exists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}
+129
View File
@@ -0,0 +1,129 @@
package keygen
import (
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/crypto/ssh"
)
func TestGenerateED25519(t *testing.T) {
r, err := Generate(AlgoED25519, "test@dial", nil)
if err != nil {
t.Fatal(err)
}
// Private key must parse back as an OpenSSH key.
if _, err := ssh.ParseRawPrivateKey(r.PrivatePEM); err != nil {
t.Fatalf("private key does not parse: %v", err)
}
// Public line must parse and carry the comment.
pk, comment, _, _, err := ssh.ParseAuthorizedKey(r.PublicLine)
if err != nil {
t.Fatalf("public key does not parse: %v", err)
}
if pk.Type() != ssh.KeyAlgoED25519 {
t.Errorf("expected ed25519, got %s", pk.Type())
}
if comment != "test@dial" {
t.Errorf("comment = %q, want test@dial", comment)
}
}
func TestGenerateWithPassphrase(t *testing.T) {
r, err := Generate(AlgoED25519, "c", []byte("hunter2"))
if err != nil {
t.Fatal(err)
}
if _, err := ssh.ParseRawPrivateKey(r.PrivatePEM); err == nil {
t.Error("encrypted key should not parse without passphrase")
}
if _, err := ssh.ParseRawPrivateKeyWithPassphrase(r.PrivatePEM, []byte("hunter2")); err != nil {
t.Errorf("key should parse with passphrase: %v", err)
}
}
func TestGenerateRSA(t *testing.T) {
r, err := Generate(AlgoRSA, "c", nil)
if err != nil {
t.Fatal(err)
}
pk, _, _, _, err := ssh.ParseAuthorizedKey(r.PublicLine)
if err != nil {
t.Fatal(err)
}
if pk.Type() != ssh.KeyAlgoRSA {
t.Errorf("expected rsa, got %s", pk.Type())
}
}
func TestWriteKeyPairPermsAndNoOverwrite(t *testing.T) {
dir := t.TempDir()
r, _ := Generate(AlgoED25519, "c", nil)
priv, pub, err := WriteKeyPair(dir, "mykey", r)
if err != nil {
t.Fatal(err)
}
fi, _ := os.Stat(priv)
if fi.Mode().Perm() != 0o600 {
t.Errorf("private key perms = %o, want 600", fi.Mode().Perm())
}
pfi, _ := os.Stat(pub)
if pfi.Mode().Perm() != 0o644 {
t.Errorf("public key perms = %o, want 644", pfi.Mode().Perm())
}
// Second write must refuse to overwrite.
if _, _, err := WriteKeyPair(dir, "mykey", r); err == nil {
t.Error("expected refusal to overwrite existing key")
}
}
func TestAppendHostEntryBackupAndDup(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config")
if err := os.WriteFile(cfg, []byte("Host existing\n HostName e\n"), 0o600); err != nil {
t.Fatal(err)
}
backup, err := AppendHostEntry(cfg, HostEntry{
Alias: "newhost", HostName: "10.0.0.1", User: "ben", Port: "22",
IdentityFile: "~/.ssh/mykey", Tags: []string{"prod"},
})
if err != nil {
t.Fatal(err)
}
if backup == "" {
t.Error("expected a backup path")
}
if _, err := os.Stat(backup); err != nil {
t.Errorf("backup not written: %v", err)
}
b, _ := os.ReadFile(cfg)
got := string(b)
for _, want := range []string{"# tag: prod", "Host newhost", "HostName 10.0.0.1", "IdentitiesOnly yes"} {
if !strings.Contains(got, want) {
t.Errorf("config missing %q\n%s", want, got)
}
}
// Port 22 is the default and should be omitted.
if strings.Contains(got, "Port 22") {
t.Error("default port 22 should not be written")
}
// Appending the same alias must be refused.
if _, err := AppendHostEntry(cfg, HostEntry{Alias: "newhost", HostName: "x"}); err == nil {
t.Error("expected duplicate-alias refusal")
}
}
func TestValidateRejectsInjection(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config")
if _, err := AppendHostEntry(cfg, HostEntry{Alias: "a b"}); err == nil {
t.Error("alias with space should be rejected")
}
if _, err := AppendHostEntry(cfg, HostEntry{Alias: "ok", HostName: "x\n ProxyCommand evil"}); err == nil {
t.Error("newline injection should be rejected")
}
}
+240
View File
@@ -0,0 +1,240 @@
// Package picker is dial's interactive host list. Per the brief it is a static,
// numbered, high-contrast list — NOT a fuzzy-filter-as-you-type UI. The resting
// state is always a clean, well-spaced, readable list; a search/filter mode is
// available on demand via "/" but is never the default view.
//
// The picker only *selects* a host. Connecting is the caller's job (see package
// connect), keeping selection decoupled from what's done with the chosen host.
package picker
import (
"sort"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"gitea.apointless.space/bsncubed/dial/internal/sshconf"
"gitea.apointless.space/bsncubed/dial/internal/store"
"gitea.apointless.space/bsncubed/dial/internal/theme"
)
// Action is what the user chose to do with the selected host. The type leaves
// room for additional actions to slot in without reworking the result plumbing.
type Action int
const (
ActionNone Action = iota
ActionConnect
)
// Result reports the outcome of a picker session.
type Result struct {
Alias string // selected host alias; empty if the user quit
Action Action
}
// row is a host enriched with its usage stats and favourite flag.
type row struct {
host sshconf.Host
count int
last time.Time
fav bool
}
type model struct {
th theme.Theme
rows []row // immutable set of all hosts
sortM store.SortMode
sorted []int // indices into rows, in sorted order (favourites first)
view []int // indices into rows currently shown (sorted, filtered, grouped)
grouped bool // section the list by tag
headers map[int]sectionHead // view position -> section header shown before it
cursor int // position within view (a host row)
top int // scroll offset, in rendered lines
// winFirst/winLast are the first/last host positions visible after the last
// render, used for the "showing XY of N" header hint.
winFirst, winLast int
filtering bool
query string
width, height int
status string // transient one-line message
result Result
quitting bool
}
// sectionHead labels a group section in grouped mode. tag is the colour key
// (empty for the Favourites/untagged sections, which render neutral).
type sectionHead struct {
title string
tag string
}
// Run displays the picker and blocks until the user connects or quits. It
// persists sort-mode and favourite changes via the store as they happen. The
// returned Result carries the chosen alias (empty if quit).
func Run(hosts []sshconf.Host, data store.Data, settings store.Settings) (Result, error) {
m, _ := buildModel(hosts, data, settings)
p := tea.NewProgram(m, tea.WithAltScreen())
fm, err := p.Run()
if err != nil {
return Result{}, err
}
return fm.(*model).result, nil
}
// buildModel constructs an initialised model (rows enriched, sorted, filtered)
// without starting a tea program, so tests can render it directly.
func buildModel(hosts []sshconf.Host, data store.Data, settings store.Settings) (*model, error) {
fav := map[string]bool{}
for _, f := range data.Favorites {
fav[f] = true
}
rows := make([]row, len(hosts))
for i, h := range hosts {
r := row{host: h, fav: fav[h.Alias]}
if st := data.Stats[h.Alias]; st != nil {
r.count = st.Count
r.last = st.LastUsed
}
rows[i] = r
}
m := &model{
th: theme.New(settings.Theme),
rows: rows,
sortM: settings.Sort,
grouped: settings.GroupByTag,
}
m.sortAll()
m.applyFilter()
return m, nil
}
func (m *model) Init() tea.Cmd { return nil }
// sortAll rebuilds m.sorted: favourites first, then by the active sort mode,
// ties broken by alias for stable, predictable ordering.
func (m *model) sortAll() {
idx := make([]int, len(m.rows))
for i := range idx {
idx[i] = i
}
less := func(a, b int) bool {
ra, rb := m.rows[a], m.rows[b]
if ra.fav != rb.fav {
return ra.fav // favourites float to the top
}
switch m.sortM {
case store.SortFrequent:
if ra.count != rb.count {
return ra.count > rb.count
}
case store.SortAlpha:
// Alias-only ordering; handled by the shared tiebreaker below.
default: // SortRecent
if !ra.last.Equal(rb.last) {
return ra.last.After(rb.last)
}
}
return strings.ToLower(ra.host.Alias) < strings.ToLower(rb.host.Alias)
}
sort.SliceStable(idx, func(i, j int) bool { return less(idx[i], idx[j]) })
m.sorted = idx
}
// applyFilter recomputes m.view from m.sorted using the current query. An empty
// query shows everything (the resting state). Matching is a case-insensitive
// substring over alias, hostname, user, and tags — predictable, not fuzzy.
func (m *model) applyFilter() {
q := strings.ToLower(strings.TrimSpace(m.query))
filtered := make([]int, 0, len(m.sorted))
for _, i := range m.sorted {
if q == "" || rowMatches(m.rows[i], q) {
filtered = append(filtered, i)
}
}
if m.grouped {
m.view, m.headers = m.regroup(filtered)
} else {
m.view, m.headers = filtered, nil
}
if m.cursor >= len(m.view) {
m.cursor = len(m.view) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
m.ensureVisible()
}
// regroup reorders the filtered hosts into sections: a Favourites section
// first (if any), then one section per tag (tags sorted alphabetically), then
// an "untagged" section. A host is placed under its first tag only, so the view
// stays a clean permutation with no duplicated rows. Within each section, the
// active sort order is preserved (favourites are pulled out, so their tag
// sections don't repeat them).
func (m *model) regroup(filtered []int) ([]int, map[int]sectionHead) {
var favs, untagged []int
buckets := map[string][]int{}
var tagOrder []string
seenTag := map[string]bool{}
for _, vi := range filtered {
r := m.rows[vi]
if r.fav {
favs = append(favs, vi)
continue
}
if len(r.host.Tags) == 0 {
untagged = append(untagged, vi)
continue
}
t := r.host.Tags[0]
if !seenTag[t] {
seenTag[t] = true
tagOrder = append(tagOrder, t)
}
buckets[t] = append(buckets[t], vi)
}
sort.Strings(tagOrder)
view := make([]int, 0, len(filtered))
headers := map[int]sectionHead{}
addSection := func(h sectionHead, hosts []int) {
if len(hosts) == 0 {
return
}
headers[len(view)] = h
view = append(view, hosts...)
}
addSection(sectionHead{title: "★ favourites"}, favs)
for _, t := range tagOrder {
addSection(sectionHead{title: t, tag: t}, buckets[t])
}
addSection(sectionHead{title: "untagged"}, untagged)
return view, headers
}
func rowMatches(r row, q string) bool {
h := r.host
if strings.Contains(strings.ToLower(h.Alias), q) ||
strings.Contains(strings.ToLower(h.HostName), q) ||
strings.Contains(strings.ToLower(h.User), q) {
return true
}
for _, t := range h.Tags {
if strings.Contains(strings.ToLower(t), q) {
return true
}
}
return false
}
+163
View File
@@ -0,0 +1,163 @@
package picker
import (
"strings"
"testing"
"time"
"github.com/charmbracelet/lipgloss"
"gitea.apointless.space/bsncubed/dial/internal/sshconf"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
func sampleModel() *model {
hosts := []sshconf.Host{
{Alias: "web1", HostName: "10.0.0.5", User: "deploy", Port: "2222", ProxyJump: "bastion", Tags: []string{"prod", "riedel"}},
{Alias: "db-primary", HostName: "db.internal", User: "postgres", Port: "22", Tags: []string{"prod"}},
{Alias: "bastion", HostName: "bastion.example.com", User: "admin"},
{Alias: "staging-web", HostName: "10.1.0.9", User: "deploy", Tags: []string{"staging"}},
}
data := store.DefaultData()
data.Stats["web1"] = &store.HostStat{Count: 9, LastUsed: time.Now()}
data.Stats["bastion"] = &store.HostStat{Count: 3, LastUsed: time.Now().Add(-time.Hour)}
data.Favorites = []string{"bastion"}
settings := store.DefaultSettings()
m := newModelForTest(hosts, data, settings)
m.width, m.height = 90, 30
m.ensureVisible()
return m
}
// newModelForTest mirrors the setup Run does, without starting a tea program.
func newModelForTest(hosts []sshconf.Host, data store.Data, settings store.Settings) *model {
res, _ := buildModel(hosts, data, settings)
return res
}
func TestRenderSnapshot(t *testing.T) {
m := sampleModel()
out := m.View()
if !strings.Contains(out, "dial") {
t.Error("header missing")
}
if !strings.Contains(out, "web1") || !strings.Contains(out, "bastion") {
t.Error("hosts missing from render")
}
// bastion is a favourite, so it should sort to the top of a recent-sort list.
t.Logf("\n%s", out)
}
func TestFilterNarrows(t *testing.T) {
m := sampleModel()
m.query = "staging"
m.applyFilter()
if len(m.view) != 1 {
t.Fatalf("expected 1 match for 'staging', got %d", len(m.view))
}
if m.rows[m.view[0]].host.Alias != "staging-web" {
t.Errorf("wrong match: %s", m.rows[m.view[0]].host.Alias)
}
}
func TestFavoriteSortsFirst(t *testing.T) {
m := sampleModel()
if len(m.view) == 0 {
t.Fatal("no rows")
}
if !m.rows[m.view[0]].fav {
t.Errorf("favourite should be first, got %s", m.rows[m.view[0]].host.Alias)
}
}
func TestAlphaSort(t *testing.T) {
m := sampleModel()
m.sortM = store.SortAlpha
m.sortAll()
m.applyFilter()
// Favourite (bastion) still pins first; the rest are alphabetical.
got := []string{}
for _, vi := range m.view {
got = append(got, m.rows[vi].host.Alias)
}
want := []string{"bastion", "db-primary", "staging-web", "web1"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("alpha order = %v, want %v", got, want)
}
}
func TestSortCycle(t *testing.T) {
s := store.SortRecent
seq := []store.SortMode{}
for i := 0; i < 3; i++ {
s = s.Next()
seq = append(seq, s)
}
want := []store.SortMode{store.SortFrequent, store.SortAlpha, store.SortRecent}
for i := range want {
if seq[i] != want[i] {
t.Errorf("cycle[%d] = %s, want %s", i, seq[i], want[i])
}
}
}
func TestGrouping(t *testing.T) {
m := sampleModel()
m.grouped = true
m.applyFilter()
// Expect a Favourites header first, then tag sections, then no untagged
// (bastion, the only untagged host, is a favourite).
if h, ok := m.headers[0]; !ok || !strings.Contains(h.title, "favourites") {
t.Fatalf("first section should be favourites, got %+v", m.headers[0])
}
// bastion (fav) appears once, only in the favourites section.
count := 0
for _, vi := range m.view {
if m.rows[vi].host.Alias == "bastion" {
count++
}
}
if count != 1 {
t.Errorf("favourite host should appear exactly once, got %d", count)
}
// Every non-header host is reachable; view length equals host count (no dupes).
if len(m.view) != len(m.rows) {
t.Errorf("grouped view should contain each host once: got %d, want %d", len(m.view), len(m.rows))
}
// A tag section header should carry the tag as its colour key.
foundProd := false
for _, h := range m.headers {
if h.title == "prod" && h.tag == "prod" {
foundProd = true
}
}
if !foundProd {
t.Error("expected a 'prod' tag section header")
}
}
func TestNoOverflow(t *testing.T) {
for _, grouped := range []bool{false, true} {
for _, w := range []int{40, 60, 90, 140} {
for _, h := range []int{18, 24, 40} {
m := sampleModel()
m.grouped = grouped
m.applyFilter()
m.width, m.height = w, h
out := m.View()
lines := strings.Split(out, "\n")
if len(lines) > h {
t.Errorf("grouped=%v %dx%d: %d lines > height", grouped, w, h, len(lines))
}
for _, ln := range lines {
if lw := lipgloss.Width(ln); lw > w {
t.Errorf("grouped=%v %dx%d: line %d cols > width", grouped, w, h, lw)
}
}
}
}
}
}
+237
View File
@@ -0,0 +1,237 @@
package picker
import (
tea "github.com/charmbracelet/bubbletea"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
// Layout budget (lines) reserved around the scrolling list.
const (
headerLines = 2 // title + blank spacer
footerLines = 1 // key hints
previewLines = 8 // bordered preview box (2 border + 6 fields)
linesPerRow = 3 // alias line + detail line + blank spacer
)
// visibleListLines returns how many rendered lines the scrolling list may
// occupy, reserving space for the header, preview box, and footer.
func (m *model) visibleListLines() int {
reserved := headerLines + previewLines + footerLines + 1 // +1 slack for separators
if m.filtering {
reserved++ // filter input line
}
n := m.height - reserved
if n < linesPerRow {
return linesPerRow // always show at least one host block
}
return n
}
// visibleCount estimates how many host rows fit on screen, used only as the
// page size for PgUp/PgDn.
func (m *model) visibleCount() int {
n := m.visibleListLines() / linesPerRow
if n < 1 {
return 1
}
return n
}
// ensureVisible clamps the cursor to a valid host position. The actual line
// scrolling (m.top) is computed during render, where header/host line heights
// are known.
func (m *model) ensureVisible() {
if m.cursor >= len(m.view) {
m.cursor = len(m.view) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.ensureVisible()
return m, nil
case tea.KeyMsg:
if m.filtering {
return m.updateFiltering(msg)
}
return m.updateNormal(msg)
}
return m, nil
}
// updateNormal handles keys in the resting (static list) state.
func (m *model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.status = ""
switch msg.String() {
case "ctrl+c", "q", "esc":
m.quitting = true
return m, tea.Quit
case "up", "k", "ctrl+p":
m.move(-1)
case "down", "j", "ctrl+n":
m.move(1)
case "home":
m.cursor = 0
m.ensureVisible()
case "end", "G":
m.cursor = len(m.view) - 1
m.ensureVisible()
case "pgup":
m.move(-m.visibleCount())
case "pgdown":
m.move(m.visibleCount())
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
// Jump the cursor to the row bearing that number (1-based, absolute).
n := int(msg.String()[0] - '0')
if n >= 1 && n <= len(m.view) {
m.cursor = n - 1
m.ensureVisible()
}
case "/":
m.filtering = true
m.query = ""
return m, nil
case "s":
// Cycle sort mode (recent → frequent → alpha) and persist the preference.
m.sortM = m.sortM.Next()
m.persistSort()
m.reindex()
case "g":
// Toggle tag grouping (orthogonal to sort) and persist.
m.grouped = !m.grouped
m.persistGroup()
if m.grouped {
m.status = "grouped by tag"
} else {
m.status = "grouping off"
}
m.reindex()
case "f":
m.toggleFavorite()
case "enter":
return m.selectWith(ActionConnect)
}
return m, nil
}
// updateFiltering handles keys while the on-demand search box is open.
func (m *model) updateFiltering(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "ctrl+c":
// Leave filter mode and return to the clean, static resting list.
m.filtering = false
m.query = ""
m.applyFilter()
return m, nil
case "enter":
return m.selectWith(ActionConnect)
case "up", "ctrl+p":
m.move(-1)
return m, nil
case "down", "ctrl+n":
m.move(1)
return m, nil
case "backspace":
if len(m.query) > 0 {
// Trim one rune from the query.
r := []rune(m.query)
m.query = string(r[:len(r)-1])
m.applyFilter()
}
return m, nil
default:
if msg.Type == tea.KeyRunes {
m.query += string(msg.Runes)
m.applyFilter()
}
return m, nil
}
}
func (m *model) move(delta int) {
if len(m.view) == 0 {
return
}
m.cursor += delta
if m.cursor < 0 {
m.cursor = 0
}
if m.cursor >= len(m.view) {
m.cursor = len(m.view) - 1
}
m.ensureVisible()
}
// reindex re-sorts and re-filters while keeping the highlighted host under the
// cursor where possible.
func (m *model) reindex() {
var keepAlias string
if m.cursor >= 0 && m.cursor < len(m.view) {
keepAlias = m.rows[m.view[m.cursor]].host.Alias
}
m.sortAll()
m.applyFilter()
if keepAlias != "" {
for i, vi := range m.view {
if m.rows[vi].host.Alias == keepAlias {
m.cursor = i
break
}
}
}
m.ensureVisible()
}
func (m *model) selectWith(action Action) (tea.Model, tea.Cmd) {
if m.cursor < 0 || m.cursor >= len(m.view) {
return m, nil
}
m.result = Result{Alias: m.rows[m.view[m.cursor]].host.Alias, Action: action}
m.quitting = true
return m, tea.Quit
}
func (m *model) toggleFavorite() {
if m.cursor < 0 || m.cursor >= len(m.view) {
return
}
ri := m.view[m.cursor]
alias := m.rows[ri].host.Alias
on, err := store.ToggleFavorite(alias)
if err != nil {
m.status = "could not save favourite: " + err.Error()
return
}
m.rows[ri].fav = on
if on {
m.status = "★ favourited " + alias
} else {
m.status = "removed favourite " + alias
}
m.reindex()
}
func (m *model) persistSort() {
s := store.LoadSettings()
s.Sort = m.sortM
_ = store.SaveSettings(s) // non-fatal: preference only
}
func (m *model) persistGroup() {
s := store.LoadSettings()
s.GroupByTag = m.grouped
_ = store.SaveSettings(s) // non-fatal: preference only
}
+310
View File
@@ -0,0 +1,310 @@
package picker
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"gitea.apointless.space/bsncubed/dial/internal/sshconf"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
func (m *model) View() string {
if m.quitting {
return ""
}
// renderList must run first: it computes the scroll window and sets
// winFirst/winLast, which the header's "showing" hint reads.
list := m.renderList()
var b strings.Builder
b.WriteString(m.renderHeader())
b.WriteString("\n\n")
b.WriteString(list)
b.WriteString("\n")
b.WriteString(m.renderPreview())
b.WriteString("\n")
b.WriteString(m.renderFooter())
return b.String()
}
func (m *model) renderHeader() string {
th := m.th
sortName := "recently used"
switch m.sortM {
case store.SortFrequent:
sortName = "most used"
case store.SortAlpha:
sortName = "name"
}
left := th.Title.Render("dial") + th.Meta.Render(
fmt.Sprintf(" %d hosts · sorted by %s", len(m.rows), sortName))
if m.grouped {
left += th.Meta.Render(" · grouped")
}
// Orientation hint when the list is scrolled/windowed.
if m.winFirst >= 0 && (m.winFirst > 0 || m.winLast < len(m.view)-1) {
left += th.Meta.Render(fmt.Sprintf(" · showing %d%d of %d",
m.winFirst+1, m.winLast+1, len(m.view)))
}
if m.status != "" {
left += " " + th.Star.Render(m.status)
}
return m.truncate(" " + left)
}
// renderList builds the whole scrollable body as lines (section headers, host
// blocks, and spacers), then windows those lines so the cursor's host — and its
// section header, if any — stays visible. Line-based windowing lets grouped and
// flat modes share one code path and keeps the preview/footer from being pushed
// off-screen regardless of how many headers are on screen.
func (m *model) renderList() string {
th := m.th
m.winFirst, m.winLast = -1, -1
if len(m.view) == 0 {
msg := "no hosts in ~/.ssh/config"
if m.query != "" {
msg = "no hosts match \"" + m.query + "\""
}
return " " + th.Meta.Render(msg)
}
var lines []string
hostLine := make([]int, len(m.view)) // index of a host's first content line
headerLine := make([]int, len(m.view)) // index of the header line before it, or -1
for i := range m.view {
headerLine[i] = -1
if m.grouped {
if h, ok := m.headers[i]; ok {
if len(lines) > 0 {
lines = append(lines, "") // gap before a new section
}
headerLine[i] = len(lines)
lines = append(lines, m.truncate(m.renderSectionHeader(h)), "")
}
}
hostLine[i] = len(lines)
l1, l2 := m.renderHostLines(i)
lines = append(lines, m.truncate(l1), m.truncate(l2), "")
}
// Scroll so the cursor block (plus its header, if any) stays visible: start
// from the previous offset and nudge up or down only as needed.
budget := m.visibleListLines()
top := m.top
blockTop := hostLine[m.cursor]
if headerLine[m.cursor] >= 0 {
blockTop = headerLine[m.cursor]
}
if blockTop < top {
top = blockTop // scroll up to reveal the cursor (and its header)
}
blockBottom := hostLine[m.cursor] + 1 // the detail line
if blockBottom >= top+budget {
top = blockBottom - budget + 1 // scroll down to reveal the cursor
}
if maxTop := len(lines) - budget; top > maxTop {
top = maxTop
}
if top < 0 {
top = 0
}
m.top = top
end := top + budget
if end > len(lines) {
end = len(lines)
}
for i := range m.view {
if hostLine[i] >= top && hostLine[i] < end {
if m.winFirst == -1 {
m.winFirst = i
}
m.winLast = i
}
}
return strings.Join(lines[top:end], "\n")
}
// renderHostLines returns the two rendered lines for the host at view position i.
func (m *model) renderHostLines(i int) (string, string) {
th := m.th
r := m.rows[m.view[i]]
selected := i == m.cursor
marker := " "
if selected {
marker = th.Cursor.Render("▸ ")
}
numStr := th.Number.Render(fmt.Sprintf("%2d", i+1))
if selected {
numStr = th.NumberSel.Render(fmt.Sprintf("%2d", i+1))
}
star := " "
if r.fav {
star = th.Star.Render("★ ")
}
alias := th.Alias.Render(r.host.Alias)
if selected {
alias = th.AliasSel.Render(r.host.Alias)
}
line1 := fmt.Sprintf("%s%s %s%s", marker, numStr, star, alias)
if len(r.host.Tags) > 0 {
line1 += " " + m.renderPills(r.host.Tags)
}
dstyle := th.Detail
if selected {
dstyle = th.DetailSel
}
return line1, " " + dstyle.Render(summary(r.host))
}
// renderPills renders each tag in its own deterministic colour.
func (m *model) renderPills(tags []string) string {
parts := make([]string, len(tags))
for i, t := range tags {
parts[i] = m.th.TagStyle(t).Render(t)
}
return strings.Join(parts, " ")
}
// renderSectionHeader renders a grouped-mode section label, coloured to match
// the tag's pill (neutral for the Favourites/untagged sections).
func (m *model) renderSectionHeader(h sectionHead) string {
return " " + m.th.TagStyle(h.tag).Bold(true).Render("▊ "+h.title)
}
// summary builds a compact "user@hostname:port (via jump)" line for the list.
func summary(h sshconf.Host) string {
target := h.HostName
if target == "" {
target = h.Alias
}
s := target
if h.User != "" {
s = h.User + "@" + target
}
if h.Port != "" && h.Port != "22" {
s += ":" + h.Port
}
if h.ProxyJump != "" {
s += " via " + h.ProxyJump
}
return s
}
func (m *model) renderPreview() string {
th := m.th
if m.cursor < 0 || m.cursor >= len(m.view) {
return th.Preview.Render(th.Meta.Render("no selection"))
}
r := m.rows[m.view[m.cursor]]
h := r.host
rowsKV := [][2]string{
{"Host", h.Alias},
{"HostName", orDash(h.HostName)},
{"User", orDash(h.User)},
{"Port", orDash(defaultPort(h.Port))},
{"ProxyJump", orDash(h.ProxyJump)},
{"Last used", humanizeSince(r.last)},
}
var lines []string
for _, kv := range rowsKV {
lines = append(lines, fmt.Sprintf("%s %s",
th.PreviewK.Render(pad(kv[0], 10)), th.PreviewV.Render(kv[1])))
}
box := th.Preview.Width(m.previewWidth()).Render(strings.Join(lines, "\n"))
return box
}
func (m *model) previewWidth() int {
// Subtract the box border so the whole box fits the terminal.
w := m.width - 6
if w > 100 {
w = 100 // don't stretch the box uncomfortably wide
}
if w < 10 {
w = 10
}
return w
}
func (m *model) renderFooter() string {
th := m.th
if m.filtering {
bar := th.FilterBar.Render("/ " + m.query + "▌")
hint := th.Help.Render(" type to filter · enter connect · esc cancel")
return m.truncate(" " + bar + hint)
}
hints := []string{
"↑↓ move", "enter connect", "/ search", "s sort", "g group", "f fav", "q quit",
}
return m.truncate(" " + th.Help.Render(strings.Join(hints, " · ")))
}
// truncate clamps a rendered line to the terminal width to avoid wrapping,
// which would break the fixed 2-lines-per-row layout.
func (m *model) truncate(s string) string {
if m.width <= 0 {
return s
}
return lipgloss.NewStyle().MaxWidth(m.width).Render(s)
}
// humanizeSince renders a connection time as a compact relative phrase for the
// preview pane. A zero time means the host has never been connected to.
func humanizeSince(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
switch {
case d < 0:
return "just now"
case d < time.Minute:
return "just now"
case d < time.Hour:
return plural(int(d.Minutes()), "minute")
case d < 24*time.Hour:
return plural(int(d.Hours()), "hour")
case d < 30*24*time.Hour:
return plural(int(d.Hours()/24), "day")
case d < 365*24*time.Hour:
return plural(int(d.Hours()/(24*30)), "month")
default:
return plural(int(d.Hours()/(24*365)), "year")
}
}
func plural(n int, unit string) string {
if n <= 1 {
return fmt.Sprintf("%d %s ago", n, unit)
}
return fmt.Sprintf("%d %ss ago", n, unit)
}
func orDash(s string) string {
if s == "" {
return "—"
}
return s
}
func defaultPort(p string) string {
if p == "" {
return "22"
}
return p
}
func pad(s string, n int) string {
if len(s) >= n {
return s
}
return s + strings.Repeat(" ", n-len(s))
}
+264
View File
@@ -0,0 +1,264 @@
// Package sshconf parses ~/.ssh/config (following Include directives) into a
// flat list of connectable Host entries. It is strictly read-only: nothing in
// this package ever writes to the ssh config tree.
//
// A small hand-written parser is used instead of a third-party library so that
// two things the ssh_config format does not natively support are available:
// - the "# tag: a, b" comment convention placed above a Host block, and
// - full control over Include expansion order for stable, fast startup.
package sshconf
import (
"bufio"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// Host is a single connectable ssh alias with the preview fields dial surfaces.
// It intentionally holds only what the picker needs; connecting is done by
// exec'ing `ssh <Alias>` so the real ssh client re-resolves everything.
type Host struct {
Alias string
HostName string
User string
Port string
ProxyJump string
Tags []string
Source string // config file this entry was declared in
}
// maxIncludeDepth guards against pathological or cyclic Include chains.
const maxIncludeDepth = 16
// DefaultConfigPath returns ~/.ssh/config for the current user.
func DefaultConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".ssh", "config"), nil
}
// Parse reads the config at path, follows Include directives, and returns the
// concrete hosts in declaration order. A missing top-level config is not an
// error: it yields an empty list so a fresh machine still launches cleanly.
func Parse(path string) ([]Host, error) {
sshDir := filepath.Dir(path)
p := &parser{sshDir: sshDir, seen: map[string]bool{}}
if err := p.parseFile(path, 0); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
out := make([]Host, len(p.hosts))
for i, h := range p.hosts {
out[i] = *h
}
return out, nil
}
type parser struct {
sshDir string
seen map[string]bool // include cycle guard, by absolute path
hosts []*Host // pointers so block updates stay valid across appends
}
func (p *parser) parseFile(path string, depth int) error {
if depth > maxIncludeDepth {
return nil
}
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
if p.seen[abs] {
return nil // already parsed; avoid include cycles
}
p.seen[abs] = true
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
return p.parseReader(f, path, depth)
}
func (p *parser) parseReader(r io.Reader, source string, depth int) error {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var pendingTags []string // tags from a "# tag:" comment awaiting the next Host
var block []*Host // all aliases in the current Host block
// applyKV sets a keyword/value pair on every host in the current block.
applyKV := func(keyword, value string) {
for _, h := range block {
switch strings.ToLower(keyword) {
case "hostname":
h.HostName = value
case "user":
h.User = value
case "port":
h.Port = value
case "proxyjump":
h.ProxyJump = value
}
}
}
for sc.Scan() {
raw := sc.Text()
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
if tags, ok := parseTagComment(line); ok {
pendingTags = append(pendingTags, tags...)
}
continue
}
keyword, value := splitKeyword(line)
if keyword == "" {
continue
}
switch strings.ToLower(keyword) {
case "host":
block = nil
for _, alias := range strings.Fields(value) {
if isPattern(alias) {
continue // wildcard/negated patterns aren't connect targets
}
h := &Host{Alias: alias, Source: source, Tags: append([]string(nil), pendingTags...)}
p.hosts = append(p.hosts, h)
block = append(block, h)
}
pendingTags = nil
case "include":
// Include is resolved relative to ~/.ssh and may contain globs.
for _, pat := range strings.Fields(value) {
for _, inc := range p.expandInclude(pat) {
_ = p.parseFile(inc, depth+1)
}
}
pendingTags = nil
case "match":
// Match blocks are conditional; dial does not evaluate conditions.
// End the current block so stray keywords don't attach to a host.
block = nil
pendingTags = nil
default:
if len(block) > 0 {
applyKV(keyword, value)
}
}
}
return sc.Err()
}
// expandInclude resolves an Include pattern to concrete file paths. Relative
// patterns are anchored at ~/.ssh, matching OpenSSH behaviour.
func (p *parser) expandInclude(pattern string) []string {
pattern = expandTilde(pattern)
if !filepath.IsAbs(pattern) {
pattern = filepath.Join(p.sshDir, pattern)
}
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return nil
}
sort.Strings(matches) // deterministic order across machines
// Filter out directories; only include regular files.
var files []string
for _, m := range matches {
if fi, err := os.Stat(m); err == nil && !fi.IsDir() {
files = append(files, m)
}
}
return files
}
// parseTagComment recognises the "# tag: prod, riedel" convention and returns
// the individual tags. It is lenient about spacing and the tag/tags spelling.
func parseTagComment(line string) ([]string, bool) {
body := strings.TrimSpace(strings.TrimPrefix(line, "#"))
lower := strings.ToLower(body)
var rest string
switch {
case strings.HasPrefix(lower, "tags:"):
rest = body[len("tags:"):]
case strings.HasPrefix(lower, "tag:"):
rest = body[len("tag:"):]
default:
return nil, false
}
var tags []string
for _, t := range strings.Split(rest, ",") {
if t = strings.TrimSpace(t); t != "" {
tags = append(tags, t)
}
}
return tags, len(tags) > 0
}
// splitKeyword splits an ssh_config line into keyword and value. ssh_config
// allows "Key Value" and "Key = Value"; both are handled here.
func splitKeyword(line string) (string, string) {
// Strip trailing inline comments only when clearly separated.
if i := strings.Index(line, " #"); i >= 0 {
// keep '#' inside values rare; safe to drop trailing comment
line = strings.TrimSpace(line[:i])
}
if eq := strings.IndexByte(line, '='); eq >= 0 && !strings.ContainsAny(line[:eq], " \t") {
return strings.TrimSpace(line[:eq]), unquote(strings.TrimSpace(line[eq+1:]))
}
fields := strings.Fields(line)
if len(fields) == 0 {
return "", ""
}
if len(fields) == 1 {
return fields[0], ""
}
// Support "Key = Value" where '=' is its own token.
if fields[1] == "=" {
return fields[0], unquote(strings.TrimSpace(strings.Join(fields[2:], " ")))
}
return fields[0], unquote(strings.TrimSpace(strings.Join(fields[1:], " ")))
}
func unquote(s string) string {
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
return s
}
// isPattern reports whether an alias is a wildcard/negation pattern rather than
// a concrete connectable host.
func isPattern(alias string) bool {
return strings.ContainsAny(alias, "*?!")
}
func expandTilde(path string) string {
if path == "~" || strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
if path == "~" {
return home
}
return filepath.Join(home, path[2:])
}
}
return path
}
+106
View File
@@ -0,0 +1,106 @@
package sshconf
import (
"os"
"path/filepath"
"testing"
)
func TestParseWithIncludeAndTags(t *testing.T) {
dir := t.TempDir()
incDir := filepath.Join(dir, "conf.d")
if err := os.MkdirAll(incDir, 0o755); err != nil {
t.Fatal(err)
}
main := `# global defaults
Host *
ServerAliveInterval 60
# tag: prod, riedel
Host web1 web2
HostName 10.0.0.5
User deploy
Port 2222
ProxyJump bastion
Include conf.d/*.conf
Host bastion
HostName bastion.example.com
User admin
`
inc := `# tag: staging
Host stage
HostName stage.example.com
User dev = ignored
Host = eqform
HostName eq.example.com
`
if err := os.WriteFile(filepath.Join(dir, "config"), []byte(main), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(incDir, "a.conf"), []byte(inc), 0o644); err != nil {
t.Fatal(err)
}
hosts, err := Parse(filepath.Join(dir, "config"))
if err != nil {
t.Fatal(err)
}
byAlias := map[string]Host{}
for _, h := range hosts {
byAlias[h.Alias] = h
}
// Wildcard "Host *" must not appear as a connectable target.
if _, ok := byAlias["*"]; ok {
t.Error("wildcard host * should be skipped")
}
// Multiple aliases in one block share settings and tags.
for _, a := range []string{"web1", "web2"} {
h, ok := byAlias[a]
if !ok {
t.Fatalf("missing host %s", a)
}
if h.HostName != "10.0.0.5" || h.User != "deploy" || h.Port != "2222" || h.ProxyJump != "bastion" {
t.Errorf("%s wrong fields: %+v", a, h)
}
if len(h.Tags) != 2 || h.Tags[0] != "prod" || h.Tags[1] != "riedel" {
t.Errorf("%s wrong tags: %v", a, h.Tags)
}
}
// Included file's host and its tag.
stage, ok := byAlias["stage"]
if !ok {
t.Fatal("included host 'stage' missing")
}
if len(stage.Tags) != 1 || stage.Tags[0] != "staging" {
t.Errorf("stage tags wrong: %v", stage.Tags)
}
// "Host = eqform" equals-form should parse the alias.
if _, ok := byAlias["eqform"]; !ok {
t.Error("equals-form Host 'eqform' missing")
}
// Ordering: web1 before included stage before bastion.
order := []string{}
for _, h := range hosts {
order = append(order, h.Alias)
}
t.Logf("order: %v", order)
}
func TestParseMissingConfig(t *testing.T) {
hosts, err := Parse(filepath.Join(t.TempDir(), "does-not-exist"))
if err != nil {
t.Fatalf("missing config should not error: %v", err)
}
if hosts != nil {
t.Errorf("expected nil hosts, got %v", hosts)
}
}
+298
View File
@@ -0,0 +1,298 @@
// Package store persists dial's app data under ~/.ssh/.dial/ so it rides along
// on the user's existing Syncthing sync of the .ssh tree.
//
// Two files are used:
// - settings.json — yubikey on/off + slot, theme, sort preference, and the
// *hashed* backup recovery code (never any secret material).
// - data.json — recents/frequency stats and favorites.
//
// Because multiple machines may write concurrently through Syncthing, the
// contract here is deliberately forgiving: last-write-wins on save, and every
// load tolerates a missing, partial, or corrupt file by returning defaults
// rather than failing. Writes are atomic (temp file + rename) so a reader on
// another machine never observes a half-written file from a local write.
package store
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
// SortMode controls the resting order of the host list.
type SortMode string
const (
SortRecent SortMode = "recent" // most-recently-used first
SortFrequent SortMode = "frequent" // most-frequently-used first
SortAlpha SortMode = "alpha" // alphabetical by alias
)
// Next returns the following sort mode in the toggle cycle
// recent → frequent → alpha → recent.
func (s SortMode) Next() SortMode {
switch s {
case SortRecent:
return SortFrequent
case SortFrequent:
return SortAlpha
default:
return SortRecent
}
}
// Valid reports whether s is a known sort mode.
func (s SortMode) Valid() bool {
return s == SortRecent || s == SortFrequent || s == SortAlpha
}
// Settings holds user preferences and the hashed recovery code. It contains
// nothing secret: the YubiKey secret never leaves the key in HMAC challenge-
// response mode, and the recovery code is stored only as a salted hash.
type Settings struct {
YubiKeyEnabled bool `json:"yubikey_enabled"`
YubiKeySlot int `json:"yubikey_slot"` // 1 or 2 (default 2)
Theme string `json:"theme"` // "dark" (default) | "light"
Sort SortMode `json:"sort"`
GroupByTag bool `json:"group_by_tag"` // section the list by tag
// YubiKey holds the non-secret challenge and the salted hash of its expected
// response, used to verify the physical key at launch. It is empty until the
// gate is enrolled.
YubiKey YubiKeyConfig `json:"yubikey,omitempty"`
// Recovery is the salted hash of the one-time backup code. Empty when the
// gate is disabled or no code is currently active.
Recovery RecoveryHash `json:"recovery,omitempty"`
}
// YubiKeyConfig captures what dial needs to verify a YubiKey without storing any
// secret. Challenge is random and non-secret; ResponseHash is a salted SHA-256
// of the key's HMAC response to that challenge, which reveals nothing usable
// and cannot reproduce the response without the physical key.
type YubiKeyConfig struct {
Challenge string `json:"challenge"` // hex-encoded random challenge
ResponseSalt string `json:"response_salt"` // hex-encoded random salt
ResponseHash string `json:"response_hash"` // hex sha256(salt || response)
}
// IsEnrolled reports whether a YubiKey verification record is present.
func (y YubiKeyConfig) IsEnrolled() bool {
return y.Challenge != "" && y.ResponseHash != "" && y.ResponseSalt != ""
}
// RecoveryHash is the persisted, non-reversible form of a backup recovery code.
type RecoveryHash struct {
Salt string `json:"salt"` // hex-encoded random salt
Hash string `json:"hash"` // hex-encoded sha256(salt || code)
}
// IsSet reports whether an active recovery code hash is present.
func (r RecoveryHash) IsSet() bool { return r.Hash != "" && r.Salt != "" }
// DefaultSettings returns the settings used when none are stored or the stored
// file is unreadable/corrupt.
func DefaultSettings() Settings {
return Settings{
YubiKeyEnabled: false,
YubiKeySlot: 2,
Theme: "dark",
Sort: SortRecent,
}
}
// HostStat records usage of a single host alias for MRU/MFU sorting.
type HostStat struct {
Count int `json:"count"`
LastUsed time.Time `json:"last_used"`
}
// Data holds mutable usage state that changes on nearly every run.
type Data struct {
Stats map[string]*HostStat `json:"stats"`
Favorites []string `json:"favorites"`
// LastUnlock records the most recent successful gate unlock. Unused by the
// every-launch gate policy but persisted so an idle-timeout policy could be
// added later without a data migration.
LastUnlock time.Time `json:"last_unlock,omitempty"`
}
// DefaultData returns empty usage data.
func DefaultData() Data {
return Data{Stats: map[string]*HostStat{}}
}
// mu serialises local writes so two goroutines in this process can't interleave
// temp files. It does nothing about cross-machine writes (Syncthing) — that is
// intentionally last-write-wins.
var mu sync.Mutex
// Dir returns ~/.ssh/.dial, creating it (0700) if necessary.
func Dir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
d := filepath.Join(home, ".ssh", ".dial")
if err := os.MkdirAll(d, 0o700); err != nil {
return "", err
}
return d, nil
}
func settingsPath() (string, error) {
d, err := Dir()
if err != nil {
return "", err
}
return filepath.Join(d, "settings.json"), nil
}
func dataPath() (string, error) {
d, err := Dir()
if err != nil {
return "", err
}
return filepath.Join(d, "data.json"), nil
}
// LoadSettings reads settings, falling back to defaults on any error including a
// partial/corrupt file mid-sync. It never returns an error for that reason so
// the app always starts.
func LoadSettings() Settings {
def := DefaultSettings()
p, err := settingsPath()
if err != nil {
return def
}
b, err := os.ReadFile(p)
if err != nil {
return def
}
var s Settings
if err := json.Unmarshal(b, &s); err != nil {
return def // partial write / corruption: fall back
}
// Repair any nonsensical values that a bad/partial write could leave.
if s.YubiKeySlot != 1 && s.YubiKeySlot != 2 {
s.YubiKeySlot = def.YubiKeySlot
}
if s.Theme == "" {
s.Theme = def.Theme
}
if !s.Sort.Valid() {
s.Sort = def.Sort
}
return s
}
// SaveSettings writes settings atomically. Errors are returned because a failed
// settings write (e.g. toggling the gate) is meaningful to the caller.
func SaveSettings(s Settings) error {
p, err := settingsPath()
if err != nil {
return err
}
return writeAtomic(p, s)
}
// LoadData reads usage data, falling back to empty defaults on any error.
func LoadData() Data {
def := DefaultData()
p, err := dataPath()
if err != nil {
return def
}
b, err := os.ReadFile(p)
if err != nil {
return def
}
var d Data
if err := json.Unmarshal(b, &d); err != nil {
return def
}
if d.Stats == nil {
d.Stats = map[string]*HostStat{}
}
return d
}
// SaveData writes usage data atomically. A failed data write is non-fatal to
// the user's flow (they still connected), so callers may choose to ignore it.
func SaveData(d Data) error {
p, err := dataPath()
if err != nil {
return err
}
if d.Stats == nil {
d.Stats = map[string]*HostStat{}
}
return writeAtomic(p, d)
}
// Record bumps the usage stats for an alias. It loads, mutates, and saves so a
// connect can persist in one call; the save error is returned for logging but
// is safe to ignore.
func Record(alias string) error {
d := LoadData()
st := d.Stats[alias]
if st == nil {
st = &HostStat{}
d.Stats[alias] = st
}
st.Count++
st.LastUsed = time.Now()
return SaveData(d)
}
// ToggleFavorite adds or removes an alias from favorites and persists.
func ToggleFavorite(alias string) (bool, error) {
d := LoadData()
for i, f := range d.Favorites {
if f == alias {
d.Favorites = append(d.Favorites[:i], d.Favorites[i+1:]...)
return false, SaveData(d)
}
}
d.Favorites = append(d.Favorites, alias)
return true, SaveData(d)
}
// writeAtomic marshals v to JSON and writes it to path via a temp file + rename
// so concurrent readers never see a partial file.
func writeAtomic(path string, v any) error {
mu.Lock()
defer mu.Unlock()
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
// Best-effort cleanup if we bail before the rename.
defer os.Remove(tmpName)
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpName, 0o600); err != nil {
return err
}
return os.Rename(tmpName, path)
}
+82
View File
@@ -0,0 +1,82 @@
package store
import (
"os"
"path/filepath"
"testing"
)
// withHome points UserHomeDir at a temp dir for the duration of a test.
func withHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
// Ensure ~/.ssh exists as the store nests under it.
if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil {
t.Fatal(err)
}
return home
}
func TestSettingsRoundTrip(t *testing.T) {
withHome(t)
s := DefaultSettings()
s.YubiKeyEnabled = true
s.Sort = SortFrequent
if err := SaveSettings(s); err != nil {
t.Fatal(err)
}
got := LoadSettings()
if !got.YubiKeyEnabled || got.Sort != SortFrequent {
t.Errorf("round trip mismatch: %+v", got)
}
}
func TestLoadCorruptFallsBackToDefaults(t *testing.T) {
withHome(t)
d, _ := Dir()
// Simulate a mid-Syncthing partial write.
if err := os.WriteFile(filepath.Join(d, "settings.json"), []byte(`{"yubikey_enabled": tr`), 0o600); err != nil {
t.Fatal(err)
}
got := LoadSettings()
def := DefaultSettings()
if got != def {
t.Errorf("corrupt settings should yield defaults, got %+v", got)
}
}
func TestRecordAndSort(t *testing.T) {
withHome(t)
if err := Record("web1"); err != nil {
t.Fatal(err)
}
if err := Record("web1"); err != nil {
t.Fatal(err)
}
if err := Record("db"); err != nil {
t.Fatal(err)
}
d := LoadData()
if d.Stats["web1"].Count != 2 {
t.Errorf("web1 count = %d, want 2", d.Stats["web1"].Count)
}
if d.Stats["db"].Count != 1 {
t.Errorf("db count = %d, want 1", d.Stats["db"].Count)
}
}
func TestToggleFavorite(t *testing.T) {
withHome(t)
on, err := ToggleFavorite("web1")
if err != nil || !on {
t.Fatalf("expected favorite on, got %v %v", on, err)
}
off, err := ToggleFavorite("web1")
if err != nil || off {
t.Fatalf("expected favorite off, got %v %v", off, err)
}
if len(LoadData().Favorites) != 0 {
t.Error("favorites should be empty after toggle off")
}
}
+105
View File
@@ -0,0 +1,105 @@
// Package theme centralises dial's visual styling. The brief makes low-light
// legibility a hard requirement: generous spacing, high contrast, and no dense
// dim text. These styles encode that — the "muted" tones are still clearly
// readable, not the near-invisible grey common in dense pickers.
package theme
import (
"hash/fnv"
"github.com/charmbracelet/lipgloss"
)
// Theme holds the styles used across the picker.
type Theme struct {
Title lipgloss.Style
Meta lipgloss.Style // header meta / hints
Number lipgloss.Style // list index
NumberSel lipgloss.Style
Alias lipgloss.Style // host alias, resting
AliasSel lipgloss.Style // host alias, highlighted
Detail lipgloss.Style // secondary line (hostname/user)
DetailSel lipgloss.Style
Tag lipgloss.Style
Star lipgloss.Style
Cursor lipgloss.Style // the selection bar/marker
Preview lipgloss.Style // preview box border
PreviewK lipgloss.Style // preview key column
PreviewV lipgloss.Style // preview value column
FilterBar lipgloss.Style
Help lipgloss.Style
Warn lipgloss.Style
// tagPalette is a set of distinct high-contrast hues used to colour tag
// pills and tag section headers, assigned deterministically per tag name.
tagPalette []lipgloss.AdaptiveColor
base lipgloss.Style
}
// TagStyle returns the pill style for a tag, colour chosen deterministically by
// the tag name so the same tag is always the same colour. An empty tag name
// (used for the Favourites/untagged sections) gets a neutral style.
func (t Theme) TagStyle(tag string) lipgloss.Style {
if tag == "" {
return t.Meta
}
h := fnv.New32a()
_, _ = h.Write([]byte(tag))
c := t.tagPalette[int(h.Sum32())%len(t.tagPalette)]
return t.base.Foreground(c)
}
// adaptive picks a colour for dark vs light backgrounds.
func adaptive(dark, light string) lipgloss.AdaptiveColor {
return lipgloss.AdaptiveColor{Dark: dark, Light: light}
}
// New returns the theme for the given name ("dark" or "light"). Both are built
// from the same high-contrast palette; lipgloss adapts per background so a
// single definition stays readable either way.
func New(name string) Theme {
// High-contrast, deliberately bright accents for low-light legibility.
accent := adaptive("#7DD3FC", "#0369A1") // sky — highlighted alias
accent2 := adaptive("#FDE047", "#A16207") // amber — numbers/stars
fg := adaptive("#F1F5F9", "#0F172A") // primary text (near-white / near-black)
muted := adaptive("#CBD5E1", "#334155") // secondary text — still clearly readable
faint := adaptive("#94A3B8", "#475569") // hints; the dimmest we allow
tag := adaptive("#86EFAC", "#15803D") // green tags
warn := adaptive("#FCA5A5", "#B91C1C")
base := lipgloss.NewStyle()
// Distinct, high-contrast hues for tag pills (dark / light variants).
tagPalette := []lipgloss.AdaptiveColor{
adaptive("#7DD3FC", "#0369A1"), // sky
adaptive("#86EFAC", "#15803D"), // green
adaptive("#FDBA74", "#C2410C"), // orange
adaptive("#F9A8D4", "#BE185D"), // pink
adaptive("#C4B5FD", "#6D28D9"), // violet
adaptive("#5EEAD4", "#0F766E"), // teal
adaptive("#FDE047", "#A16207"), // amber
adaptive("#FCA5A5", "#B91C1C"), // red
}
return Theme{
base: base,
tagPalette: tagPalette,
Title: base.Bold(true).Foreground(fg),
Meta: base.Foreground(faint),
Number: base.Foreground(faint),
NumberSel: base.Bold(true).Foreground(accent2),
Alias: base.Foreground(fg),
AliasSel: base.Bold(true).Foreground(accent),
Detail: base.Foreground(muted),
DetailSel: base.Foreground(muted),
Tag: base.Foreground(tag),
Star: base.Foreground(accent2),
Cursor: base.Bold(true).Foreground(accent),
Preview: base.BorderStyle(lipgloss.RoundedBorder()).BorderForeground(faint).Padding(0, 2),
PreviewK: base.Foreground(faint),
PreviewV: base.Foreground(fg),
FilterBar: base.Bold(true).Foreground(accent2),
Help: base.Foreground(faint),
Warn: base.Bold(true).Foreground(warn),
}
}
+95
View File
@@ -0,0 +1,95 @@
package yubikey
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/hex"
"strings"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
// Recovery code format: 6 dash-separated groups of 5 base32 characters
// (XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX), i.e. 30 chars ≈ 150 bits of entropy.
// It is shown to the user exactly once at enrollment and stored only as a
// salted hash. It is single-use: after a successful recovery unlock the caller
// invalidates it and issues a fresh one.
const (
recoveryGroups = 6
recoveryGroupLen = 5
recoveryChars = recoveryGroups * recoveryGroupLen // 30
recoveryRawBytes = 19 // 19 bytes -> 31 base32 chars, sliced to 30
)
// base32NoPad uses the standard RFC 4648 alphabet without padding.
var base32NoPad = base32.StdEncoding.WithPadding(base32.NoPadding)
// GenerateRecoveryCode returns a fresh human-formatted recovery code together
// with its salted hash for storage. The plaintext is returned only here and is
// expected to be displayed once and then discarded by the caller.
func GenerateRecoveryCode() (code string, h store.RecoveryHash, err error) {
raw := make([]byte, recoveryRawBytes)
if _, err = rand.Read(raw); err != nil {
return "", store.RecoveryHash{}, err
}
enc := base32NoPad.EncodeToString(raw)[:recoveryChars]
var b strings.Builder
for i := 0; i < recoveryGroups; i++ {
if i > 0 {
b.WriteByte('-')
}
b.WriteString(enc[i*recoveryGroupLen : (i+1)*recoveryGroupLen])
}
code = b.String()
salt := make([]byte, 16)
if _, err = rand.Read(salt); err != nil {
return "", store.RecoveryHash{}, err
}
h = store.RecoveryHash{
Salt: hex.EncodeToString(salt),
Hash: hex.EncodeToString(hashCode(salt, normalizeCode(code))),
}
return code, h, nil
}
// VerifyRecoveryCode reports whether input matches the stored recovery hash.
// Input is normalised (uppercased, separators/whitespace removed) before
// comparison so the user can type it with or without dashes.
func VerifyRecoveryCode(input string, h store.RecoveryHash) bool {
if !h.IsSet() {
return false
}
salt, err := hex.DecodeString(h.Salt)
if err != nil {
return false
}
want, err := hex.DecodeString(h.Hash)
if err != nil {
return false
}
got := hashCode(salt, normalizeCode(input))
return subtle.ConstantTimeCompare(got, want) == 1
}
// normalizeCode strips separators/whitespace and uppercases so that "abc de"
// and "ABCDE" compare equal.
func normalizeCode(s string) string {
var b strings.Builder
for _, r := range strings.ToUpper(s) {
if (r >= 'A' && r <= 'Z') || (r >= '2' && r <= '7') {
b.WriteRune(r)
}
}
return b.String()
}
func hashCode(salt []byte, normalized string) []byte {
h := sha256.New()
h.Write(salt)
h.Write([]byte(normalized))
return h.Sum(nil)
}
+161
View File
@@ -0,0 +1,161 @@
// Package yubikey implements dial's optional launch gate. It performs an
// offline HMAC-SHA1 challenge-response against a YubiKey by shelling out to
// Yubico's official `ykman` CLI (slot configurable, default 2), and manages a
// single-use backup recovery code so the user can't be permanently locked out.
//
// Per the brief, this feature intentionally depends on `ykman` being installed
// separately — it trades the "single static binary" goal for leaning on
// Yubico's own well-tested tool. dial checks for `ykman` on PATH and reports a
// clear error if it is missing.
//
// Nothing secret is persisted: enrollment stores only a random challenge and a
// salted hash of the key's response. The YubiKey secret never leaves the key.
package yubikey
import (
"bufio"
"bytes"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"os/exec"
"strings"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
// ErrYkmanMissing is returned when the gate is enabled but `ykman` is not on
// PATH. Callers surface this as an actionable message.
var ErrYkmanMissing = errors.New("ykman not found on PATH: install Yubico's ykman CLI to use the YubiKey gate (https://developers.yubico.com/yubikey-manager/)")
// ErrKeyMismatch means the connected key produced a response that does not
// match the enrolled one (wrong key, wrong slot, or no key present).
var ErrKeyMismatch = errors.New("YubiKey response did not match the enrolled key")
// runYkman executes ykman and returns stdout. It is a package variable so tests
// can stub the external call without a physical key.
var runYkman = func(args ...string) ([]byte, error) {
cmd := exec.Command("ykman", args...)
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errBuf.String())
if msg != "" {
return nil, fmt.Errorf("ykman: %s", msg)
}
return nil, err
}
return out.Bytes(), nil
}
// Available reports whether the ykman CLI is installed and on PATH.
func Available() error {
if _, err := exec.LookPath("ykman"); err != nil {
return ErrYkmanMissing
}
return nil
}
// challenge runs an HMAC-SHA1 challenge-response against the given slot. The
// challenge is passed as hex; ykman blocks for a physical touch if the slot was
// programmed to require one. The response is returned as raw bytes.
func challenge(slot int, chal []byte) ([]byte, error) {
if slot != 1 && slot != 2 {
return nil, fmt.Errorf("invalid slot %d (must be 1 or 2)", slot)
}
out, err := runYkman("otp", "calculate", fmt.Sprintf("%d", slot), hex.EncodeToString(chal))
if err != nil {
return nil, err
}
resp, err := parseResponse(out)
if err != nil {
return nil, err
}
return resp, nil
}
// parseResponse extracts the hex response from ykman's stdout, tolerating extra
// whitespace or trailing lines.
func parseResponse(out []byte) ([]byte, error) {
var last string
sc := bufio.NewScanner(bytes.NewReader(out))
for sc.Scan() {
if t := strings.TrimSpace(sc.Text()); t != "" {
last = t
}
}
last = strings.TrimSpace(last)
if last == "" {
return nil, errors.New("ykman returned an empty response")
}
b, err := hex.DecodeString(last)
if err != nil {
return nil, fmt.Errorf("unexpected ykman output %q: %w", last, err)
}
return b, nil
}
// Enroll performs a challenge-response now (prompting a touch) and returns a
// YubiKeyConfig that records the challenge and a salted hash of the response.
// This is called when the user turns the gate on.
func Enroll(slot int) (store.YubiKeyConfig, error) {
chal := make([]byte, 20) // 160-bit challenge, matching HMAC-SHA1's block use
if _, err := rand.Read(chal); err != nil {
return store.YubiKeyConfig{}, err
}
resp, err := challenge(slot, chal)
if err != nil {
return store.YubiKeyConfig{}, err
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return store.YubiKeyConfig{}, err
}
return store.YubiKeyConfig{
Challenge: hex.EncodeToString(chal),
ResponseSalt: hex.EncodeToString(salt),
ResponseHash: hex.EncodeToString(hashResponse(salt, resp)),
}, nil
}
// Verify re-runs the enrolled challenge and checks the response hash matches.
// It returns ErrKeyMismatch if the physical key does not produce the expected
// response. A touch is required if the slot demands one.
func Verify(cfg store.YubiKeyConfig, slot int) error {
if !cfg.IsEnrolled() {
return errors.New("no YubiKey enrolled")
}
chal, err := hex.DecodeString(cfg.Challenge)
if err != nil {
return fmt.Errorf("corrupt stored challenge: %w", err)
}
salt, err := hex.DecodeString(cfg.ResponseSalt)
if err != nil {
return fmt.Errorf("corrupt stored salt: %w", err)
}
want, err := hex.DecodeString(cfg.ResponseHash)
if err != nil {
return fmt.Errorf("corrupt stored hash: %w", err)
}
resp, err := challenge(slot, chal)
if err != nil {
return err
}
got := hashResponse(salt, resp)
if subtle.ConstantTimeCompare(got, want) != 1 {
return ErrKeyMismatch
}
return nil
}
// hashResponse computes sha256(salt || response).
func hashResponse(salt, resp []byte) []byte {
h := sha256.New()
h.Write(salt)
h.Write(resp)
return h.Sum(nil)
}
+103
View File
@@ -0,0 +1,103 @@
package yubikey
import (
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"strings"
"testing"
"gitea.apointless.space/bsncubed/dial/internal/store"
)
// fakeKey simulates a YubiKey slot programmed with a fixed secret by computing
// HMAC-SHA1 over the challenge, mirroring ykman's challenge-response output.
func fakeKey(t *testing.T, secret []byte) func(args ...string) ([]byte, error) {
t.Helper()
return func(args ...string) ([]byte, error) {
// args: otp calculate <slot> <hexchallenge>
if len(args) < 4 {
t.Fatalf("unexpected ykman args: %v", args)
}
chal, err := hex.DecodeString(args[3])
if err != nil {
t.Fatalf("bad challenge hex: %v", err)
}
m := hmac.New(sha1.New, secret)
m.Write(chal)
return []byte(hex.EncodeToString(m.Sum(nil)) + "\n"), nil
}
}
func TestEnrollThenVerifySucceeds(t *testing.T) {
orig := runYkman
defer func() { runYkman = orig }()
runYkman = fakeKey(t, []byte("secret-slot-2"))
cfg, err := Enroll(2)
if err != nil {
t.Fatal(err)
}
if !cfg.IsEnrolled() {
t.Fatal("config should be enrolled")
}
if err := Verify(cfg, 2); err != nil {
t.Errorf("verify with same key should succeed: %v", err)
}
}
func TestVerifyWrongKeyFails(t *testing.T) {
orig := runYkman
defer func() { runYkman = orig }()
runYkman = fakeKey(t, []byte("correct-secret"))
cfg, err := Enroll(2)
if err != nil {
t.Fatal(err)
}
// Now a different physical key (different secret) is present.
runYkman = fakeKey(t, []byte("attacker-secret"))
if err := Verify(cfg, 2); err != ErrKeyMismatch {
t.Errorf("expected ErrKeyMismatch, got %v", err)
}
}
func TestInvalidSlot(t *testing.T) {
if _, err := Enroll(3); err == nil {
t.Error("slot 3 should be rejected")
}
}
func TestRecoveryCodeRoundTrip(t *testing.T) {
code, h, err := GenerateRecoveryCode()
if err != nil {
t.Fatal(err)
}
// Format: 6 groups of 5 chars separated by dashes.
parts := strings.Split(code, "-")
if len(parts) != recoveryGroups {
t.Fatalf("expected %d groups, got %d (%q)", recoveryGroups, len(parts), code)
}
for _, p := range parts {
if len(p) != recoveryGroupLen {
t.Errorf("group %q wrong length", p)
}
}
if !VerifyRecoveryCode(code, h) {
t.Error("exact code should verify")
}
// With separators stripped / lowercased it should still verify.
if !VerifyRecoveryCode(strings.ToLower(strings.ReplaceAll(code, "-", " ")), h) {
t.Error("normalised code should verify")
}
if VerifyRecoveryCode("WRONG-WRONG-WRONG-WRONG-WRONG-WRONG", h) {
t.Error("wrong code should not verify")
}
}
func TestVerifyRecoveryUnsetHash(t *testing.T) {
if VerifyRecoveryCode("anything", store.RecoveryHash{}) {
t.Error("empty hash should never verify")
}
}