8af44dfcd2
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>
299 lines
8.5 KiB
Go
299 lines
8.5 KiB
Go
// 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)
|
|
}
|