penguin/homekey-go

homekey-go

drivers/sesame/client.go

raw ยท 10724 bytes

// SPDX-License-Identifier: Apache-2.0
package sesame

import (
	"context"
	"crypto/ecdh"
	"crypto/rand"
	"encoding/binary"
	"encoding/hex"
	"errors"
	"fmt"
	"log"
	"sync"
	"time"
)

type session struct {
	link   Link
	rx     assembler
	cipher *sessionCipher
	state  State
	update func(State)
}

func (s *session) publish() {
	if s.update != nil {
		s.update(s.state)
	}
}
func (s *session) decode(fragment []byte) ([]byte, error) {
	kind, b, e := s.rx.feed(fragment)
	if e != nil || kind == 0 {
		return nil, e
	}
	if kind == 2 {
		if s.cipher == nil {
			return nil, errors.New("encrypted message before Sesame session")
		}
		return s.cipher.decrypt(b)
	}
	if s.cipher != nil {
		return nil, errors.New("plaintext message in authenticated Sesame session")
	}
	return b, nil
}
func (s *session) next(ctx context.Context) ([]byte, error) {
	for {
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case e := <-s.link.Done():
			if e == nil {
				e = errors.New("Sesame connection closed")
			}
			return nil, e
		case p, ok := <-s.link.Packets():
			if !ok {
				return nil, errors.New("Sesame notifications closed")
			}
			b, e := s.decode(p)
			if e != nil {
				return nil, e
			}
			if b != nil {
				return b, nil
			}
		}
	}
}
func (s *session) handle(b []byte) error {
	if len(b) < 2 {
		return errors.New("short Sesame notification")
	}
	if b[0] != 8 {
		return errors.New("unexpected Sesame response")
	}
	if b[1] == 14 {
		return errors.New("Sesame session restarted")
	}
	if e := s.state.publish(b[1], b[2:]); e != nil {
		return e
	}
	s.publish()
	return nil
}
func (s *session) initial(ctx context.Context) ([]byte, error) {
	b, e := s.next(ctx)
	if e != nil {
		return nil, e
	}
	if len(b) != 6 || b[0] != 8 || b[1] != 14 {
		return nil, errors.New("expected four-byte Sesame initial token")
	}
	return b[2:], nil
}
func (s *session) exchange(ctx context.Context, item byte, payload []byte, encrypted bool) ([]byte, error) {
	if e := ctx.Err(); e != nil {
		return nil, e
	}
	b := append([]byte{item}, payload...)
	kind := byte(1)
	if encrypted {
		if s.cipher == nil {
			return nil, errors.New("Sesame not authenticated")
		}
		var e error
		b, e = s.cipher.encrypt(b)
		if e != nil {
			return nil, e
		}
		kind = 2
	}
	fs, e := fragments(kind, b)
	if e != nil {
		return nil, e
	}
	for _, f := range fs {
		if e = s.link.Write(ctx, f); e != nil {
			return nil, e
		}
	}
	for {
		b, e := s.next(ctx)
		if e != nil {
			return nil, e
		}
		if len(b) < 2 {
			return nil, errors.New("short Sesame response")
		}
		if b[0] == 7 {
			if len(b) < 3 || b[1] != item {
				return nil, errors.New("unexpected Sesame response item")
			}
			if b[2] != 0 {
				return nil, &ResultError{item, b[2]}
			}
			return b[3:], nil
		}
		if e = s.handle(b); e != nil {
			return nil, e
		}
	}
}
func login(ctx context.Context, l Link, c Credential, update func(State)) (*session, error) {
	s := &session{link: l, update: update}
	token, e := s.initial(ctx)
	if e != nil {
		return nil, fmt.Errorf("wait for Sesame initial token: %w", e)
	}
	key, e := c.key()
	if e != nil {
		return nil, e
	}
	var auth []byte
	s.cipher, auth, e = newCipher(key, token)
	if e != nil {
		return nil, e
	}
	reply, e := s.exchange(ctx, 2, auth[:4], false)
	if e != nil {
		return nil, fmt.Errorf("Sesame login: %w", e)
	}
	if len(reply) < 4 {
		return nil, errors.New("short Sesame login response")
	}
	s.state.Online = true
	s.state.Updated = time.Now()
	s.publish()
	return s, nil
}

// Register enrolls only a device advertising as unregistered. Persist is invoked
// immediately after deriving the credential, before any additional BLE command.
// A registered device must instead be imported; this API never resets a lock.
func Register(ctx context.Context, t Target, dial Dialer, persist func(Credential) error) error {
	connect, cancel := context.WithTimeout(ctx, 30*time.Second)
	l, actual, e := dial(connect, t)
	cancel()
	if e != nil {
		return fmt.Errorf("connect for Sesame registration: %w", e)
	}
	defer l.Close()
	if actual.Registered {
		return errors.New("lock already registered: import its local key instead")
	}
	s := &session{link: l}
	token, done := context.WithTimeout(ctx, 10*time.Second)
	_, e = s.initial(token)
	done()
	if e != nil {
		return fmt.Errorf("wait for Sesame registration initial token: %w", e)
	}
	private, e := ecdh.P256().GenerateKey(rand.Reader)
	if e != nil {
		return e
	}
	payload := append([]byte(nil), private.PublicKey().Bytes()[1:]...)
	stamp := make([]byte, 4)
	binary.LittleEndian.PutUint32(stamp, uint32(time.Now().Unix()))
	payload = append(payload, stamp...)
	registration, finish := context.WithTimeout(ctx, 10*time.Second)
	reply, e := s.exchange(registration, 1, payload, false)
	finish()
	if e != nil {
		return fmt.Errorf("Sesame registration exchange (lock may already be registered): %w", e)
	}
	if len(reply) < 77 {
		return errors.New("invalid registration response; lock may need recovery in the Sesame app")
	}
	pub, e := ecdh.P256().NewPublicKey(append([]byte{4}, reply[13:77]...))
	if e != nil {
		return errors.New("invalid Sesame registration public key")
	}
	shared, e := private.ECDH(pub)
	if e != nil {
		return e
	}
	if e = persist(Credential{Secret: hex.EncodeToString(shared[:16])}); e != nil {
		return fmt.Errorf("save Sesame registration key: %w", e)
	}
	return nil
}
func Probe(ctx context.Context, t Target, c Credential, dial Dialer) error {
	connect, cancel := context.WithTimeout(ctx, 30*time.Second)
	l, actual, e := dial(connect, t)
	cancel()
	if e != nil {
		return fmt.Errorf("connect to verify Sesame key: %w", e)
	}
	defer l.Close()
	if !actual.Registered {
		return errors.New("lock is unregistered; register it first")
	}
	handshake, done := context.WithTimeout(ctx, 10*time.Second)
	_, e = login(handshake, l, c, nil)
	done()
	return e
}

type operation struct {
	ctx      context.Context
	name     string
	position *int16
	reply    chan error
}
type Client struct {
	target     Target
	credential Credential
	dial       Dialer
	ops        chan operation
	mu         sync.RWMutex
	state      State
}

func NewClient(t Target, c Credential, dial Dialer) *Client {
	return &Client{target: t, credential: c, dial: dial, ops: make(chan operation)}
}
func (c *Client) State() State { c.mu.RLock(); defer c.mu.RUnlock(); return c.state }
func (c *Client) set(s State)  { c.mu.Lock(); c.state = s; c.mu.Unlock() }
func (c *Client) Action(ctx context.Context, name string) error {
	switch name {
	case "lock", "unlock", "set-lock", "set-unlock", "set-boundary":
	default:
		return errors.New("unsupported Sesame action")
	}
	return c.submit(ctx, name, nil)
}

// MoveTo tests the OS3 move-to command without changing calibrated endpoints.
// Firmware support is not advertised; a rejected command is returned to callers.
func (c *Client) MoveTo(ctx context.Context, position int16) error {
	if position == -32768 {
		return errors.New("-32768 is reserved for no target")
	}
	return c.submit(ctx, "move-to", &position)
}

func (c *Client) submit(ctx context.Context, name string, position *int16) error {
	if !c.State().Online {
		return errors.New("physical lock is offline")
	}
	op := operation{ctx: ctx, name: name, position: position, reply: make(chan error, 1)}
	select {
	case c.ops <- op:
	case <-ctx.Done():
		return ctx.Err()
	}
	select {
	case e := <-op.reply:
		return e
	case <-ctx.Done():
		return ctx.Err()
	}
}
func (c *Client) Run(ctx context.Context) {
	defer c.set(State{Error: "physical lock stopped", Updated: time.Now()})
	lastError := ""
	for ctx.Err() == nil {
		e := c.runSession(ctx)
		msg := "physical lock disconnected"
		if e != nil {
			msg = e.Error()
		}
		c.set(State{Error: msg, Updated: time.Now()})
		if ctx.Err() == nil && msg != lastError {
			log.Printf("Sesame %s on %s: %s; retrying in 3s", c.target.UUID, c.target.Adapter, msg)
			lastError = msg
		}
		if pause(ctx, 3*time.Second) != nil {
			return
		}
	}
}
func (c *Client) runSession(ctx context.Context) error {
	connect, cancel := context.WithTimeout(ctx, 30*time.Second)
	l, actual, e := c.dial(connect, c.target)
	cancel()
	if e != nil {
		return e
	}
	defer l.Close()
	if !actual.Registered {
		return errors.New("Sesame was reset; enroll it again")
	}
	// Discovery/connect must not consume the initial-token/login budget.
	handshake, done := context.WithTimeout(ctx, 10*time.Second)
	s, e := login(handshake, l, c.credential, c.set)
	done()
	if e != nil {
		return e
	}
	log.Printf("Sesame %s on %s: authenticated connection ready", c.target.UUID, c.target.Adapter)
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case e := <-l.Done():
			return e
		case p, ok := <-l.Packets():
			if !ok {
				return errors.New("Sesame notification stream ended")
			}
			b, e := s.decode(p)
			if e != nil {
				return e
			}
			if b != nil {
				if e = s.handle(b); e != nil {
					return e
				}
			}
		case op := <-c.ops:
			if e = op.ctx.Err(); e != nil {
				op.reply <- e
				continue
			}
			command, cancel := context.WithTimeout(op.ctx, 8*time.Second)
			// Service shutdown also cancels an in-flight web/HAP operation.
			stop := context.AfterFunc(ctx, cancel)
			e = s.actionPosition(command, op.name, op.position)
			stop()
			cancel()
			op.reply <- e
			var rejected *ResultError
			if e != nil && !errors.As(e, &rejected) {
				return e
			}
		}
	}
}
func (s *session) action(ctx context.Context, name string) error {
	return s.actionPosition(ctx, name, nil)
}

func (s *session) actionPosition(ctx context.Context, name string, position *int16) error {
	var item byte
	var payload []byte
	switch name {
	case "move-to":
		if position == nil || *position == -32768 {
			return &ResultError{84, 8}
		}
		item = 84
		payload = short(*position)
	case "lock":
		item = 82
		payload = []byte{0, 14}
	case "unlock":
		item = 83
		payload = []byte{0, 14}
	case "set-lock", "set-unlock":
		if s.state.Position == nil || s.state.LockPosition == nil || s.state.UnlockPosition == nil {
			return &ResultError{80, 6}
		}
		locked, unlocked := *s.state.LockPosition, *s.state.UnlockPosition
		if name == "set-lock" {
			locked = *s.state.Position
		} else {
			unlocked = *s.state.Position
		}
		item = 80
		payload = append(short(locked), short(unlocked)...)
	case "set-boundary":
		if s.state.Boundary == nil || s.state.Position == nil {
			return &ResultError{20, 2}
		}
		item = 20
		payload = short(*s.state.Position)
	default:
		return errors.New("unsupported Sesame action")
	}
	_, e := s.exchange(ctx, item, payload, true)
	if e != nil {
		return e
	}
	if item == 80 {
		s.state.LockPosition = i16(payload[:2])
		s.state.UnlockPosition = i16(payload[2:])
	}
	if item == 20 {
		s.state.Boundary = i16(payload)
	}
	s.publish()
	return nil
}