package config import ( "crypto/ed25519" "encoding/base64" "fmt" "os" "sync" "time" ) // LiveDevices is the node's paired-device set, kept in step with the config file. // // The daemon used to load its device list once at startup and hold it in memory forever. That made // the documented revocation path not work: the operator runs `--pair`, POSTs /destroy, the file on // disk is emptied — and the running daemon keeps answering with the list it read at boot, then // re-pushes that list to the relay on its next handshake. So a revoked phone stayed authorised at // the node and stayed routable at the relay, and the only way to make the revocation real was to // restart the service, which the deployment document does not say. // // The design anticipated this: the Devices interface in internal/agent notes that the first // implementation is a static list and the intended one is a snapshot pulled from the relay. This is // the smaller step that makes revocation work without a new wire protocol — the file is already the // authority, it just was not being re-read. // // Reloading is driven by the file's modification time and size rather than a timer, so a poll cycle // pays one stat and only re-reads when something actually changed. A read that fails leaves the // previous set in place: a transient filesystem error must not turn into "nobody is paired", which // would lock the user out of a node whose configuration is fine. type LiveDevices struct { path string mu sync.RWMutex keys map[string]ed25519.PublicKey devices []Device stamp fileStamp } // fileStamp is what a change is detected by. Modification time alone is not enough: two writes // inside the same clock tick are possible, and a pairing followed immediately by a destroy is // exactly that. The size catches those, and the inode catches a rename over the file — which is how // Save writes, so every legitimate change is a rename. type fileStamp struct { modTime time.Time size int64 inode uint64 } // NewLiveDevices reads the device list and remembers how to tell when it changes. func NewLiveDevices(path string) (*LiveDevices, error) { d := &LiveDevices{path: path} if err := d.reload(); err != nil { return nil, err } return d, nil } // SigningKey returns the phone's public key, or nil when it is not paired. // // The read is on the hot path — every envelope is verified through it — so it takes the read lock // and does no I/O. Reloading happens in Refresh, which the loop calls once per cycle. func (d *LiveDevices) SigningKey(app string) ed25519.PublicKey { d.mu.RLock() defer d.mu.RUnlock() return d.keys[app] } // Len reports how many phones are trusted. func (d *LiveDevices) Len() int { d.mu.RLock() defer d.mu.RUnlock() return len(d.keys) } // Devices returns a copy of the current paired-device list, for the handshake's registration push. func (d *LiveDevices) Devices() []Device { d.mu.RLock() defer d.mu.RUnlock() return append([]Device(nil), d.devices...) } // Refresh re-reads the config if it changed on disk, and reports whether it did. // // The caller uses the report to decide whether to re-assert the set at the relay: a destroy that // only took effect locally would leave the revoked bearer routable, and the phone would get // "unknown device" from a node that had stopped trusting it rather than a clean refusal. func (d *LiveDevices) Refresh() (bool, error) { stamp, err := stampOf(d.path) if err != nil { return false, err } d.mu.RLock() unchanged := stamp == d.stamp d.mu.RUnlock() if unchanged { return false, nil } if err := d.reload(); err != nil { return false, err } return true, nil } func (d *LiveDevices) reload() error { cfg, err := loadFile(d.path) if err != nil { return err } // Pair mode is the right validation here: a node whose list was just destroyed has zero devices // and must still be able to load, because that state is the whole point of a revocation. if err := cfg.Validate(true); err != nil { return fmt.Errorf("config: %s: %w", d.path, err) } keys := make(map[string]ed25519.PublicKey, len(cfg.Devices)) for _, device := range cfg.Devices { raw, err := base64.StdEncoding.Strict().DecodeString(device.SignKey) if err != nil || len(raw) != ed25519.PublicKeySize { return fmt.Errorf("config: device %s has an unusable signKey", device.ID) } keys[device.ID] = ed25519.PublicKey(raw) } stamp, err := stampOf(d.path) if err != nil { return err } d.mu.Lock() d.keys = keys d.devices = append([]Device(nil), cfg.Devices...) d.stamp = stamp d.mu.Unlock() return nil } func stampOf(path string) (fileStamp, error) { info, err := os.Stat(path) if err != nil { return fileStamp{}, fmt.Errorf("config: %w", err) } return fileStamp{modTime: info.ModTime(), size: info.Size(), inode: inodeOf(info)}, nil }