drivers/sesame/client_test.go
raw ยท 12585 bytes
package sesame
import (
"bytes"
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
type fakeLink struct {
packets chan []byte
done chan error
rx assembler
write func(byte, []byte) error
mu sync.Mutex
closed bool
}
func newLink() *fakeLink {
f := &fakeLink{packets: make(chan []byte, 64), done: make(chan error, 1)}
f.emit(1, []byte{8, 14, 0x11, 0x22, 0x33, 0x44})
return f
}
func (f *fakeLink) emit(kind byte, b []byte) {
fs, _ := fragments(kind, b)
for _, v := range fs {
f.packets <- v
}
}
func (f *fakeLink) Write(ctx context.Context, b []byte) error {
if e := ctx.Err(); e != nil {
return e
}
kind, p, e := f.rx.feed(b)
if e != nil {
return e
}
if kind != 0 {
return f.write(kind, p)
}
return nil
}
func (f *fakeLink) Packets() <-chan []byte { return f.packets }
func (f *fakeLink) Done() <-chan error { return f.done }
func (f *fakeLink) Close() { f.mu.Lock(); f.closed = true; f.mu.Unlock() }
var testTarget = Target{UUID: "00112233-4455-6677-8899-aabbccddeeff", Model: "sesame_6_pro", Adapter: "hci0"}
var testKey = Credential{Secret: "000102030405060708090a0b0c0d0e0f"}
func readySession(t *testing.T) (*session, *fakeLink, *sessionCipher) {
t.Helper()
f := newLink()
key, _ := testKey.key()
peer, auth, _ := newCipher(key, unhex("11223344"))
f.write = func(kind byte, b []byte) error {
if kind != 1 || !bytes.Equal(b, append([]byte{2}, auth[:4]...)) {
return errors.New("wrong login")
}
reply, _ := peer.encrypt([]byte{7, 2, 0, 0, 0, 0, 0})
f.emit(2, reply)
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
s, e := login(ctx, f, testKey, nil)
if e != nil {
t.Fatal(e)
}
return s, f, peer
}
func TestLoginCalibrationAndCommandAcknowledgement(t *testing.T) {
s, f, peer := readySession(t)
s.state.publish(81, []byte{0, 0, 0, 128, 45, 0, 16})
s.state.publish(80, []byte{90, 0, 0, 0, 0, 0})
s.state.publish(20, []byte{30, 0})
commands := [][]byte{{80, 45, 0, 0, 0}, {80, 45, 0, 45, 0}, {20, 45, 0}, {83, 0, 14}, {82, 0, 14}}
i := 0
f.write = func(kind byte, b []byte) error {
if kind != 2 {
return errors.New("plaintext command")
}
p, e := peer.decrypt(b)
if e != nil {
return e
}
if !bytes.Equal(p, commands[i]) {
t.Errorf("command %d: %x", i, p)
}
i++
reply, _ := peer.encrypt([]byte{7, p[0], 0})
f.emit(2, reply)
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
for _, action := range []string{"set-lock", "set-unlock", "set-boundary", "unlock", "lock"} {
if e := s.action(ctx, action); e != nil {
t.Fatal(e)
}
}
if s.state.Locked {
t.Fatal("command fabricated physical lock state")
}
if *s.state.Boundary != 45 {
t.Fatal("boundary not updated")
}
f.write = func(kind byte, b []byte) error {
p, e := peer.decrypt(b)
if e != nil {
return e
}
reply, _ := peer.encrypt([]byte{7, p[0], 7})
f.emit(2, reply)
return nil
}
var rejected *ResultError
if e := s.action(ctx, "unlock"); !errors.As(e, &rejected) || rejected.Code != 7 {
t.Fatal("did not propagate BUSY", e)
}
}
func TestSessionRejectsTamperingAndPlaintext(t *testing.T) {
s, _, peer := readySession(t)
cipher, _ := peer.encrypt([]byte{8, 81, 0, 0, 0, 128, 1, 0, 16})
cipher[len(cipher)-1] ^= 1
fs, _ := fragments(2, cipher)
if _, e := s.decode(fs[0]); e == nil {
t.Fatal("accepted tampered notification")
}
fs, _ = fragments(1, []byte{8, 81, 0, 0, 0, 128, 1, 0, 16})
if _, e := s.decode(fs[0]); e == nil {
t.Fatal("accepted plaintext status")
}
}
func TestRegistrationAndRegisteredDeviceRefusal(t *testing.T) {
f := newLink()
device, _ := ecdh.P256().GenerateKey(rand.Reader)
var want string
f.write = func(kind byte, p []byte) error {
if kind != 1 || len(p) != 69 || p[0] != 1 {
return errors.New("invalid registration command")
}
pub, e := ecdh.P256().NewPublicKey(append([]byte{4}, p[1:65]...))
if e != nil {
return e
}
shared, e := device.ECDH(pub)
if e != nil {
return e
}
want = hex.EncodeToString(shared[:16])
reply := make([]byte, 77)
copy(reply[13:], device.PublicKey().Bytes()[1:])
f.emit(1, append([]byte{7, 1, 0}, reply...))
return nil
}
dial := func(context.Context, Target) (Link, Target, error) { return f, testTarget, nil }
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
saved := ""
if e := Register(ctx, testTarget, dial, func(c Credential) error { saved = c.Secret; return nil }); e != nil || saved == "" || saved != want {
t.Fatal("credential derivation", e)
}
registered := testTarget
registered.Registered = true
f = newLink()
f.write = func(byte, []byte) error { t.Fatal("wrote to registered device"); return nil }
dial = func(context.Context, Target) (Link, Target, error) { return f, registered, nil }
if e := Register(ctx, registered, dial, func(Credential) error { t.Fatal("overwrote credential"); return nil }); e == nil {
t.Fatal("registered an owned lock")
}
}
func TestMissingBoundaryDoesNotWrite(t *testing.T) {
s, f, _ := readySession(t)
f.write = func(byte, []byte) error { t.Fatal("unsupported setting sent"); return nil }
if e := s.action(context.Background(), "set-boundary"); e == nil {
t.Fatal("accepted unsupported boundary")
}
}
func TestClientCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
dial := func(ctx context.Context, _ Target) (Link, Target, error) {
<-ctx.Done()
return nil, Target{}, ctx.Err()
}
c := NewClient(testTarget, testKey, dial)
done := make(chan struct{})
go func() { c.Run(ctx); close(done) }()
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("client did not stop")
}
if c.State().Online {
t.Fatal("online after stop")
}
}
func TestClientReceivesManualStateAndDoesNotReplay(t *testing.T) {
f := newLink()
key, _ := testKey.key()
peer, auth, _ := newCipher(key, unhex("11223344"))
writes := make(chan byte, 8)
f.write = func(kind byte, b []byte) error {
if kind == 1 {
if !bytes.Equal(b, append([]byte{2}, auth[:4]...)) {
return errors.New("login mismatch")
}
reply, _ := peer.encrypt([]byte{7, 2, 0, 0, 0, 0, 0})
f.emit(2, reply)
state, _ := peer.encrypt([]byte{8, 81, 0, 0, 0, 128, 90, 0, 18})
f.emit(2, state)
return nil
}
p, e := peer.decrypt(b)
if e != nil {
return e
}
writes <- p[0]
// Deliberately drop the acknowledgement. Client must invalidate this session.
return nil
}
actual := testTarget
actual.Registered = true
c := NewClient(testTarget, testKey, func(context.Context, Target) (Link, Target, error) { return f, actual, nil })
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { c.Run(ctx); close(done) }()
defer func() { cancel(); <-done }()
deadline := time.Now().Add(time.Second)
for c.State().Position == nil && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if s := c.State(); !s.Online || s.Position == nil || *s.Position != 90 || !s.Locked {
t.Fatalf("manual status not delivered: %+v", s)
}
request, stop := context.WithTimeout(ctx, 20*time.Millisecond)
defer stop()
if e := c.Action(request, "unlock"); e == nil {
t.Fatal("missing ack reported success")
}
deadline = time.Now().Add(time.Second)
for c.State().Online && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if c.State().Online {
t.Fatal("timed-out crypto session remained online")
}
if len(writes) != 1 || <-writes != 83 {
t.Fatal("replayed or missed unlock")
}
}
func TestInitialTokenTimeoutIdentifiesStage(t *testing.T) {
f := &fakeLink{packets: make(chan []byte), done: make(chan error)}
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
_, e := login(ctx, f, testKey, nil)
if !errors.Is(e, context.DeadlineExceeded) || !strings.Contains(e.Error(), "initial token") {
t.Fatal("missing stage", e)
}
}
func TestRegistrationTimeoutIdentifiesStageAndDoesNotRetry(t *testing.T) {
for _, stage := range []string{"connect", "initial token", "registration exchange"} {
t.Run(stage, func(t *testing.T) {
f := newLink()
if stage == "initial token" {
<-f.packets // No initial notification arrives.
}
writes := 0
f.write = func(byte, []byte) error {
writes++ // Registration sent, but no response arrives.
return nil
}
dial := func(ctx context.Context, _ Target) (Link, Target, error) {
if stage == "connect" {
<-ctx.Done()
return nil, Target{}, ctx.Err()
}
return f, testTarget, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
e := Register(ctx, testTarget, dial, func(Credential) error {
t.Fatal("saved key without registration response")
return nil
})
if !errors.Is(e, context.DeadlineExceeded) || !strings.Contains(e.Error(), stage) {
t.Fatal("missing timeout stage", e)
}
wantWrites := 0
if stage == "registration exchange" {
wantWrites = 1
}
if writes != wantWrites {
t.Fatalf("registration attempts = %d, want %d", writes, wantWrites)
}
})
}
}
func TestReconnectUsesNewTokenAndResetsCipher(t *testing.T) {
var attempts atomic.Int32
firstReady := make(chan *fakeLink, 1)
dial := func(ctx context.Context, target Target) (Link, Target, error) {
n := attempts.Add(1)
f := newLink()
if n == 1 {
firstReady <- f
}
token := []byte{byte(n), 0x22, 0x33, 0x44}
<-f.packets
f.emit(1, append([]byte{8, 14}, token...))
key, _ := testKey.key()
peer, auth, _ := newCipher(key, token)
f.write = func(kind byte, b []byte) error {
if kind != 1 || !bytes.Equal(b, append([]byte{2}, auth[:4]...)) {
return errors.New("reconnect reused login token")
}
reply, _ := peer.encrypt([]byte{7, 2, 0, 0, 0, 0, 0})
f.emit(2, reply)
state, _ := peer.encrypt([]byte{8, 81, 0, 0, 0, 128, byte(n), 0, 18})
f.emit(2, state)
return nil
}
target.Registered = true
return f, target, nil
}
c := NewClient(testTarget, testKey, dial)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { c.Run(ctx); close(done) }()
defer func() { cancel(); <-done }()
await := func(position int16) {
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
s := c.State()
if s.Online && s.Position != nil && *s.Position == position {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("did not authenticate session %d: %+v", position, c.State())
}
await(1)
first := <-firstReady
first.done <- errors.New("battery removed")
await(2)
if attempts.Load() != 2 {
t.Fatal("unexpected reconnect attempts", attempts.Load())
}
}
func TestMoveToEncodingRejectionAndLiveState(t *testing.T) {
s, f, peer := readySession(t)
s.state.publish(81, []byte{0, 0, 0, 128, 45, 0, 16})
s.state.publish(80, []byte{90, 0, 0, 0, 0, 0})
var want []byte
code := byte(0)
f.write = func(kind byte, b []byte) error {
if kind != 2 {
t.Fatal("plaintext move")
}
p, e := peer.decrypt(b)
if e != nil {
return e
}
if !bytes.Equal(p, want) {
t.Fatalf("move-to = %x, want %x", p, want)
}
reply, _ := peer.encrypt([]byte{7, 84, code})
f.emit(2, reply)
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
for _, tc := range []struct {
position int16
payload []byte
}{{0, []byte{84, 0, 0}}, {-1, []byte{84, 255, 255}}, {-32767, []byte{84, 1, 128}}, {32767, []byte{84, 255, 127}}} {
want = tc.payload
if e := s.actionPosition(ctx, "move-to", &tc.position); e != nil {
t.Fatal(e)
}
if *s.state.Position != 45 || s.state.Target != nil || *s.state.LockPosition != 90 || *s.state.UnlockPosition != 0 {
t.Fatal("move fabricated status or changed calibration")
}
}
code = 2 // Unsupported firmware must reject without losing the session.
position := int16(-1)
want = []byte{84, 255, 255}
var rejected *ResultError
if e := s.actionPosition(ctx, "move-to", &position); !errors.As(e, &rejected) || rejected.Item != 84 || rejected.Code != 2 {
t.Fatal("missing firmware rejection", e)
}
f.write = func(byte, []byte) error { t.Fatal("invalid move sent"); return nil }
position = -32768
if e := s.actionPosition(ctx, "move-to", &position); e == nil {
t.Fatal("accepted reserved target")
}
if e := s.actionPosition(ctx, "move-to", nil); e == nil {
t.Fatal("accepted missing target")
}
}