// 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 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{}} 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") } 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 } 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) m.clients[r.ID] = c m.wg.Add(1) go func() { defer m.wg.Done(); c.Run(m.ctx) }() } 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") } ctx, cancel := context.WithTimeout(ctx, 30*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 }