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
+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")
}
}