// SPDX-License-Identifier: Apache-2.0 // Package physical owns enrolled actuator identities and their private keys. package physical import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" "homekey.local/provisioner/drivers/sesame" ) type Record struct { ID string `json:"id"` Name string `json:"name"` Driver string `json:"driver"` Target sesame.Target `json:"target"` } type View struct { Record State sesame.State `json:"state"` } type Enrollment struct { Name string `json:"name"` Target sesame.Target `json:"target"` Mode string `json:"mode"` // Accept the SDK's toKeyJson fields, bound to the scanned device identity. Key *struct { UUID string `json:"deviceUUID"` Model string `json:"deviceModel"` Secret string `json:"secretKey"` Index string `json:"keyIndex"` Public string `json:"sesame2PublicKey"` } `json:"key,omitempty"` } type Manager struct { ctx context.Context cancel context.CancelFunc dir string dial sesame.Dialer enroll sync.Mutex mu sync.RWMutex records map[string]Record clients map[string]*sesame.Client clientCancel map[string]context.CancelFunc clientDone map[string]<-chan struct{} wg sync.WaitGroup } func Open(ctx context.Context, dir string, dial sesame.Dialer) (*Manager, error) { if dial == nil { dial = sesame.DialBlueZ } life, cancel := context.WithCancel(ctx) m := &Manager{ctx: life, cancel: cancel, dir: dir, dial: dial, records: map[string]Record{}, clients: map[string]*sesame.Client{}, clientCancel: map[string]context.CancelFunc{}, clientDone: map[string]<-chan struct{}{}} if e := os.MkdirAll(filepath.Join(dir, "physical-lock-keys"), 0700); e != nil { cancel() return nil, e } raw, e := os.ReadFile(filepath.Join(dir, "physical-locks.json")) if e != nil && !errors.Is(e, os.ErrNotExist) { cancel() return nil, e } var records []Record if len(raw) > 0 { if e = json.Unmarshal(raw, &records); e != nil { cancel() return nil, e } } // Validate the whole registry before starting any device connections. keys := map[string]sesame.Credential{} for _, r := range records { id, err := identity(r.Target) if err != nil || id != r.ID || r.Driver != "sesame" || m.records[r.ID].ID != "" { cancel() return nil, errors.New("invalid physical lock registry") } // A crash before the registry update leaves a staged key removal. // Restore it when the persisted registry still references this lock. if _, err := os.Stat(m.keyPath(id)); errors.Is(err, os.ErrNotExist) { if err = os.Rename(m.keyPath(id)+".removing", m.keyPath(id)); err != nil && !errors.Is(err, os.ErrNotExist) { cancel() return nil, err } } c, e := m.readKey(id) if e != nil { cancel() return nil, fmt.Errorf("physical lock %s: %w", id, e) } keys[id] = c m.records[id] = r } // After a committed removal, a crash may leave only the tombstone. files, err := os.ReadDir(filepath.Join(dir, "physical-lock-keys")) if err != nil { cancel() return nil, err } for _, f := range files { if f.IsDir() || !strings.HasSuffix(f.Name(), ".json.removing") { continue } id := strings.TrimSuffix(f.Name(), ".json.removing") if _, ok := m.records[id]; ok { continue } if !strings.HasPrefix(id, "sesame-") { continue } if _, err := sesame.NormalizeUUID(strings.TrimPrefix(id, "sesame-")); err != nil { continue } if err := os.Remove(filepath.Join(dir, "physical-lock-keys", f.Name())); err != nil { cancel() return nil, err } } for _, r := range records { m.start(r, keys[r.ID]) } return m, nil } func identity(t sesame.Target) (string, error) { if e := sesame.ValidateTarget(t); e != nil { return "", e } uuid, e := sesame.NormalizeUUID(t.UUID) if e != nil { return "", e } return "sesame-" + uuid, nil } func (m *Manager) keyPath(id string) string { return filepath.Join(m.dir, "physical-lock-keys", id+".json") } func (m *Manager) readKey(id string) (sesame.Credential, error) { var c sesame.Credential b, e := os.ReadFile(m.keyPath(id)) if e != nil { return c, e } if e = json.Unmarshal(b, &c); e != nil { return c, e } return c, c.Validate() } func writeJSON(path string, v any) error { b, e := json.MarshalIndent(v, "", " ") if e != nil { return e } f, e := os.CreateTemp(filepath.Dir(path), ".physical-*") if e != nil { return e } defer os.Remove(f.Name()) if _, e = f.Write(append(b, '\n')); e != nil { f.Close() return e } if e = f.Sync(); e != nil { f.Close() return e } if e = f.Close(); e != nil { return e } if e = os.Rename(f.Name(), path); e != nil { return e } d, e := os.Open(filepath.Dir(path)) if e != nil { return e } defer d.Close() return d.Sync() } func (m *Manager) start(r Record, key sesame.Credential) { c := sesame.NewClient(r.Target, key, m.dial) ctx, cancel := context.WithCancel(m.ctx) done := make(chan struct{}) m.clients[r.ID] = c m.clientCancel[r.ID] = cancel m.clientDone[r.ID] = done m.wg.Add(1) go func() { defer m.wg.Done(); defer close(done); c.Run(ctx) }() } var ErrNotFound = errors.New("unknown physical lock") // Remove forgets local enrollment; it never sends a reset or changes the lock. // App configuration must reject removal while a virtual lock references this ID. func (m *Manager) Remove(id string) error { m.enroll.Lock() defer m.enroll.Unlock() m.mu.Lock() if _, ok := m.records[id]; !ok { m.mu.Unlock() return ErrNotFound } // Move the credential out of the import/recovery path first. If the registry // update fails, restore it and keep the active controller intact. key := m.keyPath(id) tombstone := key + ".removing" moved := false if e := os.Rename(key, tombstone); e != nil && !errors.Is(e, os.ErrNotExist) { m.mu.Unlock() return fmt.Errorf("remove physical lock key: %w", e) } else if e == nil { moved = true } records := make([]Record, 0, len(m.records)-1) for rid, r := range m.records { if rid != id { records = append(records, r) } } if e := writeJSON(filepath.Join(m.dir, "physical-locks.json"), records); e != nil { var restore error if moved { restore = os.Rename(tombstone, key) } m.mu.Unlock() return errors.Join(e, restore) } cancel, done := m.clientCancel[id], m.clientDone[id] delete(m.records, id) delete(m.clients, id) delete(m.clientCancel, id) delete(m.clientDone, id) cancel() m.mu.Unlock() <-done // Includes transport cleanup; do not leave a reconnect goroutine alive. if moved { if e := os.Remove(tombstone); e != nil { return fmt.Errorf("lock removed, but saved key cleanup failed: %w", e) } } return nil } func (m *Manager) Close() { m.cancel(); m.enroll.Lock(); defer m.enroll.Unlock(); m.wg.Wait() } func (m *Manager) Has(id string) bool { m.mu.RLock() defer m.mu.RUnlock() _, ok := m.records[id] return ok } func (m *Manager) Views() []View { m.mu.RLock() defer m.mu.RUnlock() out := make([]View, 0, len(m.records)) for id, r := range m.records { out = append(out, View{r, m.clients[id].State()}) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } func (m *Manager) Add(ctx context.Context, in Enrollment) (Record, error) { m.enroll.Lock() defer m.enroll.Unlock() name := strings.TrimSpace(in.Name) if len(name) == 0 || len(name) > 128 { return Record{}, errors.New("physical lock name is required (maximum 128 bytes)") } uuid, e := sesame.NormalizeUUID(in.Target.UUID) if e != nil { return Record{}, e } in.Target.UUID = uuid id, e := identity(in.Target) if e != nil { return Record{}, e } if m.Has(id) { return Record{}, errors.New("physical lock is already added") } if in.Mode != "register" && in.Mode != "import" { return Record{}, errors.New("choose register or import") } // Leave room for separate BLE discovery/connect, initial-token and // registration budgets, plus persistence. A slow dial must not exhaust // the handshake deadline before the lock can answer. ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() stop := context.AfterFunc(m.ctx, cancel) defer stop() // Check writable private storage before starting irreversible registration. probe, e := os.CreateTemp(filepath.Join(m.dir, "physical-lock-keys"), ".probe-*") if e != nil { return Record{}, e } probe.Close() os.Remove(probe.Name()) var key sesame.Credential if recovered, err := m.readKey(id); err == nil { // Retry can recover a key persisted before a failed registry write. key = recovered e = sesame.Probe(ctx, in.Target, key, m.dial) } else if !errors.Is(err, os.ErrNotExist) { return Record{}, err } else if in.Mode == "import" { if in.Key == nil { return Record{}, errors.New("paste the Sesame key JSON") } keyUUID, err := sesame.NormalizeUUID(in.Key.UUID) if err != nil || keyUUID != uuid || in.Key.Model != in.Target.Model { return Record{}, errors.New("imported key does not match the selected lock") } key = sesame.Credential{Secret: strings.TrimSpace(in.Key.Secret)} if e = key.Validate(); e != nil { return Record{}, e } e = sesame.Probe(ctx, in.Target, key, m.dial) if e == nil { e = writeJSON(m.keyPath(id), key) } } else { e = sesame.Register(ctx, in.Target, m.dial, func(c sesame.Credential) error { key = c; return writeJSON(m.keyPath(id), c) }) } if e != nil { return Record{}, e } in.Target.Registered = true r := Record{ID: id, Name: name, Driver: "sesame", Target: in.Target} m.mu.Lock() defer m.mu.Unlock() records := make([]Record, 0, len(m.records)+1) for _, v := range m.records { records = append(records, v) } records = append(records, r) if e = writeJSON(filepath.Join(m.dir, "physical-locks.json"), records); e != nil { return Record{}, fmt.Errorf("key saved, but physical lock list could not be saved; retry adding this device: %w", e) } m.records[id] = r m.start(r, key) return r, nil } func (m *Manager) Action(ctx context.Context, id, name string) error { m.mu.RLock() c := m.clients[id] m.mu.RUnlock() if c == nil { return errors.New("unknown physical lock") } return c.Action(ctx, name) } // Dispatch accepts an authorized command without holding the provision store's // mutex through BLE I/O. Cancellation and a short deadline apply to the work. func (m *Manager) Dispatch(ctx context.Context, ids []string, locked bool) <-chan error { result := make(chan error, 1) copyIDs := append([]string(nil), ids...) go func() { ctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() stop := context.AfterFunc(m.ctx, cancel) defer stop() name := "unlock" if locked { name = "lock" } var wg sync.WaitGroup errs := make(chan error, len(copyIDs)) seen := map[string]bool{} for _, id := range copyIDs { if seen[id] { continue } seen[id] = true wg.Add(1) go func(id string) { defer wg.Done() if e := m.Action(ctx, id, name); e != nil { errs <- fmt.Errorf("%s: %w", id, e) } }(id) } wg.Wait() close(errs) var all []error for e := range errs { all = append(all, e) } result <- errors.Join(all...) }() return result }