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