penguin/homekey-go

homekey-go

internal/physical/manager_test.go

raw ยท 7787 bytes

package physical

import (
	"context"
	"encoding/json"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"testing"

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

func TestPrivatePersistenceAndPublicViews(t *testing.T) {
	dir := t.TempDir()
	target := sesame.Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0", Registered: true}
	id, _ := identity(target)
	record := Record{id, "Front door", "sesame", target}
	secret := "000102030405060708090a0b0c0d0e0f"
	os.Mkdir(filepath.Join(dir, "physical-lock-keys"), 0700)
	if e := writeJSON(filepath.Join(dir, "physical-lock-keys", id+".json"), sesame.Credential{Secret: secret}); e != nil {
		t.Fatal(e)
	}
	if e := writeJSON(filepath.Join(dir, "physical-locks.json"), []Record{record}); e != nil {
		t.Fatal(e)
	}
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	m, e := Open(ctx, dir, func(context.Context, sesame.Target) (sesame.Link, sesame.Target, error) {
		t.Error("dial after cancellation")
		return nil, sesame.Target{}, ctx.Err()
	})
	if e != nil {
		t.Fatal(e)
	}
	defer m.Close()
	b, _ := json.Marshal(m.Views())
	if strings.Contains(string(b), secret) || strings.Contains(string(b), "secret") {
		t.Fatal("public view leaks credentials")
	}
	if !m.Has(id) || m.Has("missing") {
		t.Fatal("registry lookup")
	}
	k, e := m.readKey(id)
	if e != nil || k.Secret != secret {
		t.Fatal("key not persisted")
	}
	for _, path := range []string{m.keyPath(id), filepath.Join(dir, "physical-locks.json")} {
		st, e := os.Stat(path)
		if e != nil || st.Mode().Perm() != 0600 {
			t.Fatal("unsafe file permissions", path)
		}
	}
	var records []Record
	b, _ = os.ReadFile(filepath.Join(dir, "physical-locks.json"))
	if e = json.Unmarshal(b, &records); e != nil || len(records) != 1 {
		t.Fatal("registry")
	}
}
func TestEnrollmentValidationBeforeBLE(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	m, e := Open(ctx, t.TempDir(), func(context.Context, sesame.Target) (sesame.Link, sesame.Target, error) {
		t.Fatal("invalid input reached Bluetooth")
		return nil, sesame.Target{}, nil
	})
	if e != nil {
		t.Fatal(e)
	}
	defer m.Close()
	target := sesame.Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0"}
	for _, in := range []Enrollment{{Name: "", Target: target, Mode: "register"}, {Name: "Door", Target: target, Mode: "reset"}, {Name: "Door", Target: target, Mode: "import"}, {Name: "Door", Target: sesame.Target{UUID: "../../secret", Model: "sesame_6_pro", Adapter: "hci0"}, Mode: "register"}} {
		if _, e = m.Add(ctx, in); e == nil {
			t.Fatal("accepted invalid enrollment")
		}
	}
}
func TestRegistryRejectsTraversalAndMissingKey(t *testing.T) {
	for _, id := range []string{"../../secret", "sesame-00112233-4455-6677-8899-aabbccddeeff"} {
		dir := t.TempDir()
		writeJSON(filepath.Join(dir, "physical-locks.json"), []Record{{ID: id, Name: "Door", Driver: "sesame", Target: sesame.Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0"}}})
		m, e := Open(context.Background(), dir, nil)
		if e == nil {
			m.Close()
			t.Fatal("invalid registry accepted")
		}
	}
}

// The encrypted login response is an independent AESCCM fixture for the
// synthetic key/token in this test, not generated by the driver under test.
type importLink struct {
	packets chan []byte
	done    chan error
	sent    bool
}

func newImportLink() *importLink {
	l := &importLink{packets: make(chan []byte, 4), done: make(chan error)}
	l.packets <- []byte{3, 8, 14, 0x11, 0x22, 0x33, 0x44}
	return l
}
func (l *importLink) Packets() <-chan []byte { return l.packets }
func (l *importLink) Done() <-chan error     { return l.done }
func (l *importLink) Close()                 {}
func (l *importLink) Write(ctx context.Context, b []byte) error {
	if !l.sent {
		l.sent = true
		l.packets <- []byte{5, 0xbe, 0x9d, 0x51, 0x39, 0x09, 0xb8, 0x0b, 0x9e, 0x70, 0x6a, 0x5e}
	}
	return nil
}
func TestImportPersistsAndRestoresWithoutPublicSecret(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	dir := t.TempDir()
	dial := func(_ context.Context, target sesame.Target) (sesame.Link, sesame.Target, error) {
		target.Registered = true
		return newImportLink(), target, nil
	}
	m, e := Open(ctx, dir, dial)
	if e != nil {
		t.Fatal(e)
	}
	var in Enrollment
	raw := `{"name":"Door","mode":"import","target":{"uuid":"00112233-4455-6677-8899-aabbccddeeff","model":"sesame_6_pro","adapter":"hci0"},"key":{"deviceUUID":"00112233-4455-6677-8899-aabbccddeeff","deviceModel":"sesame_6_pro","secretKey":"000102030405060708090a0b0c0d0e0f"}}`
	if e = json.Unmarshal([]byte(raw), &in); e != nil {
		t.Fatal(e)
	}
	record, e := m.Add(ctx, in)
	if e != nil {
		m.Close()
		t.Fatal(e)
	}
	if !m.Has(record.ID) {
		t.Fatal("import missing")
	}
	if _, e = m.Add(ctx, in); e == nil {
		t.Fatal("duplicate import")
	}
	public, _ := json.Marshal(m.Views())
	if strings.Contains(string(public), in.Key.Secret) {
		t.Fatal("key leaked")
	}
	m.Close()
	m, e = Open(ctx, dir, dial)
	if e != nil {
		t.Fatal(e)
	}
	defer m.Close()
	if !m.Has(record.ID) {
		t.Fatal("import not restored")
	}
}

func removalFixture(t *testing.T) (*Manager, string, string) {
	t.Helper()
	dir := t.TempDir()
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	target := sesame.Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0", Registered: true}
	id, _ := identity(target)
	os.Mkdir(filepath.Join(dir, "physical-lock-keys"), 0700)
	writeJSON(filepath.Join(dir, "physical-lock-keys", id+".json"), sesame.Credential{Secret: "000102030405060708090a0b0c0d0e0f"})
	writeJSON(filepath.Join(dir, "physical-locks.json"), []Record{{id, "Door", "sesame", target}})
	m, e := Open(ctx, dir, nil)
	if e != nil {
		t.Fatal(e)
	}
	return m, id, dir
}
func TestRemovalDeletesKeyStopsWorkerAndPersists(t *testing.T) {
	m, id, dir := removalFixture(t)
	done := m.clientDone[id]
	if e := m.Remove(id); e != nil {
		t.Fatal(e)
	}
	select {
	case <-done:
	default:
		t.Fatal("worker still running")
	}
	if m.Has(id) || len(m.Views()) != 0 {
		t.Fatal("removed lock remains visible")
	}
	for _, p := range []string{m.keyPath(id), m.keyPath(id) + ".removing"} {
		if _, e := os.Stat(p); !os.IsNotExist(e) {
			t.Fatal("credential remains", p)
		}
	}
	if e := m.Remove(id); !errors.Is(e, ErrNotFound) {
		t.Fatal("unknown removal accepted", e)
	}
	m.Close()
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	m, e := Open(ctx, dir, nil)
	if e != nil {
		t.Fatal(e)
	}
	defer m.Close()
	if m.Has(id) {
		t.Fatal("lock returned on restart")
	}
}
func TestFailedRemovalRestoresKey(t *testing.T) {
	m, id, dir := removalFixture(t)
	defer m.Close()
	registry := filepath.Join(dir, "physical-locks.json")
	os.Remove(registry)
	os.Mkdir(registry, 0700)
	if e := m.Remove(id); e == nil {
		t.Fatal("accepted failed registry update")
	}
	if !m.Has(id) {
		t.Fatal("lost active record")
	}
	if _, e := m.readKey(id); e != nil {
		t.Fatal("lost key after failed removal", e)
	}
}
func TestInterruptedRemovalRecoversOnOpen(t *testing.T) {
	m, id, dir := removalFixture(t)
	m.Close()
	key := m.keyPath(id)
	if e := os.Rename(key, key+".removing"); e != nil {
		t.Fatal(e)
	}
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	m, e := Open(ctx, dir, nil)
	if e != nil {
		t.Fatal(e)
	}
	if !m.Has(id) {
		t.Fatal("precommit removal was not rolled back")
	}
	m.Close()
	os.Rename(key, key+".removing")
	writeJSON(filepath.Join(dir, "physical-locks.json"), []Record{})
	m, e = Open(ctx, dir, nil)
	if e != nil {
		t.Fatal(e)
	}
	defer m.Close()
	if _, e = os.Stat(key + ".removing"); !os.IsNotExist(e) {
		t.Fatal("committed removal retained key")
	}
}