// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later // Package store persists enrollment tokens and device credentials as a single // JSON file in the state dir. Deliberately boring: the expected scale is a // handful of devices per homelab server; a database would be ceremony. package store import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "os" "path/filepath" "sync" "echo-lot.app/server/internal/adminauth" "time" ) type EnrollToken struct { // SHA-256 of the token, hex. Plaintext exists only in the admin's hands. Hash string `json:"hash"` Expires time.Time `json:"expires"` Used bool `json:"used"` Note string `json:"note,omitempty"` } type Device struct { ID string `json:"id"` // SHA-256 of the bearer credential, hex. The credential itself is also // the HKDF ikm for session keys (spec §2.4), so the server needs it in // cleartext at session time — kept alongside, file mode 0600. // TODO(hardening): move cleartext creds to a separate keyring file. CredentialHash string `json:"credential_hash"` Credential string `json:"credential"` Enrolled time.Time `json:"enrolled"` Name string `json:"name,omitempty"` // The account this device belongs to, as issuer#subject — empty when nobody has signed in // on it. Enrollment and sign-in are deliberately separate: a device is admitted by an // operator's token, and only later (if ever) associated with a person. Servers that accept // anonymous uploads never need the second step. AccountID string `json:"account_id,omitempty"` AccountName string `json:"account_name,omitempty"` LinkedAt time.Time `json:"linked_at,omitempty"` } // LinkedToAccount reports whether a person has signed in on this device. func (d Device) LinkedToAccount() bool { return d.AccountID != "" } type Store struct { mu sync.Mutex path string data fileData } type fileData struct { Tokens []EnrollToken `json:"tokens"` Devices []Device `json:"devices"` // The break-glass admin. Absent until an operator sets one. LocalAdmin *adminauth.Credential `json:"local_admin,omitempty"` // Signing secret for admin session cookies. Persisted so sessions survive a restart; // deleting it from the state file invalidates every session at once, which is how an // operator revokes them. SessionSecret string `json:"session_secret,omitempty"` } func Open(stateDir string) (*Store, error) { if err := os.MkdirAll(stateDir, 0o700); err != nil { return nil, err } s := &Store{path: filepath.Join(stateDir, "devices.json")} b, err := os.ReadFile(s.path) switch { case errors.Is(err, os.ErrNotExist): return s, nil case err != nil: return nil, err } return s, json.Unmarshal(b, &s.data) } func (s *Store) save() error { b, err := json.MarshalIndent(s.data, "", " ") if err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return err } return os.Rename(tmp, s.path) } func hashOf(tok string) string { h := sha256.Sum256([]byte(tok)) return hex.EncodeToString(h[:]) } func randomHex(n int) string { b := make([]byte, n) _, _ = rand.Read(b) return hex.EncodeToString(b) } // NewEnrollToken mints a single-use token (returned in cleartext once). func (s *Store) NewEnrollToken(ttl time.Duration, note string) (string, error) { s.mu.Lock() defer s.mu.Unlock() tok := randomHex(24) s.data.Tokens = append(s.data.Tokens, EnrollToken{ Hash: hashOf(tok), Expires: time.Now().Add(ttl), Note: note, }) return tok, s.save() } // Redeem consumes a valid enrollment token and mints a device credential. func (s *Store) Redeem(token, name string) (*Device, error) { s.mu.Lock() defer s.mu.Unlock() h := hashOf(token) for i := range s.data.Tokens { t := &s.data.Tokens[i] if subtle.ConstantTimeCompare([]byte(t.Hash), []byte(h)) == 1 { if t.Used || time.Now().After(t.Expires) { return nil, errors.New("token used or expired") } t.Used = true d := Device{ ID: randomHex(8), Credential: randomHex(32), Enrolled: time.Now().UTC(), Name: name, } d.CredentialHash = hashOf(d.Credential) s.data.Devices = append(s.data.Devices, d) return &d, s.save() } } return nil, errors.New("unknown token") } // DeviceByCredential authenticates a bearer credential. // LinkAccount ties a device to a signed-in identity, or clears it when accountID is empty. func (s *Store) LinkAccount(deviceID, accountID, displayName string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.data.Devices { if s.data.Devices[i].ID != deviceID { continue } s.data.Devices[i].AccountID = accountID s.data.Devices[i].AccountName = displayName if accountID == "" { s.data.Devices[i].LinkedAt = time.Time{} } else { s.data.Devices[i].LinkedAt = time.Now().UTC() } return s.save() } return errors.New("no such device") } // Devices returns a copy of the device list, for the admin UI. func (s *Store) Devices() []Device { s.mu.Lock() defer s.mu.Unlock() return append([]Device(nil), s.data.Devices...) } // DeleteDevice revokes a device: its credential stops working immediately. func (s *Store) DeleteDevice(id string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.data.Devices { if s.data.Devices[i].ID == id { s.data.Devices = append(s.data.Devices[:i], s.data.Devices[i+1:]...) return s.save() } } return errors.New("no such device") } // SetLocalAdmin stores (or replaces) the break-glass admin password. func (s *Store) SetLocalAdmin(c adminauth.Credential) error { s.mu.Lock() defer s.mu.Unlock() s.data.LocalAdmin = &c return s.save() } // LocalAdmin returns the configured break-glass admin, or nil. func (s *Store) LocalAdmin() *adminauth.Credential { s.mu.Lock() defer s.mu.Unlock() if s.data.LocalAdmin == nil { return nil } c := *s.data.LocalAdmin return &c } // ClearLocalAdmin removes the break-glass admin. func (s *Store) ClearLocalAdmin() error { s.mu.Lock() defer s.mu.Unlock() s.data.LocalAdmin = nil return s.save() } // SessionSecret returns the admin session signing secret, creating one on first use. func (s *Store) SessionSecret() ([]byte, error) { s.mu.Lock() defer s.mu.Unlock() if s.data.SessionSecret != "" { if b, err := hex.DecodeString(s.data.SessionSecret); err == nil && len(b) >= 32 { return b, nil } } b, err := adminauth.NewSecret() if err != nil { return nil, err } s.data.SessionSecret = hex.EncodeToString(b) return b, s.save() } func (s *Store) DeviceByCredential(cred string) *Device { s.mu.Lock() defer s.mu.Unlock() h := hashOf(cred) for i := range s.data.Devices { if subtle.ConstantTimeCompare([]byte(s.data.Devices[i].CredentialHash), []byte(h)) == 1 { d := s.data.Devices[i] return &d } } return nil }