penguin/homekey-go

homekey-go

internal/physical/manager.go

raw ยท 12186 bytes

// 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{}
	pending      map[string]*sesame.RegistrationConnection
	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{}{}, pending: map[string]*sesame.RegistrationConnection{}}
	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) {
	m.startClient(r, sesame.NewClient(r.Target, key, m.dial))
}
func (m *Manager) startClient(r Record, c *sesame.Client) {
	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()
	for _, p := range m.pending {
		p.Close()
	}
	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, 90*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 prepared *sesame.Client
	defer func() {
		if prepared != nil {
			prepared.Close()
		}
	}()
	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 {
		m.mu.RLock()
		connection := m.pending[id]
		m.mu.RUnlock()
		if connection == nil || connection.State().Target.Adapter != in.Target.Adapter {
			return Record{}, errors.New("scan first, then wait for Ready to register on the connected lock")
		}
		prepared, e = connection.Register(ctx, func(c sesame.Credential) error { key = c; return writeJSON(m.keyPath(id), c) })
		if e == nil {
			in.Target = connection.State().Target
			m.mu.Lock()
			delete(m.pending, id) // Ownership transferred to prepared, even if registry write fails.
			m.mu.Unlock()
		}
	}
	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
	if prepared != nil {
		m.startClient(r, prepared)
		prepared = nil // The running client now owns the live registration session.
	} else {
		m.start(r, key)
	}
	delete(m.pending, id)
	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
}