penguin/homekey-go

homekey-go

internal/physical/registration_test.go

raw ยท 5618 bytes

package physical

import (
	"context"
	"crypto/ecdh"
	"crypto/rand"
	"encoding/json"
	"errors"
	"os"
	"path/filepath"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"homekey.local/provisioner/drivers/sesame"
)

type registrationLink struct {
	packets  chan []byte
	done     chan error
	mu       sync.Mutex
	received []byte
	public   []byte
	writes   atomic.Int32
	closed   atomic.Bool
}

func newRegistrationLink(t *testing.T) *registrationLink {
	t.Helper()
	key, err := ecdh.P256().GenerateKey(rand.Reader)
	if err != nil {
		t.Fatal(err)
	}
	l := &registrationLink{packets: make(chan []byte, 16), done: make(chan error, 1), public: key.PublicKey().Bytes()[1:]}
	l.packets <- []byte{3, 8, 14, 0x11, 0x22, 0x33, 0x44}
	return l
}
func (l *registrationLink) Packets() <-chan []byte { return l.packets }
func (l *registrationLink) Done() <-chan error     { return l.done }
func (l *registrationLink) Close()                 { l.closed.Store(true) }
func (l *registrationLink) Write(ctx context.Context, fragment []byte) error {
	l.mu.Lock()
	defer l.mu.Unlock()
	if err := ctx.Err(); err != nil {
		return err
	}
	if fragment[0]&1 != 0 {
		l.received = nil
	}
	l.received = append(l.received, fragment[1:]...)
	if fragment[0]>>1 == 0 {
		return nil
	}
	l.writes.Add(1)
	if l.received[0] != 1 {
		return errors.New("unexpected login or operation")
	}
	reply := make([]byte, 80)
	copy(reply, []byte{7, 1, 0, 0, 0, 0, 128, 45, 0, 18, 90, 0, 0, 0, 0, 0})
	copy(reply[16:], l.public)
	start := byte(1)
	for len(reply) > 0 {
		n := len(reply)
		if n > 19 {
			n = 19
		}
		header := start
		if n == len(reply) {
			header |= 2
		}
		l.packets <- append([]byte{header}, reply[:n]...)
		reply = reply[n:]
		start = 0
	}
	return nil
}
func awaitPreparation(t *testing.T, m *Manager, phase string) {
	t.Helper()
	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		states := m.RegistrationConnections()
		if len(states) == 1 && states[0].Phase == phase {
			return
		}
		time.Sleep(time.Millisecond)
	}
	t.Fatalf("preparation = %+v, want %s", m.RegistrationConnections(), phase)
}
func pendingTarget() sesame.Target {
	return sesame.Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0", Address: "AA:BB:CC:DD:EE:FF"}
}

func TestScanPreparationDeduplicatesAndRegistersExistingLink(t *testing.T) {
	l := newRegistrationLink(t)
	var dials atomic.Int32
	dir := t.TempDir()
	m, err := Open(context.Background(), dir, func(_ context.Context, target sesame.Target) (sesame.Link, sesame.Target, error) {
		dials.Add(1)
		return l, target, nil
	})
	if err != nil {
		t.Fatal(err)
	}
	defer m.Close()
	target := pendingTarget()
	if _, err = m.Add(context.Background(), Enrollment{Name: "Door", Target: target, Mode: "register"}); err == nil {
		t.Fatal("Add dialed without preparation")
	}
	m.Prepare([]sesame.Target{target})
	awaitPreparation(t, m, "ready")
	other := target
	other.Adapter = "hci1"
	m.Prepare([]sesame.Target{target, other})
	awaitPreparation(t, m, "ready")
	if dials.Load() != 1 || l.writes.Load() != 0 {
		t.Fatal("duplicate dial or registration before click")
	}
	record, err := m.Add(context.Background(), Enrollment{Name: "Door", Target: target, Mode: "register"})
	if err != nil {
		t.Fatal(err)
	}
	if !m.Has(record.ID) || !record.Target.Registered || len(m.RegistrationConnections()) != 0 {
		t.Fatal("handoff not committed")
	}
	if dials.Load() != 1 || l.writes.Load() != 1 || l.closed.Load() {
		t.Fatal("registration reconnected, logged in, or closed")
	}
	views := m.Views()
	if len(views) != 1 || views[0].State.Position == nil || *views[0].State.Position != 45 {
		t.Fatal("registration status missing")
	}
	key, err := m.readKey(record.ID)
	if err != nil || len(key.Secret) != 32 {
		t.Fatal("missing registration key", err)
	}
	raw, _ := json.Marshal(m.RegistrationConnections())
	if string(raw) != "[]" {
		t.Fatal("prepared list not empty")
	}
	m.Close()
	if !l.closed.Load() {
		t.Fatal("shutdown leaked registered connection")
	}
}

func TestDisconnectPreparationAndShutdownCancelOwnedLinks(t *testing.T) {
	l := newRegistrationLink(t)
	m, err := Open(context.Background(), t.TempDir(), func(_ context.Context, target sesame.Target) (sesame.Link, sesame.Target, error) {
		return l, target, nil
	})
	if err != nil {
		t.Fatal(err)
	}
	defer m.Close()
	target := pendingTarget()
	m.Prepare([]sesame.Target{target})
	awaitPreparation(t, m, "ready")
	if !m.DisconnectRegistration(target.Adapter, target.Address) || !l.closed.Load() || len(m.RegistrationConnections()) != 0 {
		t.Fatal("waiting link not released")
	}
	if m.DisconnectRegistration(target.Adapter, target.Address) {
		t.Fatal("claimed external link")
	}
}

func TestFailedRegistryWriteClosesHandoffAndRetainsRecoveryKey(t *testing.T) {
	l := newRegistrationLink(t)
	dir := t.TempDir()
	m, err := Open(context.Background(), dir, func(_ context.Context, target sesame.Target) (sesame.Link, sesame.Target, error) {
		return l, target, nil
	})
	if err != nil {
		t.Fatal(err)
	}
	defer m.Close()
	target := pendingTarget()
	m.Prepare([]sesame.Target{target})
	awaitPreparation(t, m, "ready")
	if err = os.Mkdir(filepath.Join(dir, "physical-locks.json"), 0700); err != nil {
		t.Fatal(err)
	}
	if _, err = m.Add(context.Background(), Enrollment{Name: "Door", Target: target, Mode: "register"}); err == nil {
		t.Fatal("accepted registry failure")
	}
	id, _ := identity(target)
	if _, err = m.readKey(id); err != nil {
		t.Fatal("recovery key lost", err)
	}
	if !l.closed.Load() || len(m.RegistrationConnections()) != 0 || m.Has(id) {
		t.Fatal("failed commit leaked ownership")
	}
}