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