commit d42f3aa12b07a43af4dee3655967c9c1c35136b7
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-07T11:51:44Z |
| subject | Added support for sesame locks |
commit d42f3aa12b07a43af4dee3655967c9c1c35136b7
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-07T11:51:44Z
Added support for sesame locks
---
.dockerignore | 2 +
Dockerfile | 2 +
NOTICE | 8 +
README.md | 9 +
cmd/homekey/main.go | 17 ++
config.example.json | 3 +-
drivers/sesame/LICENSE | 21 +++
drivers/sesame/README.md | 52 ++++++
drivers/sesame/bluez.go | 353 +++++++++++++++++++++++++++++++++++
drivers/sesame/client.go | 379 ++++++++++++++++++++++++++++++++++++++
drivers/sesame/client_test.go | 257 ++++++++++++++++++++++++++
drivers/sesame/crypto.go | 179 ++++++++++++++++++
drivers/sesame/protocol.go | 196 ++++++++++++++++++++
drivers/sesame/protocol_test.go | 150 +++++++++++++++
ha-app/DOCS.md | 38 ++++
internal/app/app_test.go | 9 +
internal/app/config.go | 21 ++-
internal/app/page.html | 43 ++++-
internal/app/pairing.go | 14 +-
internal/app/physical.go | 149 +++++++++++++++
internal/app/physical_test.go | 75 ++++++++
internal/app/reader.go | 45 ++++-
internal/app/settings.go | 9 +
internal/app/settings_test.go | 17 +-
internal/app/web.go | 1 +
internal/physical/manager.go | 303 ++++++++++++++++++++++++++++++
internal/physical/manager_test.go | 154 ++++++++++++++++
scripts/stage-ha-app.sh | 2 +-
28 files changed, 2480 insertions(+), 28 deletions(-)
diff --git a/.dockerignore b/.dockerignore
index 8b17b93..0bc9bcd 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,6 +5,8 @@
!cmd/**
!internal/
!internal/**
+!drivers/
+!drivers/**
!ha-app/
!ha-app/run.sh
!LICENSE
diff --git a/Dockerfile b/Dockerfile
index 704afa5..f30533c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,6 +6,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
+COPY drivers ./drivers
RUN CGO_ENABLED=0 go build -mod=readonly -buildvcs=false -trimpath \
-ldflags='-s -w' -o /out/homekey ./cmd/homekey
@@ -28,6 +29,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY --from=builder /out/homekey /usr/local/bin/homekey
COPY ha-app/run.sh /usr/local/bin/homekey-entrypoint
COPY LICENSE NOTICE /usr/share/doc/homekey-go/
+COPY drivers/sesame/LICENSE /usr/share/doc/homekey-go/sesame-LICENSE
RUN chmod 0755 /usr/local/bin/homekey-entrypoint
WORKDIR /data
ENTRYPOINT ["/usr/local/bin/homekey-entrypoint"]
diff --git a/NOTICE b/NOTICE
index d13a95a..90c682f 100644
--- a/NOTICE
+++ b/NOTICE
@@ -27,3 +27,11 @@ notices are retained. Locally modified files carry notices; the changes are
recorded in patches/hap.patch. Local regression tests are in
third_party/hap/homekey_patch_test.go. Generator, example and image assets
not needed for building the library were omitted.
+
+Sesame OS3 BLE protocol and registration/calibration behavior adapted from:
+ CANDY HOUSE SesameSDK_Android_with_DemoApp
+ Commit d12f23c25bffd5adf6361abd0db82bb144441875
+ Copyright (c) 2014-2026 CANDY HOUSE
+ MIT License; full notice retained in drivers/sesame/LICENSE.
+Changes: standalone Go implementation with BlueZ transport, bounded framing,
+strict session validation, private credential storage and web setup controls.
diff --git a/README.md b/README.md
index 06229aa..f8babeb 100644
--- a/README.md
+++ b/README.md
@@ -176,3 +176,12 @@ public Home group ID over BLE on reconnect and after provisioning changes; the
ESP32 emits ECP before activating a card so iOS can route NFC to Home Key. The
phone's Express Mode setting governs whether user approval is required.
See [NFC deployment instructions](ha-app/NFC.md).
+
+## Sesame physical locks
+
+The web UI can scan and enroll Sesame 5/5 Pro/6/6 Pro locks, import an existing
+local key, capture lock/unlock positions and the supported boundary, and assign
+physical locks to Home Key locks. See [setup instructions](ha-app/DOCS.md#physical-locks-sesame)
+and [driver notes](drivers/sesame/README.md). Credentials stay in private state;
+`drivers/sesame` owns the protocol and direct BlueZ transport. Real-device
+registration, calibration and radio behavior still require hardware validation.
diff --git a/cmd/homekey/main.go b/cmd/homekey/main.go
index 4f7220b..997f938 100644
--- a/cmd/homekey/main.go
+++ b/cmd/homekey/main.go
@@ -9,6 +9,7 @@ import (
"fmt"
"homekey.local/provisioner/internal/app"
"homekey.local/provisioner/internal/blegateway"
+ "homekey.local/provisioner/internal/physical"
"homekey.local/provisioner/internal/provision"
"log"
"net"
@@ -115,7 +116,23 @@ func serve(processCtx context.Context, state, webAddr string, ingress, status bo
}
ctx, cancel := context.WithCancel(processCtx)
defer cancel()
+ physicalLocks, err := physical.Open(ctx, state, nil)
+ if err != nil {
+ return err
+ }
+ defer physicalLocks.Close()
+ settings.Physical = physicalLocks
+ for _, l := range locks {
+ for _, id := range l.Config.PhysicalLocks {
+ if !physicalLocks.Has(id) {
+ return fmt.Errorf("unknown physical lock %s assigned to %s", id, l.Config.ID)
+ }
+ }
+ }
var wg sync.WaitGroup
+ updatePhysical := app.BindPhysical(locks, physicalLocks)
+ wg.Add(1)
+ go func() { defer wg.Done(); updatePhysical(ctx) }()
failures := make(chan error, len(locks)+1)
for _, l := range locks {
wg.Add(1)
diff --git a/config.example.json b/config.example.json
index 6276fa0..573e746 100644
--- a/config.example.json
+++ b/config.example.json
@@ -6,7 +6,8 @@
"name": "Go Home Key",
"port": 51826,
"finish": "silver",
- "readers": ["porch"]
+ "readers": ["porch"],
+ "physical_locks": []
}
],
"readers": [
diff --git a/drivers/sesame/LICENSE b/drivers/sesame/LICENSE
new file mode 100644
index 0000000..30915f8
--- /dev/null
+++ b/drivers/sesame/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2014-2026 CANDY HOUSE
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/drivers/sesame/README.md b/drivers/sesame/README.md
new file mode 100644
index 0000000..473d31f
--- /dev/null
+++ b/drivers/sesame/README.md
@@ -0,0 +1,52 @@
+# Sesame OS3 driver
+
+This directory contains the Sesame protocol, BlueZ transport and hardware-free
+tests. Supported advertised models: Sesame 5, 5 Pro, 6 and 6 Pro. It does not
+implement Sesame OS2, Hub/Matter setup, cloud guest-key signing or firmware updates.
+
+The protocol is adapted from the MIT-licensed CANDY HOUSE Android SDK at
+`d12f23c25bffd5adf6361abd0db82bb144441875`. See `LICENSE` and the root `NOTICE`.
+
+## Integration
+
+`Discover` scans powered local BlueZ adapters. Devices are matched by their
+advertised Sesame UUID and supported product ID, not by a friendly name or fixed
+Bluetooth MAC. `Register` enrolls an unregistered device with P-256 ECDH and calls
+a persistence callback as soon as the 16-byte credential is available. It never
+issues a reset. Registration here provisions BLE access, not a Sesame cloud
+account. `Probe` validates an imported local key by logging in without moving
+the motor. Cloud-only guest keys are not supported.
+
+`Client.Run` maintains a connection and reconnects after transport or crypto
+failure. `Action` serializes operations on that connection and waits for the
+lock's result code. Failed/timed-out operations are not replayed after reconnect.
+`State` returns the latest immutable snapshot; pointers in snapshots must not be
+modified by callers. Each reconnect clears state until new publications arrive.
+
+Commands: `lock`, `unlock`, `set-lock`, `set-unlock`, `set-boundary`. Calibration
+captures the current reported position; saving either endpoint preserves the
+other endpoint. Boundary capture is allowed only after firmware has published
+its boundary capability. Positions use the SDK's signed 16-bit angle values;
+negative values, including -1, are valid.
+
+OS3 framing uses 19 payload bytes per GATT fragment. Local login uses AES-CMAC.
+The fixed CCM profile is 13-byte nonce, 4-byte tag, AAD 00; the nonce contains an
+8-byte LE counter, zero, and a 4-byte token. Unsupported token lengths fail
+explicitly. No keys, authentication data or decrypted packets are logged.
+
+`Link`/`Dialer` separate the session protocol from BlueZ so a future ESP32 GATT
+tunnel can reuse this implementation. This release requires the HA/server's
+local Bluetooth adapter to reach the lock; it does not use HA Bluetooth proxies
+or add an ESP32 central role.
+
+## Validation
+
+Run `go test -race ./...` and `go vet ./...` at repository root. Tests cover RFC
+4493 CMAC vectors, independently generated OpenSSL/Python CCM vectors with high
+counter bytes, tampering, framing, P-256 registration, commands, calibration,
+unsupported boundary settings, cancellation, private storage and HTTP validation.
+
+Still requires real hardware validation: local BLE registration/login, initial
+token length and response encryption, notifications during manual movement,
+calibration/motor behavior, coexistence with the phone app, and battery impact
+of a persistent connection. Unit tests do not establish those device behaviors.
diff --git a/drivers/sesame/bluez.go b/drivers/sesame/bluez.go
new file mode 100644
index 0000000..43e9c37
--- /dev/null
+++ b/drivers/sesame/bluez.go
@@ -0,0 +1,353 @@
+// SPDX-License-Identifier: Apache-2.0
+package sesame
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+)
+
+type Link interface {
+ Write(context.Context, []byte) error
+ Packets() <-chan []byte
+ Done() <-chan error
+ Close()
+}
+type Dialer func(context.Context, Target) (Link, Target, error)
+type Adapter struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Powered bool `json:"powered"`
+}
+type Discovery struct {
+ Adapters []Adapter `json:"adapters"`
+ Locks []Target `json:"locks"`
+ Warnings []string `json:"warnings"`
+}
+type objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant
+
+func getObjects(ctx context.Context, c *dbus.Conn) (objects, error) {
+ var o objects
+ e := c.Object("org.bluez", "/").CallWithContext(ctx, "org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&o)
+ return o, e
+}
+func value(p map[string]dbus.Variant, k string) string { s, _ := p[k].Value().(string); return s }
+
+var adapterPattern = regexp.MustCompile(`^hci[0-9]+$`)
+
+func ValidateTarget(t Target) error {
+ if _, e := NormalizeUUID(t.UUID); e != nil {
+ return e
+ }
+ if !Supported(t.Model) {
+ return errors.New("unsupported Sesame model")
+ }
+ if !adapterPattern.MatchString(t.Adapter) {
+ return errors.New("invalid Bluetooth adapter")
+ }
+ return nil
+}
+func advertised(p map[string]dbus.Variant) (Target, bool) {
+ // BlueZ strips the two-byte company identifier. Do not accept arbitrary
+ // manufacturer payloads merely because their first byte matches a model.
+ md, ok := p["ManufacturerData"].Value().(map[uint16]dbus.Variant)
+ if !ok {
+ return Target{}, false
+ }
+ v, ok := md[0x055a]
+ if !ok {
+ return Target{}, false
+ }
+ b, ok := v.Value().([]byte)
+ if !ok {
+ return Target{}, false
+ }
+ t, ok := Advertisement(b)
+ if !ok {
+ return Target{}, false
+ }
+ a, _ := p["Adapter"].Value().(dbus.ObjectPath)
+ parts := strings.Split(string(a), "/")
+ t.Adapter = parts[len(parts)-1]
+ t.Address = value(p, "Address")
+ return t, true
+}
+func snapshot(o objects) Discovery {
+ d := Discovery{Adapters: []Adapter{}, Locks: []Target{}, Warnings: []string{}}
+ for path, ifs := range o {
+ if p, ok := ifs["org.bluez.Adapter1"]; ok {
+ v, _ := p["Powered"].Value().(bool)
+ parts := strings.Split(string(path), "/")
+ d.Adapters = append(d.Adapters, Adapter{parts[len(parts)-1], value(p, "Alias"), v})
+ }
+ if p, ok := ifs["org.bluez.Device1"]; ok {
+ if t, ok := advertised(p); ok {
+ d.Locks = append(d.Locks, t)
+ }
+ }
+ }
+ sort.Slice(d.Adapters, func(i, j int) bool { return d.Adapters[i].ID < d.Adapters[j].ID })
+ sort.Slice(d.Locks, func(i, j int) bool { return d.Locks[i].Adapter+d.Locks[i].UUID < d.Locks[j].Adapter+d.Locks[j].UUID })
+ return d
+}
+func pause(ctx context.Context, d time.Duration) error {
+ t := time.NewTimer(d)
+ defer t.Stop()
+ select {
+ case <-t.C:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+func stopScan(a dbus.BusObject) {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _ = a.CallWithContext(ctx, "org.bluez.Adapter1.StopDiscovery", 0).Err
+}
+func Discover(ctx context.Context) (Discovery, error) {
+ c, e := dbus.ConnectSystemBus()
+ if e != nil {
+ return Discovery{}, fmt.Errorf("Bluetooth unavailable: %w", e)
+ }
+ defer c.Close()
+ o, e := getObjects(ctx, c)
+ if e != nil {
+ return Discovery{}, e
+ }
+ d := snapshot(o)
+ var active []dbus.BusObject
+ defer func() {
+ for _, a := range active {
+ stopScan(a)
+ }
+ }()
+ for _, a := range d.Adapters {
+ if !a.Powered {
+ d.Warnings = append(d.Warnings, a.ID+" is switched off")
+ continue
+ }
+ obj := c.Object("org.bluez", dbus.ObjectPath("/org/bluez/"+a.ID))
+ if e := obj.CallWithContext(ctx, "org.bluez.Adapter1.StartDiscovery", 0).Err; e != nil {
+ d.Warnings = append(d.Warnings, a.ID+": "+e.Error())
+ } else {
+ active = append(active, obj)
+ }
+ }
+ if len(active) > 0 {
+ if e := pause(ctx, 5*time.Second); e != nil {
+ return Discovery{}, e
+ }
+ }
+ o, e = getObjects(ctx, c)
+ if e != nil {
+ return Discovery{}, e
+ }
+ out := snapshot(o)
+ out.Warnings = d.Warnings
+ if len(out.Adapters) == 0 {
+ out.Warnings = append(out.Warnings, "No local Bluetooth adapters found")
+ }
+ return out, nil
+}
+
+type bluezLink struct {
+ conn *dbus.Conn
+ device, write dbus.BusObject
+ mode string
+ packets chan []byte
+ done chan error
+ cancel context.CancelFunc
+ once sync.Once
+}
+
+func (b *bluezLink) Packets() <-chan []byte { return b.packets }
+func (b *bluezLink) Done() <-chan error { return b.done }
+func (b *bluezLink) Write(ctx context.Context, data []byte) error {
+ return b.write.CallWithContext(ctx, "org.bluez.GattCharacteristic1.WriteValue", 0, data, map[string]dbus.Variant{"type": dbus.MakeVariant(b.mode)}).Err
+}
+func (b *bluezLink) Close() {
+ b.once.Do(func() {
+ b.cancel()
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _ = b.device.CallWithContext(ctx, "org.bluez.Device1.Disconnect", 0).Err
+ b.conn.Close()
+ })
+}
+func DialBlueZ(ctx context.Context, t Target) (Link, Target, error) {
+ if e := ValidateTarget(t); e != nil {
+ return nil, Target{}, e
+ }
+ c, e := dbus.ConnectSystemBus()
+ if e != nil {
+ return nil, Target{}, e
+ }
+ success := false
+ var device dbus.BusObject
+ owned := false
+ defer func() {
+ if !success {
+ if owned {
+ cleanup, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _ = device.CallWithContext(cleanup, "org.bluez.Device1.Disconnect", 0).Err
+ }
+ c.Close()
+ }
+ }()
+ adapter := c.Object("org.bluez", dbus.ObjectPath("/org/bluez/"+t.Adapter))
+ if e = adapter.CallWithContext(ctx, "org.bluez.Adapter1.StartDiscovery", 0).Err; e != nil {
+ return nil, Target{}, e
+ }
+ defer stopScan(adapter)
+ var path dbus.ObjectPath
+ var found Target
+ for path == "" {
+ o, e := getObjects(ctx, c)
+ if e != nil {
+ return nil, Target{}, e
+ }
+ for p, ifs := range o {
+ props, ok := ifs["org.bluez.Device1"]
+ if !ok {
+ continue
+ }
+ candidate, ok := advertised(props)
+ if ok && candidate.UUID == t.UUID && candidate.Adapter == t.Adapter {
+ if candidate.Model != t.Model {
+ return nil, Target{}, errors.New("Sesame model changed; scan again")
+ }
+ connected, _ := props["Connected"].Value().(bool)
+ if connected {
+ return nil, Target{}, errors.New("Sesame is already connected through this adapter")
+ }
+ path = p
+ found = candidate
+ break
+ }
+ }
+ if path == "" {
+ if e = pause(ctx, 250*time.Millisecond); e != nil {
+ return nil, Target{}, fmt.Errorf("Sesame discovery: %w", e)
+ }
+ }
+ }
+ device = c.Object("org.bluez", path)
+ owned = true
+ if e = device.CallWithContext(ctx, "org.bluez.Device1.Connect", 0).Err; e != nil {
+ return nil, Target{}, e
+ }
+ var writePath, notifyPath dbus.ObjectPath
+ mode := ""
+ for writePath == "" || notifyPath == "" {
+ o, e := getObjects(ctx, c)
+ if e != nil {
+ return nil, Target{}, e
+ }
+ resolved, _ := o[path]["org.bluez.Device1"]["ServicesResolved"].Value().(bool)
+ if resolved {
+ for p, ifs := range o {
+ v, ok := ifs["org.bluez.GattCharacteristic1"]
+ if !ok {
+ continue
+ }
+ sp, _ := v["Service"].Value().(dbus.ObjectPath)
+ service := o[sp]["org.bluez.GattService1"]
+ owner, _ := service["Device"].Value().(dbus.ObjectPath)
+ if owner != path || !strings.EqualFold(value(service, "UUID"), ServiceUUID) {
+ continue
+ }
+ switch strings.ToLower(value(v, "UUID")) {
+ case WriteUUID:
+ flags, _ := v["Flags"].Value().([]string)
+ for _, f := range flags {
+ if f == "write" && mode == "" {
+ mode = "request"
+ }
+ if f == "write-without-response" {
+ mode = "command"
+ }
+ }
+ writePath = p
+ case NotifyUUID:
+ notifyPath = p
+ }
+ }
+ }
+ if writePath == "" || notifyPath == "" {
+ if e = pause(ctx, 100*time.Millisecond); e != nil {
+ return nil, Target{}, e
+ }
+ }
+ }
+ if mode == "" {
+ return nil, Target{}, errors.New("Sesame command characteristic is not writable")
+ }
+ signals := make(chan *dbus.Signal, 256)
+ c.Signal(signals)
+ if e = c.AddMatchSignal(dbus.WithMatchSender("org.bluez"), dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), dbus.WithMatchMember("PropertiesChanged"), dbus.WithMatchPathNamespace(path)); e != nil {
+ return nil, Target{}, e
+ }
+ life, cancel := context.WithCancel(context.Background())
+ b := &bluezLink{conn: c, device: device, write: c.Object("org.bluez", writePath), mode: mode, packets: make(chan []byte, 128), done: make(chan error, 1), cancel: cancel}
+ go func() {
+ fail := func(e error) {
+ select {
+ case b.done <- e:
+ default:
+ }
+ }
+ defer c.RemoveSignal(signals)
+ for {
+ select {
+ case <-life.Done():
+ return
+ case sig, ok := <-signals:
+ if !ok {
+ fail(errors.New("Bluetooth bus disconnected"))
+ return
+ }
+ if sig == nil || len(sig.Body) < 2 {
+ continue
+ }
+ props, ok := sig.Body[1].(map[string]dbus.Variant)
+ if !ok {
+ continue
+ }
+ if sig.Path == path {
+ if v, exists := props["Connected"]; exists {
+ on, _ := v.Value().(bool)
+ if !on {
+ fail(errors.New("Sesame disconnected"))
+ return
+ }
+ }
+ }
+ if sig.Path == notifyPath {
+ if data, ok := props["Value"].Value().([]byte); ok {
+ select {
+ case b.packets <- append([]byte(nil), data...):
+ default:
+ fail(errors.New("Sesame notification queue overflow"))
+ return
+ }
+ }
+ }
+ }
+ }
+ }()
+ if e = c.Object("org.bluez", notifyPath).CallWithContext(ctx, "org.bluez.GattCharacteristic1.StartNotify", 0).Err; e != nil {
+ cancel()
+ return nil, Target{}, e
+ }
+ success = true
+ return b, found, nil
+}
diff --git a/drivers/sesame/client.go b/drivers/sesame/client.go
new file mode 100644
index 0000000..03fa674
--- /dev/null
+++ b/drivers/sesame/client.go
@@ -0,0 +1,379 @@
+// SPDX-License-Identifier: Apache-2.0
+package sesame
+
+import (
+ "context"
+ "crypto/ecdh"
+ "crypto/rand"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "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, 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 {
+ l, actual, e := dial(ctx, t)
+ if e != nil {
+ return e
+ }
+ defer l.Close()
+ if actual.Registered {
+ return errors.New("lock already registered: import its local key instead")
+ }
+ s := &session{link: l}
+ if _, e = s.initial(ctx); e != nil {
+ return 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...)
+ reply, e := s.exchange(ctx, 1, payload, false)
+ if e != nil {
+ return 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
+ }
+ return persist(Credential{Secret: hex.EncodeToString(shared[:16])})
+}
+func Probe(ctx context.Context, t Target, c Credential, dial Dialer) error {
+ l, actual, e := dial(ctx, t)
+ if e != nil {
+ return e
+ }
+ defer l.Close()
+ if !actual.Registered {
+ return errors.New("lock is unregistered; register it first")
+ }
+ _, e = login(ctx, l, c, nil)
+ return e
+}
+
+type operation struct {
+ ctx context.Context
+ name string
+ 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")
+ }
+ if !c.State().Online {
+ return errors.New("physical lock is offline")
+ }
+ op := operation{ctx, name, 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()})
+ 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 pause(ctx, 3*time.Second) != nil {
+ return
+ }
+ }
+}
+func (c *Client) runSession(ctx context.Context) error {
+ connect, cancel := context.WithTimeout(ctx, 20*time.Second)
+ l, actual, e := c.dial(connect, c.target)
+ if e != nil {
+ cancel()
+ return e
+ }
+ defer l.Close()
+ if !actual.Registered {
+ cancel()
+ return errors.New("Sesame was reset; enroll it again")
+ }
+ s, e := login(connect, l, c.credential, c.set)
+ cancel()
+ if e != nil {
+ return e
+ }
+ 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.action(command, op.name)
+ 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 {
+ var item byte
+ var payload []byte
+ switch name {
+ 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
+}
diff --git a/drivers/sesame/client_test.go b/drivers/sesame/client_test.go
new file mode 100644
index 0000000..d200181
--- /dev/null
+++ b/drivers/sesame/client_test.go
@@ -0,0 +1,257 @@
+package sesame
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdh"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "sync"
+ "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")
+ }
+}
diff --git a/drivers/sesame/crypto.go b/drivers/sesame/crypto.go
new file mode 100644
index 0000000..bacb251
--- /dev/null
+++ b/drivers/sesame/crypto.go
@@ -0,0 +1,179 @@
+// SPDX-License-Identifier: Apache-2.0
+package sesame
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/subtle"
+ "encoding/binary"
+ "errors"
+ "math"
+)
+
+// RFC 4493 AES-CMAC (full 128-bit output).
+func cmac(key, data []byte) ([]byte, error) {
+ b, e := aes.NewCipher(key)
+ if e != nil {
+ return nil, e
+ }
+ double := func(x []byte) []byte {
+ r := make([]byte, 16)
+ var c byte
+ for i := 15; i >= 0; i-- {
+ r[i] = x[i]<<1 | c
+ c = x[i] >> 7
+ }
+ if c != 0 {
+ r[15] ^= 0x87
+ }
+ return r
+ }
+ l := make([]byte, 16)
+ b.Encrypt(l, l)
+ k1 := double(l)
+ k2 := double(k1)
+ n := (len(data) + 15) / 16
+ if n == 0 {
+ n = 1
+ }
+ last := make([]byte, 16)
+ copy(last, data[(n-1)*16:])
+ k := k1
+ if len(data) == 0 || len(data)%16 != 0 {
+ last[len(data)%16] = 0x80
+ k = k2
+ }
+ state := make([]byte, 16)
+ for i := 0; i < n-1; i++ {
+ for j := range state {
+ state[j] ^= data[i*16+j]
+ }
+ b.Encrypt(state, state)
+ }
+ for j := range state {
+ state[j] ^= last[j] ^ k[j]
+ }
+ b.Encrypt(state, state)
+ return state, nil
+}
+
+// Fixed OS3 CCM profile, NIST SP 800-38C: nonce 13, tag 4, L=2, AAD 00.
+// It deliberately supports only this protocol profile, not a general AEAD API.
+func ccmTag(b cipher.Block, nonce, plain []byte) []byte {
+ state := make([]byte, 16)
+ state[0] = 0x49
+ copy(state[1:], nonce)
+ binary.BigEndian.PutUint16(state[14:], uint16(len(plain)))
+ b.Encrypt(state, state)
+ // Encoded AAD: two-byte length 1, byte 00, then zero padding.
+ state[1] ^= 1
+ b.Encrypt(state, state)
+ for off := 0; off < len(plain); off += 16 {
+ n := len(plain) - off
+ if n > 16 {
+ n = 16
+ }
+ for i := 0; i < n; i++ {
+ state[i] ^= plain[off+i]
+ }
+ b.Encrypt(state, state)
+ }
+ return state[:4]
+}
+func ccmStream(b cipher.Block, nonce []byte, count uint16) []byte {
+ in := make([]byte, 16)
+ in[0] = 1
+ copy(in[1:], nonce)
+ binary.BigEndian.PutUint16(in[14:], count)
+ b.Encrypt(in, in)
+ return in
+}
+func ccmSeal(b cipher.Block, nonce, plain []byte) ([]byte, error) {
+ if len(nonce) != 13 || len(plain) > maxMessage-4 {
+ return nil, errors.New("invalid CCM input")
+ }
+ out := make([]byte, len(plain)+4)
+ tag := ccmTag(b, nonce, plain)
+ s0 := ccmStream(b, nonce, 0)
+ for off := 0; off < len(plain); off += 16 {
+ s := ccmStream(b, nonce, uint16(off/16+1))
+ for j := 0; j < 16 && off+j < len(plain); j++ {
+ out[off+j] = plain[off+j] ^ s[j]
+ }
+ }
+ for i := 0; i < 4; i++ {
+ out[len(plain)+i] = tag[i] ^ s0[i]
+ }
+ return out, nil
+}
+func ccmOpen(b cipher.Block, nonce, in []byte) ([]byte, error) {
+ if len(nonce) != 13 || len(in) < 4 || len(in) > maxMessage {
+ return nil, errors.New("invalid CCM input")
+ }
+ n := len(in) - 4
+ plain := make([]byte, n)
+ for off := 0; off < n; off += 16 {
+ s := ccmStream(b, nonce, uint16(off/16+1))
+ for j := 0; j < 16 && off+j < n; j++ {
+ plain[off+j] = in[off+j] ^ s[j]
+ }
+ }
+ tag := ccmTag(b, nonce, plain)
+ s0 := ccmStream(b, nonce, 0)
+ for i := 0; i < 4; i++ {
+ tag[i] ^= s0[i]
+ }
+ if subtle.ConstantTimeCompare(tag, in[n:]) != 1 {
+ return nil, errors.New("Sesame authentication tag failed")
+ }
+ return plain, nil
+}
+
+type sessionCipher struct {
+ block cipher.Block
+ token [4]byte
+ tx, rx uint64
+}
+
+func newCipher(secret, token []byte) (*sessionCipher, []byte, error) {
+ if len(secret) != 16 || len(token) != 4 {
+ return nil, nil, errors.New("unsupported Sesame key/token length")
+ }
+ auth, e := cmac(secret, token)
+ if e != nil {
+ return nil, nil, e
+ }
+ b, e := aes.NewCipher(auth)
+ if e != nil {
+ return nil, nil, e
+ }
+ s := &sessionCipher{block: b}
+ copy(s.token[:], token)
+ return s, auth, nil
+}
+func (s *sessionCipher) nonce(counter uint64) []byte {
+ n := make([]byte, 13)
+ binary.LittleEndian.PutUint64(n, counter)
+ copy(n[9:], s.token[:])
+ return n
+}
+func (s *sessionCipher) encrypt(b []byte) ([]byte, error) {
+ if s.tx == math.MaxUint64 {
+ return nil, errors.New("Sesame counter exhausted")
+ }
+ out, e := ccmSeal(s.block, s.nonce(s.tx), b)
+ if e == nil {
+ s.tx++
+ }
+ return out, e
+}
+func (s *sessionCipher) decrypt(b []byte) ([]byte, error) {
+ if s.rx == math.MaxUint64 {
+ return nil, errors.New("Sesame counter exhausted")
+ }
+ out, e := ccmOpen(s.block, s.nonce(s.rx), b)
+ if e == nil {
+ s.rx++
+ }
+ return out, e
+}
diff --git a/drivers/sesame/protocol.go b/drivers/sesame/protocol.go
new file mode 100644
index 0000000..61d82c3
--- /dev/null
+++ b/drivers/sesame/protocol.go
@@ -0,0 +1,196 @@
+// SPDX-License-Identifier: Apache-2.0
+// Protocol adapted from CANDY HOUSE's MIT-licensed Sesame Android SDK.
+package sesame
+
+import (
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+const (
+ ServiceUUID = "0000fd81-0000-1000-8000-00805f9b34fb"
+ WriteUUID = "16860002-a5ae-9856-b6d3-dbb4c676993e"
+ NotifyUUID = "16860003-a5ae-9856-b6d3-dbb4c676993e"
+ maxMessage = 1024
+)
+
+type Target struct {
+ UUID string `json:"uuid"`
+ Model string `json:"model"`
+ Adapter string `json:"adapter"`
+ Address string `json:"address"`
+ Registered bool `json:"registered"`
+}
+
+// Credential is private state. Never include it in public configuration/status.
+type Credential struct {
+ Secret string `json:"secret"`
+}
+
+func (c Credential) key() ([]byte, error) {
+ b, e := hex.DecodeString(c.Secret)
+ if e != nil || len(b) != 16 {
+ return nil, errors.New("Sesame secret must contain exactly 32 hexadecimal characters")
+ }
+ return b, nil
+}
+func (c Credential) Validate() error { _, e := c.key(); return e }
+
+func NormalizeUUID(s string) (string, error) {
+ s = strings.ToLower(strings.TrimSpace(s))
+ if len(s) != 36 || s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' {
+ return "", errors.New("invalid Sesame UUID")
+ }
+ b, e := hex.DecodeString(strings.ReplaceAll(s, "-", ""))
+ if e != nil || len(b) != 16 {
+ return "", errors.New("invalid Sesame UUID")
+ }
+ return formatUUID(b), nil
+}
+func formatUUID(b []byte) string {
+ s := hex.EncodeToString(b)
+ return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:]
+}
+func model(product byte) string {
+ switch product {
+ case 5:
+ return "sesame_5"
+ case 7:
+ return "sesame_5_pro"
+ case 20:
+ return "sesame_6"
+ case 21:
+ return "sesame_6_pro"
+ }
+ return ""
+}
+func Supported(s string) bool {
+ for _, p := range []byte{5, 7, 20, 21} {
+ if model(p) == s {
+ return true
+ }
+ }
+ return false
+}
+func Advertisement(b []byte) (Target, bool) {
+ if len(b) < 19 || model(b[0]) == "" {
+ return Target{}, false
+ }
+ return Target{UUID: formatUUID(b[3:19]), Model: model(b[0]), Registered: b[2]&1 != 0}, true
+}
+
+type State struct {
+ Online bool `json:"online"`
+ Error string `json:"error,omitempty"`
+ Position *int16 `json:"position,omitempty"`
+ Target *int16 `json:"target,omitempty"`
+ Locked bool `json:"locked"`
+ Stopped bool `json:"stopped"`
+ Critical bool `json:"critical"`
+ BatteryCritical bool `json:"battery_critical"`
+ LockPosition *int16 `json:"lock_position,omitempty"`
+ UnlockPosition *int16 `json:"unlock_position,omitempty"`
+ Boundary *int16 `json:"boundary,omitempty"`
+ Updated time.Time `json:"updated"`
+}
+
+func i16(b []byte) *int16 { v := int16(binary.LittleEndian.Uint16(b)); return &v }
+func short(v int16) []byte {
+ b := make([]byte, 2)
+ binary.LittleEndian.PutUint16(b, uint16(v))
+ return b
+}
+func (s *State) publish(item byte, b []byte) error {
+ switch item {
+ case 81:
+ if len(b) < 7 {
+ return errors.New("short mechanical status")
+ }
+ s.Position = i16(b[4:6])
+ s.Target = i16(b[2:4])
+ if *s.Target == -32768 {
+ s.Target = nil
+ }
+ s.Locked = b[6]&2 != 0
+ s.Critical = b[6]&8 != 0
+ s.Stopped = b[6]&16 != 0
+ s.BatteryCritical = b[6]&32 != 0
+ case 80:
+ if len(b) < 6 {
+ return errors.New("short mechanical settings")
+ }
+ s.LockPosition = i16(b[:2])
+ s.UnlockPosition = i16(b[2:4])
+ case 20:
+ if len(b) != 2 {
+ return errors.New("invalid lock boundary")
+ }
+ s.Boundary = i16(b)
+ }
+ s.Updated = time.Now()
+ return nil
+}
+
+type assembler struct {
+ data []byte
+ active bool
+}
+
+func (a *assembler) feed(b []byte) (byte, []byte, error) {
+ if len(b) < 2 || len(b) > 20 || b[0] > 5 {
+ return 0, nil, errors.New("invalid Sesame fragment")
+ }
+ start, kind := b[0]&1 != 0, b[0]>>1
+ if start {
+ if a.active {
+ return 0, nil, errors.New("interrupted Sesame message")
+ }
+ a.data = nil
+ a.active = true
+ } else if !a.active {
+ return 0, nil, errors.New("orphan Sesame fragment")
+ }
+ if len(a.data)+len(b)-1 > maxMessage {
+ return 0, nil, errors.New("Sesame message too large")
+ }
+ a.data = append(a.data, b[1:]...)
+ if kind == 0 {
+ return 0, nil, nil
+ }
+ out := a.data
+ a.data = nil
+ a.active = false
+ return kind, out, nil
+}
+func fragments(kind byte, b []byte) ([][]byte, error) {
+ if (kind != 1 && kind != 2) || len(b) == 0 || len(b) > maxMessage {
+ return nil, errors.New("invalid Sesame message")
+ }
+ var out [][]byte
+ start := byte(1)
+ for len(b) > 0 {
+ n := len(b)
+ if n > 19 {
+ n = 19
+ }
+ h := start
+ if n == len(b) {
+ h |= kind << 1
+ }
+ f := append([]byte{h}, b[:n]...)
+ out = append(out, f)
+ b = b[n:]
+ start = 0
+ }
+ return out, nil
+}
+
+type ResultError struct{ Item, Code byte }
+
+func (e *ResultError) Error() string {
+ return fmt.Sprintf("Sesame rejected command 0x%02x (result %d)", e.Item, e.Code)
+}
diff --git a/drivers/sesame/protocol_test.go b/drivers/sesame/protocol_test.go
new file mode 100644
index 0000000..5c097b4
--- /dev/null
+++ b/drivers/sesame/protocol_test.go
@@ -0,0 +1,150 @@
+package sesame
+
+import (
+ "bytes"
+ "crypto/aes"
+ "encoding/hex"
+ "testing"
+)
+
+func unhex(s string) []byte {
+ b, e := hex.DecodeString(s)
+ if e != nil {
+ panic(e)
+ }
+ return b
+}
+func TestCMACRFC4493(t *testing.T) {
+ key := unhex("2b7e151628aed2a6abf7158809cf4f3c")
+ for _, v := range [][2]string{{"", "bb1d6929e95937287fa37d129b756746"}, {"6bc1bee22e409f96e93d7e117393172a", "070a16b46b4d4144f79bdd9dd04a287c"}, {"6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411", "dfa66747de9ae63030ca32611497c827"}} {
+ got, e := cmac(key, unhex(v[0]))
+ if e != nil || hex.EncodeToString(got) != v[1] {
+ t.Fatalf("CMAC mismatch %x %v", got, e)
+ }
+ }
+}
+func TestCCMIndependentVectors(t *testing.T) {
+ // Generated independently with Python cryptography/OpenSSL AESCCM, 4-byte
+ // tag and AAD 00. Nonzero upper counter bytes catch the SDK's stale comment.
+ key := unhex("000102030405060708090a0b0c0d0e0f")
+ token := unhex("11223344")
+ c, auth, e := newCipher(key, token)
+ if e != nil || hex.EncodeToString(auth) != "1d7c7f1d52493a30cc2ee841f461afaf" {
+ t.Fatal("session CMAC", e)
+ }
+ vectors := map[int]string{0: "e9edea60", 1: "6d19848f18", 16: "6d872d6cad7752dde946535210729276a0c5f8de", 17: "6d872d6cad7752dde946535210729276b241e495c5", 40: "6d872d6cad7752dde946535210729276b20637e7243ebf8f473862b5b847d68743047366a43bfe0b98f3ce1f"}
+ for n, want := range vectors {
+ p := make([]byte, n)
+ for i := range p {
+ p[i] = byte(i)
+ }
+ c.tx = 0x0102030405060708
+ c.rx = c.tx
+ got, e := c.encrypt(p)
+ if e != nil || hex.EncodeToString(got) != want {
+ t.Fatalf("n=%d: %x %v", n, got, e)
+ }
+ plain, e := c.decrypt(got)
+ if e != nil || !bytes.Equal(p, plain) {
+ t.Fatalf("decrypt: %v", e)
+ }
+ c.rx--
+ got[len(got)-1] ^= 1
+ before := c.rx
+ if _, e = c.decrypt(got); e == nil || c.rx != before {
+ t.Fatal("accepted corrupt tag or advanced counter")
+ }
+ }
+ if _, _, e := newCipher(key, token[:3]); e == nil {
+ t.Fatal("accepted bad token")
+ }
+ b, _ := aes.NewCipher(key)
+ if _, e := ccmOpen(b, make([]byte, 13), []byte{1}); e == nil {
+ t.Fatal("accepted short CCM")
+ }
+}
+func TestFragmentBoundaries(t *testing.T) {
+ for _, n := range []int{1, 19, 20, 38, 39, 1024} {
+ p := make([]byte, n)
+ for i := range p {
+ p[i] = byte(i)
+ }
+ for _, kind := range []byte{1, 2} {
+ fs, e := fragments(kind, p)
+ if e != nil {
+ t.Fatal(e)
+ }
+ var a assembler
+ var out []byte
+ for i, f := range fs {
+ k, b, e := a.feed(f)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if i < len(fs)-1 && k != 0 {
+ t.Fatal("early completion")
+ }
+ if i == len(fs)-1 {
+ if k != kind {
+ t.Fatal("wrong kind")
+ }
+ out = b
+ }
+ }
+ if !bytes.Equal(p, out) {
+ t.Fatal("reassembly")
+ }
+ }
+ }
+ for _, p := range [][]byte{nil, {1}, {6, 1}, {0, 1}} {
+ var a assembler
+ if _, _, e := a.feed(p); e == nil {
+ t.Fatalf("accepted malformed %x", p)
+ }
+ }
+ var a assembler
+ a.feed([]byte{1, 0})
+ if _, _, e := a.feed([]byte{3, 0}); e == nil {
+ t.Fatal("accepted interrupted message")
+ }
+ a = assembler{}
+ a.feed(append([]byte{1}, make([]byte, 19)...))
+ for i := 0; i < 52; i++ {
+ a.feed(append([]byte{0}, make([]byte, 19)...))
+ }
+ if _, _, e := a.feed(append([]byte{0}, make([]byte, 19)...)); e == nil {
+ t.Fatal("unbounded message")
+ }
+}
+func TestMechanicalStatusAndBoundary(t *testing.T) {
+ var s State
+ if e := s.publish(81, []byte{0, 0, 0, 128, 255, 255, 0x3a}); e != nil {
+ t.Fatal(e)
+ }
+ if *s.Position != -1 || s.Target != nil || !s.Locked || !s.Stopped || !s.Critical || !s.BatteryCritical {
+ t.Fatalf("bad status %+v", s)
+ }
+ if e := s.publish(20, []byte{255, 255}); e != nil || s.Boundary == nil || *s.Boundary != -1 {
+ t.Fatal("-1 is a valid boundary")
+ }
+ if e := s.publish(80, []byte{90, 0, 0, 0, 0, 0}); e != nil || *s.LockPosition != 90 || *s.UnlockPosition != 0 {
+ t.Fatal("settings")
+ }
+ if e := s.publish(81, []byte{1}); e == nil {
+ t.Fatal("short status")
+ }
+}
+func TestAdvertisement(t *testing.T) {
+ b := append([]byte{21, 0, 1}, unhex("00112233445566778899aabbccddeeff")...)
+ d, ok := Advertisement(b)
+ if !ok || d.UUID != "00112233-4455-6677-8899-aabbccddeeff" || d.Model != "sesame_6_pro" || !d.Registered {
+ t.Fatal(d)
+ }
+ b[0] = 1
+ if _, ok := Advertisement(b); ok {
+ t.Fatal("accepted OS2")
+ }
+ if _, ok := Advertisement([]byte{21}); ok {
+ t.Fatal("short advertisement")
+ }
+}
diff --git a/ha-app/DOCS.md b/ha-app/DOCS.md
index 55979e5..2723942 100644
--- a/ha-app/DOCS.md
+++ b/ha-app/DOCS.md
@@ -37,6 +37,44 @@ serving it after save but leaves its credential state on disk. Changes remain
in the browser until Save configuration. Concurrent edits from another page
are rejected rather than overwriting a newer configuration.
+## Physical locks (Sesame)
+
+1. In **Physical locks**, click **Add physical lock**. Scan results show supported
+ nearby Sesame locks and their local Bluetooth adapters.
+2. For an unregistered lock, choose **Register an unregistered lock**. This
+ provisions BLE access and saves its key immediately. It does not reset an
+ existing lock or enroll the lock in a Sesame cloud account.
+3. For a lock already configured in the Sesame app, choose **Import an existing
+ key** and paste the SDK key JSON containing `deviceUUID`, `deviceModel`, and
+ `secretKey` (the SDK also exports `keyIndex` and `sesame2PublicKey`). The key must
+ match the selected device and pass a local login. Cloud-only guest keys do
+ not work. This is not a parser for arbitrary Sesame sharing QR codes.
+4. Open **Set positions**. With the door open, turn the thumb-turn to the desired
+ locked position, wait for the displayed position to update, and click
+ **Use current as locked**. Repeat for unlocked. Set the boundary the same way;
+ its button is enabled only when firmware reports support. Changes are saved
+ directly to the lock. Use **Test lock** / **Test unlock** to verify movement.
+5. Edit a Home Key lock and select its **Assigned physical locks**, alongside its
+ assigned readers. Save configuration. Several virtual locks can share one
+ physical lock; a tap dispatches at most one unlock per physical device.
+
+Physical-lock enrollment and calibration take effect immediately. Virtual-lock
+assignments remain drafts until **Save configuration**. No physical assignment
+means simulation mode. Home Key success means the lock accepted the unlock
+command; motor completion is reported separately from real state notifications.
+Offline, moving, critical or conflicting multi-lock states appear as Unknown.
+
+The controller uses direct BlueZ on the local adapter. The server must be in BLE
+range; HA Bluetooth proxies and ESP32 lock tunnelling are not included. Connections
+are kept open for prompt commands and manual state updates; battery impact and
+phone-app coexistence need testing on the actual lock.
+
+The physical registry is `/data/state/physical-locks.json`; private keys are under
+`/data/state/physical-lock-keys/` with restrictive permissions. Back up the entire
+state directory. Keys are excluded from public configuration/status responses.
+If enrollment saved a key but failed to save registry metadata, retry adding the
+same device to recover it. No reset or physical-lock key deletion is exposed.
+
## Updating from 0.0.6
The first start imports existing locks, reader IDs, addresses, adapters and
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
index cf28670..de5b566 100644
--- a/internal/app/app_test.go
+++ b/internal/app/app_test.go
@@ -221,6 +221,15 @@ func TestSharedReaderPerLockAuthorization(t *testing.T) {
if b.Device.Lock.LockCurrentState.Value() != 1 {
t.Fatal("revoked lock unlocked")
}
+ a.Config.PhysicalLocks = []string{"sesame-missing"}
+ a.Device.Lock.LockCurrentState.SetValue(1)
+ if n := unlockAuthorized(context.Background(), []*Lock{a}, snapshots, groupKey, result, "porch", 3); n != 0 {
+ t.Fatal("missing actuator controller reported success")
+ }
+ if a.Device.Lock.LockCurrentState.Value() != 1 {
+ t.Fatal("fabricated physical unlock state")
+ }
+ a.Config.PhysicalLocks = nil
ctx, cancel := context.WithCancel(context.Background())
cancel()
if n := unlockAuthorized(ctx, []*Lock{a}, snapshots, groupKey, result, "porch", 3); n != 0 {
diff --git a/internal/app/config.go b/internal/app/config.go
index f64a66a..a36ad4b 100644
--- a/internal/app/config.go
+++ b/internal/app/config.go
@@ -10,11 +10,12 @@ import (
)
type LockConfig struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Port int `json:"port"`
- Finish string `json:"finish"`
- Readers []string `json:"readers"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Port int `json:"port"`
+ Finish string `json:"finish"`
+ Readers []string `json:"readers"`
+ PhysicalLocks []string `json:"physical_locks,omitempty"`
}
type ReaderConfig struct {
ID string `json:"id"`
@@ -80,6 +81,13 @@ func (c Config) Validate() error {
default:
return fmt.Errorf("invalid lock finish")
}
+ physical := map[string]bool{}
+ for _, id := range l.PhysicalLocks {
+ if !identifier.MatchString(id) || physical[id] {
+ return fmt.Errorf("invalid or duplicate physical lock %q", id)
+ }
+ physical[id] = true
+ }
seen := map[string]bool{}
for _, r := range l.Readers {
if !readers[r] || seen[r] {
@@ -116,6 +124,9 @@ func (c *Config) Normalize() {
if l.Readers == nil {
l.Readers = []string{}
}
+ for j := range l.PhysicalLocks {
+ l.PhysicalLocks[j] = strings.TrimSpace(l.PhysicalLocks[j])
+ }
for j := range l.Readers {
l.Readers[j] = strings.TrimSpace(l.Readers[j])
}
diff --git a/internal/app/page.html b/internal/app/page.html
index c98a4a1..f8780cc 100644
--- a/internal/app/page.html
+++ b/internal/app/page.html
@@ -1,38 +1,43 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Home Key locks</title>
<style>
+[hidden]{display:none!important}
:root{font:16px system-ui;color:#172735;background:#f4f6f8}body{max-width:920px;margin:30px auto;padding:0 20px}h1{margin-bottom:8px}h2{margin-top:0}article,section{background:white;border:1px solid #ccd6df;border-radius:12px;padding:20px;margin:18px 0}button,input,select{font:inherit}button{padding:9px 16px;border:1px solid #bcc9d5;border-radius:6px;background:white;cursor:pointer}button.primary{background:#136fb1;color:white;border-color:#136fb1}button:disabled{opacity:.5;cursor:default}.toolbar{display:flex;gap:10px;flex-wrap:wrap;margin:16px 0}.muted,small{color:#536675}.error{color:#a02020}label{display:block;margin:14px 0}input:not([type=checkbox]),select{box-sizing:border-box;display:block;width:100%;margin-top:6px;padding:9px;border:1px solid #bcc9d5;border-radius:5px}input[type=checkbox]{margin-right:10px;accent-color:#136fb1}fieldset{border:1px solid #ccd6df;border-radius:6px}dialog{border:1px solid #bcc9d5;border-radius:12px;padding:24px;max-width:520px;width:calc(100% - 90px)}dialog::backdrop{background:#17273577}code{font-size:26px}.badge{font-size:14px;background:#eaf1f6;border-radius:4px;padding:4px 7px}.reader{display:flex;justify-content:space-between;gap:12px;align-items:center;border-top:1px solid #e0e7ed;padding:14px 0}.reader:first-child{border-top:0}.reader p{margin:4px 0}.empty{color:#536675}.notice{min-height:24px}details{margin:15px 0}
</style></head><body>
-<h1>Home Key locks</h1><p class="muted">Choose your readers, assign them to locks, then pair with Apple Home.</p>
+<h1>Home Key locks</h1><p class="muted">Assign NFC readers and physical locks to a Home Key lock, then pair with Apple Home.</p>
<div class="toolbar"><button id="add-lock" class="primary">Add lock</button><button id="save" disabled>Save configuration</button></div>
<p id="message" class="notice" role="status"></p><main id="locks"></main>
<section><h2>NFC readers</h2><p class="muted">Readers can be assigned to several locks in the same Apple Home.</p><div id="readers"></div><div class="toolbar"><button id="add-reader">Add reader</button><button id="scan">Scan Bluetooth</button></div><p id="discovery-status" class="muted" role="status"></p></section>
+<section><h2>Physical locks</h2><p class="muted">Add a Sesame lock, set its positions, then assign it to a Home Key lock.</p><div id="physical-locks"></div><div class="toolbar"><button id="add-physical">Add physical lock</button><button id="scan-physical">Scan for locks</button></div><p id="physical-discovery-status" class="muted" role="status"></p></section>
<details><summary>Network settings</summary><label>LAN interface <input id="interface" placeholder="Automatic"></label><small>Leave empty for automatic HomeKit discovery interface selection.</small></details>
<p class="muted">Saving briefly restarts HomeKit and reader connections. Pairings and credentials are preserved.</p>
-<dialog id="lock-dialog"><form id="lock-form"><h2 id="lock-title">Add lock</h2><small id="lock-id"></small><label>Name <input id="lock-name" required maxlength="128"></label><fieldset><legend>Assigned readers</legend><div id="assignments"></div></fieldset><details><summary>HomeKit settings</summary><label>HomeKit port <input id="lock-port" type="number" min="1" max="65535" required></label><label>Wallet artwork <select id="lock-finish"><option>silver</option><option>black</option><option>gold</option><option>tan</option></select></label></details><p id="lock-error" class="error" role="alert"></p><div class="toolbar"><button class="primary" type="submit">Apply to configuration</button><button type="button" id="cancel-lock">Cancel</button></div></form></dialog>
+<dialog id="lock-dialog"><form id="lock-form"><h2 id="lock-title">Add lock</h2><small id="lock-id"></small><label>Name <input id="lock-name" required maxlength="128"></label><fieldset><legend>Assigned readers</legend><div id="assignments"></div></fieldset><fieldset><legend>Assigned physical locks</legend><div id="physical-assignments"></div><small>With none assigned, this remains a simulated lock.</small></fieldset><details><summary>HomeKit settings</summary><label>HomeKit port <input id="lock-port" type="number" min="1" max="65535" required></label><label>Wallet artwork <select id="lock-finish"><option>silver</option><option>black</option><option>gold</option><option>tan</option></select></label></details><p id="lock-error" class="error" role="alert"></p><div class="toolbar"><button class="primary" type="submit">Apply to configuration</button><button type="button" id="cancel-lock">Cancel</button></div></form></dialog>
<dialog id="reader-dialog"><form id="reader-form"><h2 id="reader-title">Add reader</h2><label>Available reader <select id="reader-device"></select></label><label id="manual-address-label" hidden>Bluetooth address <input id="reader-address" placeholder="70:AF:09:16:42:5A"></label><label>Bluetooth adapter <select id="reader-adapter"></select></label><label>Reader name <input id="reader-name" required maxlength="128"></label><small id="reader-id"></small><p id="reader-error" class="error" role="alert"></p><div class="toolbar"><button class="primary" type="submit">Apply to configuration</button><button type="button" id="cancel-reader">Cancel</button></div></form></dialog>
+<dialog id="physical-dialog"><form id="physical-form"><h2>Add physical lock</h2><p>Select a nearby lock. For a lock already set up in Sesame, import its local key JSON.</p><label>Available lock <select id="physical-device" required></select></label><label>Name <input id="physical-name" required maxlength="128"></label><label>Setup method <select id="physical-mode"><option value="import">Import an existing key</option><option value="register">Register an unregistered lock</option></select></label><label id="physical-key-label">Sesame key JSON <textarea id="physical-key" rows="6" spellcheck="false" autocomplete="off" style="box-sizing:border-box;width:100%;display:block"></textarea></label><p id="physical-register-note" hidden>Registration claims this unregistered lock and saves its key here. This does not reset an existing lock or add it to the Sesame cloud account.</p><p id="physical-error" class="error" role="alert"></p><div class="toolbar"><button id="physical-submit" class="primary" type="submit">Add physical lock</button><button type="button" id="cancel-physical">Cancel</button></div></form></dialog>
+<dialog id="calibration-dialog"><h2 id="calibration-name">Lock positions</h2><p>Turn the thumb-turn manually to each desired position, wait for the displayed position to update, then save it. Test movement with the door open.</p><p id="calibration-status" role="status"></p><dl><dt>Current position</dt><dd id="calibration-position">Waiting…</dd><dt>Locked position</dt><dd id="calibration-locked">Waiting…</dd><dt>Unlocked position</dt><dd id="calibration-unlocked">Waiting…</dd><dt>Lock/unlock boundary</dt><dd id="calibration-boundary">Waiting…</dd></dl><div class="toolbar"><button id="capture-lock">Use current as locked</button><button id="capture-unlock">Use current as unlocked</button><button id="capture-boundary">Use current as boundary</button></div><small id="boundary-help"></small><div class="toolbar"><button id="test-lock">Test lock</button><button id="test-unlock">Test unlock</button></div><p id="calibration-error" class="error" role="alert"></p><button id="close-calibration">Close</button></dialog>
<script>
const $=id=>document.getElementById(id);
const apiBase=location.pathname+(location.pathname.endsWith('/')?'':'/');
const el=(tag,text,cls)=>{const n=document.createElement(tag);n.textContent=text;if(cls)n.className=cls;return n};
const button=(text,fn)=>{const b=el('button',text);b.type='button';b.onclick=fn;return b};
let config={locks:[],readers:[],interface:''},revision='',status=[],discovery={adapters:[],readers:[]},dirty=false,restarting=false,scanning=false,editingLock=null,editingReader=null;
+let physicalLocks=[],physicalDiscovery={locks:[],adapters:[],warnings:[]},physicalScanning=false,physicalAdding=false,calibrating=null,calibrationBusy=false;
const label=r=>r.name||r.id;
function message(text,error=false){$('message').textContent=text;$('message').className='notice'+(error?' error':'')}
async function request(path,options={}){const r=await fetch(apiBase+path,options);if(!r.ok)throw Error(await r.text());return r.status===204||r.status===202?null:r.json()}
function markDirty(){dirty=true;$('save').disabled=false;message('Unsaved changes. Save configuration to apply them.');render()}
function render(){
const main=$('locks');main.replaceChildren();if(!config.locks.length)main.append(el('p','No locks yet. Add a lock to get started.','empty'));
- for(const l of config.locks){const a=el('article',''),s=status.find(x=>x.id===l.id);a.append(el('h2',l.name),el('small','ID: '+l.id+' · HomeKit port: '+l.port));const names=l.readers.map(id=>{const r=config.readers.find(r=>r.id===id);return r?label(r):id});a.append(el('p','Readers: '+(names.join(', ')||'None assigned')));const paired=!!s?.summary.paired_controllers;a.append(el('span',s?(paired?'Paired':'Unpaired'):'Pending save','badge'));const actions=el('div','','toolbar');actions.append(button('Edit',()=>openLock(l.id)),button('Remove',()=>{config.locks=config.locks.filter(x=>x.id!==l.id);markDirty()}));
+ for(const l of config.locks){const a=el('article',''),s=status.find(x=>x.id===l.id);a.append(el('h2',l.name),el('small','ID: '+l.id+' · HomeKit port: '+l.port));const names=l.readers.map(id=>{const r=config.readers.find(r=>r.id===id);return r?label(r):id});a.append(el('p','Readers: '+(names.join(', ')||'None assigned')));a.append(el('p','Physical locks: '+((l.physical_locks||[]).map(id=>physicalLocks.find(p=>p.id===id)?.name||id).join(', ')||'None (simulation)')));const paired=!!s?.summary.paired_controllers;a.append(el('span',s?(paired?'Paired':'Unpaired'):'Pending save','badge'));const actions=el('div','','toolbar');actions.append(button('Edit',()=>openLock(l.id)),button('Remove',()=>{config.locks=config.locks.filter(x=>x.id!==l.id);markDirty()}));
if(s?.pin){a.append(el('p','Pairing code'),el('code',s.pin),el('p','Window ends '+new Date(s.until).toLocaleTimeString()));actions.append(button('Cancel pairing',()=>pair(l.id,'cancel')))}else if(s&&!paired){const b=button('Pair',()=>pair(l.id,'pair'));b.disabled=dirty||restarting;actions.append(b)}a.append(actions);main.append(a)}
const readers=$('readers');readers.replaceChildren();if(!config.readers.length)readers.append(el('p','No readers configured. Click Add reader to choose a Bluetooth reader.','empty'));
for(const r of config.readers){const row=el('div','','reader'),info=el('div','');info.append(el('strong',label(r)),el('p',r.address+' · '+r.adapter,'muted'));const actions=el('div','','toolbar');actions.append(button('Edit',()=>openReader(r.id)),button('Remove',()=>{config.readers=config.readers.filter(x=>x.id!==r.id);for(const l of config.locks)l.readers=l.readers.filter(id=>id!==r.id);markDirty()}));row.append(info,actions);readers.append(row)}
}
async function load(){const data=await request('api/config');config=data.config;revision=data.revision;dirty=false;$('save').disabled=true;$('interface').value=config.interface||'';await refresh()}
-async function refresh(){try{const data=await request('api/locks');status=data.locks;render();if(!dirty&&!restarting)message('');for(const r of data.readers){if(r.warning)message(label(r)+': '+r.warning,true)}}catch(e){if(!restarting)message(e.message,true)}}
+async function refresh(){try{const [data,physical]=await Promise.all([request('api/locks'),request('api/physical-locks')]);status=data.locks;physicalLocks=physical;render();renderPhysical();updateCalibration();if(!dirty&&!restarting)message('');for(const r of data.readers){if(r.warning)message(label(r)+': '+r.warning,true)}}catch(e){if(!restarting)message(e.message,true)}}
async function pair(id,action){try{await request('api/locks/'+encodeURIComponent(id)+'/'+action,{method:'POST',headers:{'X-Homekey-Action':'pairing'}});await refresh()}catch(e){message(e.message,true)}}
-function openLock(id=null){editingLock=id;const l=config.locks.find(x=>x.id===id);$('lock-title').textContent=l?'Edit lock':'Add lock';$('lock-id').textContent=l?'Stable ID: '+l.id:'A stable ID will be generated automatically.';$('lock-name').value=l?.name||'';let port=51826;while(config.locks.some(x=>x.port===port))port++;$('lock-port').value=l?.port||port;$('lock-finish').value=l?.finish||'silver';$('lock-error').textContent='';const list=$('assignments');list.replaceChildren();if(!config.readers.length)list.append(el('p','No readers configured. Add a reader after creating this lock.'));for(const r of config.readers){const labelNode=el('label',''),input=document.createElement('input');input.type='checkbox';input.value=r.id;input.checked=!!l?.readers.includes(r.id);labelNode.append(input,document.createTextNode(label(r)+' · '+r.address));list.append(labelNode)}$('lock-dialog').showModal()}
-$('lock-form').onsubmit=e=>{e.preventDefault();const name=$('lock-name').value.trim(),port=Number($('lock-port').value);if(!name){$('lock-error').textContent='Enter a lock name.';return}if(config.locks.some(x=>x.id!==editingLock&&x.port===port)||port===8099){$('lock-error').textContent='Choose an unused HomeKit port.';return}const id=editingLock||'lock-'+Array.from(crypto.getRandomValues(new Uint8Array(8)),x=>x.toString(16).padStart(2,'0')).join('');const l={id,name,port,finish:$('lock-finish').value,readers:[...$('assignments').querySelectorAll('input:checked')].map(x=>x.value)};if(editingLock)config.locks=config.locks.map(x=>x.id===editingLock?l:x);else config.locks.push(l);$('lock-dialog').close();markDirty()};
+function openLock(id=null){editingLock=id;const l=config.locks.find(x=>x.id===id);$('lock-title').textContent=l?'Edit lock':'Add lock';$('lock-id').textContent=l?'Stable ID: '+l.id:'A stable ID will be generated automatically.';$('lock-name').value=l?.name||'';let port=51826;while(config.locks.some(x=>x.port===port))port++;$('lock-port').value=l?.port||port;$('lock-finish').value=l?.finish||'silver';$('lock-error').textContent='';const list=$('assignments');list.replaceChildren();if(!config.readers.length)list.append(el('p','No readers configured. Add a reader after creating this lock.'));for(const r of config.readers){const labelNode=el('label',''),input=document.createElement('input');input.type='checkbox';input.value=r.id;input.checked=!!l?.readers.includes(r.id);labelNode.append(input,document.createTextNode(label(r)+' · '+r.address));list.append(labelNode)}const physicalList=$('physical-assignments');physicalList.replaceChildren();if(!physicalLocks.length)physicalList.append(el('p','No physical locks added yet.'));for(const p of physicalLocks){const row=el('label',''),input=document.createElement('input');input.type='checkbox';input.value=p.id;input.checked=!!l?.physical_locks?.includes(p.id);row.append(input,document.createTextNode(p.name+' · '+p.target.model));physicalList.append(row)}$('lock-dialog').showModal()}
+$('lock-form').onsubmit=e=>{e.preventDefault();const name=$('lock-name').value.trim(),port=Number($('lock-port').value);if(!name){$('lock-error').textContent='Enter a lock name.';return}if(config.locks.some(x=>x.id!==editingLock&&x.port===port)||port===8099){$('lock-error').textContent='Choose an unused HomeKit port.';return}const id=editingLock||'lock-'+Array.from(crypto.getRandomValues(new Uint8Array(8)),x=>x.toString(16).padStart(2,'0')).join('');const l={id,name,port,finish:$('lock-finish').value,readers:[...$('assignments').querySelectorAll('input:checked')].map(x=>x.value),physical_locks:[...$('physical-assignments').querySelectorAll('input:checked')].map(x=>x.value)};if(editingLock)config.locks=config.locks.map(x=>x.id===editingLock?l:x);else config.locks.push(l);$('lock-dialog').close();markDirty()};
$('cancel-lock').onclick=()=>$('lock-dialog').close();$('add-lock').onclick=()=>openLock();
function options(select,items,value){select.replaceChildren();for(const item of items){const o=el('option',item.label);o.value=item.value;select.append(o)}if(items.some(x=>x.value===value))select.value=value}
function readerChoices(){const current=config.readers.find(r=>r.id===editingReader),devices=[...discovery.readers];if(current&&!devices.some(d=>d.address===current.address&&d.adapter===current.adapter))devices.push({...current,name:label(current)});const available=devices.filter(d=>!config.readers.some(r=>r.id!==editingReader&&r.address===d.address));const selected=$('reader-device').value;options($('reader-device'),[...available.map(d=>({value:d.adapter+'|'+d.address,label:d.name+' · '+d.address+' · '+d.adapter})),{value:'manual',label:'Enter address manually'}],selected|| (current?current.adapter+'|'+current.address:''));const adapters=discovery.adapters.map(a=>({value:a.id,label:(a.name||'Bluetooth adapter')+' · '+a.id+(a.powered?'':' (off)')}));for(const r of config.readers){if(!adapters.some(a=>a.value===r.adapter))adapters.push({value:r.adapter,label:r.adapter+' (configured)'})}if(!adapters.length)adapters.push({value:'hci0',label:'Default adapter (hci0) — scan to verify'});options($('reader-adapter'),adapters,$('reader-adapter').value||current?.adapter);selectDevice()}
@@ -44,5 +49,31 @@ $('add-reader').onclick=()=>openReader();$('cancel-reader').onclick=()=>$('reade
async function scan(){if(scanning)return;scanning=true;$('scan').disabled=true;$('discovery-status').textContent='Scanning Bluetooth for Home Key readers…';try{discovery=await request('api/discovery');$('discovery-status').textContent=(discovery.readers.length?discovery.readers.length+' reader(s) found.':'No Home Key readers found. Power on your reader and scan again, or enter its address manually.')+' '+(discovery.warnings||[]).join(' · ');if($('reader-dialog').open)readerChoices()}catch(e){$('discovery-status').textContent=e.message+' You can still enter a reader address manually.'}finally{scanning=false;$('scan').disabled=false}}
$('scan').onclick=scan;$('interface').oninput=()=>{config.interface=$('interface').value.trim();markDirty()};
$('save').onclick=async()=>{if(restarting)return;restarting=true;$('save').disabled=true;message('Saving configuration and restarting connections…');try{await request('api/config',{method:'POST',headers:{'Content-Type':'application/json','X-Homekey-Action':'configuration'},body:JSON.stringify({config,revision})});for(let i=0;i<40;i++){await new Promise(resolve=>setTimeout(resolve,500));try{await load();restarting=false;message('Configuration saved.');return}catch{}}throw Error('Configuration saved; service is still restarting. Reload this page.')}catch(e){restarting=false;$('save').disabled=!dirty;message(e.message,true)}};
+
+function renderPhysical(){
+ const list=$('physical-locks');list.replaceChildren();if(!physicalLocks.length)list.append(el('p','No physical locks added.','empty'));
+ for(const p of physicalLocks){const row=el('div','','reader'),info=el('div',''),state=p.state;info.append(el('strong',p.name),el('p',p.target.model+' · '+p.target.adapter,'muted'));info.append(el('p',physicalState(state),state.online?'muted':'error'));const actions=el('div','','toolbar');actions.append(button('Set positions',()=>openCalibration(p.id)));row.append(info,actions);list.append(row)}
+}
+function physicalState(s){if(!s.online)return s.error||'Connecting…';if(s.position===undefined)return 'Connected; waiting for position';if(s.critical)return 'Lock reports a critical condition';if(!s.stopped)return 'Moving · '+s.position+'°';return (s.locked?'Locked':'Unlocked')+' · '+s.position+'°'+(s.battery_critical?' · Low battery':'')}
+function physicalChoices(){const old=$('physical-device').value,available=physicalDiscovery.locks.filter(t=>!physicalLocks.some(p=>p.target.uuid===t.uuid));options($('physical-device'),available.map(t=>({value:t.adapter+'|'+t.uuid,label:t.model+' · '+t.address+' · '+t.adapter+(t.registered?' · Registered':' · Unregistered')})),old);selectPhysical()}
+function selectedPhysical(){return physicalDiscovery.locks.find(t=>t.adapter+'|'+t.uuid===$('physical-device').value)}
+function selectPhysical(){const t=selectedPhysical();if(t){$('physical-mode').value=t.registered?'import':'register';if(!$('physical-name').value)$('physical-name').value=t.model.replaceAll('_',' ')}physicalMode();$('physical-submit').disabled=!t||physicalAdding}
+function physicalMode(){const registering=$('physical-mode').value==='register';$('physical-key-label').hidden=registering;$('physical-key').required=!registering;$('physical-register-note').hidden=!registering}
+async function scanPhysical(){if(physicalScanning)return;physicalScanning=true;$('scan-physical').disabled=true;$('physical-discovery-status').textContent='Scanning for supported Sesame locks…';try{physicalDiscovery=await request('api/physical-locks/discovery');$('physical-discovery-status').textContent=physicalDiscovery.locks.length+' supported lock(s) found. '+(physicalDiscovery.warnings||[]).join(' · ');if($('physical-dialog').open)physicalChoices()}catch(e){$('physical-discovery-status').textContent=e.message;if($('physical-dialog').open)$('physical-error').textContent=e.message}finally{physicalScanning=false;$('scan-physical').disabled=false}}
+$('scan-physical').onclick=scanPhysical;
+$('add-physical').onclick=()=>{$('physical-name').value='';$('physical-key').value='';$('physical-error').textContent='';physicalChoices();$('physical-dialog').showModal();scanPhysical()};
+$('physical-device').onchange=selectPhysical;$('physical-mode').onchange=physicalMode;
+$('cancel-physical').onclick=()=>{if(!physicalAdding){$('physical-key').value='';$('physical-dialog').close()}};
+$('physical-dialog').addEventListener('cancel',e=>{if(physicalAdding)e.preventDefault();else $('physical-key').value=''});
+$('physical-form').onsubmit=async e=>{e.preventDefault();if(physicalAdding)return;const target=selectedPhysical();if(!target){$('physical-error').textContent='Scan and select a supported lock first.';return}const mode=$('physical-mode').value,body={name:$('physical-name').value.trim(),target,mode};try{if(mode==='import'){const k=JSON.parse($('physical-key').value);body.key={deviceUUID:k.deviceUUID,deviceModel:k.deviceModel,secretKey:k.secretKey,keyIndex:k.keyIndex||'',sesame2PublicKey:k.sesame2PublicKey||''}}}catch{$('physical-error').textContent='Enter valid Sesame key JSON.';return}
+ physicalAdding=true;for(const n of $('physical-form').elements)n.disabled=true;$('physical-error').textContent=mode==='register'?'Registering and saving the key…':'Verifying the key…';try{const added=await request('api/physical-locks',{method:'POST',headers:{'Content-Type':'application/json','X-Homekey-Action':'physical-lock'},body:JSON.stringify(body)});$('physical-key').value='';$('physical-dialog').close();await refresh();openCalibration(added.id)}catch(err){$('physical-error').textContent=err.message}finally{physicalAdding=false;for(const n of $('physical-form').elements)n.disabled=false}}
+function openCalibration(id){calibrating=id;$('calibration-error').textContent='';updateCalibration();$('calibration-dialog').showModal()}
+function updateCalibration(){if(!calibrating)return;const p=physicalLocks.find(p=>p.id===calibrating);if(!p)return;const s=p.state,angle=v=>v===undefined?'Not reported':v+'°';$('calibration-name').textContent=p.name+' — positions';$('calibration-status').textContent=physicalState(s);$('calibration-position').textContent=angle(s.position);$('calibration-locked').textContent=angle(s.lock_position);$('calibration-unlocked').textContent=angle(s.unlock_position);$('calibration-boundary').textContent=angle(s.boundary);
+ const ready=s.online&&s.position!==undefined&&!calibrationBusy;
+ $('capture-lock').disabled=$('capture-unlock').disabled=!ready||s.lock_position===undefined||s.unlock_position===undefined;$('capture-boundary').disabled=!ready||s.boundary===undefined;$('test-lock').disabled=$('test-unlock').disabled=!s.online||calibrationBusy;
+ $('boundary-help').textContent=s.boundary===undefined?'Boundary adjustment becomes available when the lock firmware reports support.':'The boundary determines where the lock changes between locked and unlocked status. Settings are saved directly to the lock.';
+}
+async function physicalAction(action){if(calibrationBusy||!calibrating)return;calibrationBusy=true;$('calibration-error').textContent='';updateCalibration();try{await request('api/physical-locks/'+encodeURIComponent(calibrating)+'/'+action,{method:'POST',headers:{'X-Homekey-Action':'physical-lock'}});await refresh()}catch(e){$('calibration-error').textContent=e.message}finally{calibrationBusy=false;updateCalibration()}}
+$('capture-lock').onclick=()=>physicalAction('set-lock');$('capture-unlock').onclick=()=>physicalAction('set-unlock');$('capture-boundary').onclick=()=>physicalAction('set-boundary');$('test-lock').onclick=()=>physicalAction('lock');$('test-unlock').onclick=()=>physicalAction('unlock');$('close-calibration').onclick=()=>{$('calibration-dialog').close();calibrating=null};
load().then(()=>scan()).catch(e=>message(e.message,true));setInterval(()=>{if(!restarting)refresh()},2000);
</script></body></html>
diff --git a/internal/app/pairing.go b/internal/app/pairing.go
index 55c4b86..7eceb52 100644
--- a/internal/app/pairing.go
+++ b/internal/app/pairing.go
@@ -6,6 +6,7 @@ import (
"github.com/brutella/hap"
"github.com/go-chi/chi"
"homekey.local/provisioner/internal/hkserver"
+ "homekey.local/provisioner/internal/physical"
"homekey.local/provisioner/internal/provision"
"math/big"
"net/http"
@@ -14,12 +15,13 @@ import (
)
type Lock struct {
- Config LockConfig
- Store *provision.Store
- Device *hkserver.Device
- pairing sync.Mutex
- until time.Time
- pin string
+ Physical *physical.Manager
+ Config LockConfig
+ Store *provision.Store
+ Device *hkserver.Device
+ pairing sync.Mutex
+ until time.Time
+ pin string
}
func NewLock(c LockConfig, store *provision.Store, ifaces []string) (*Lock, error) {
diff --git a/internal/app/physical.go b/internal/app/physical.go
new file mode 100644
index 0000000..59e7a68
--- /dev/null
+++ b/internal/app/physical.go
@@ -0,0 +1,149 @@
+package app
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "time"
+
+ "homekey.local/provisioner/drivers/sesame"
+ "homekey.local/provisioner/internal/physical"
+)
+
+// BindPhysical installs command handlers before HAP starts. The returned worker
+// projects confirmed actuator state, including manual movement, into HAP.
+func BindPhysical(locks []*Lock, m *physical.Manager) func(context.Context) {
+ for _, l := range locks {
+ l.Physical = m
+ if len(l.Config.PhysicalLocks) == 0 {
+ continue
+ }
+ l.Device.Lock.LockCurrentState.SetValue(3) // Unknown until live status arrives.
+ l.Device.Lock.LockTargetState.OnSetRemoteValue(func(v int) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+ defer cancel()
+ return <-m.Dispatch(ctx, l.Config.PhysicalLocks, v == 1)
+ })
+ }
+ return func(ctx context.Context) {
+ timer := time.NewTicker(500 * time.Millisecond)
+ defer timer.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-timer.C:
+ states := map[string]sesame.State{}
+ for _, v := range m.Views() {
+ states[v.ID] = v.State
+ }
+ for _, l := range locks {
+ if len(l.Config.PhysicalLocks) == 0 {
+ continue
+ }
+ state := aggregatePhysical(l.Config.PhysicalLocks, states)
+ previous := l.Device.Lock.LockCurrentState.Value()
+ l.Device.Lock.LockCurrentState.SetValue(state)
+ // Manual turns must also settle HomeKit's target. Do not
+ // overwrite an outstanding target on unchanged old status.
+ if state < 2 && state != previous {
+ l.Device.Lock.LockTargetState.SetValue(state)
+ }
+ }
+ }
+ }
+ }
+}
+func aggregatePhysical(ids []string, states map[string]sesame.State) int {
+ current := -1
+ for _, id := range ids {
+ s, ok := states[id]
+ if !ok || !s.Online || s.Position == nil || !s.Stopped || s.Critical {
+ return 3
+ }
+ v := 0
+ if s.Locked {
+ v = 1
+ }
+ if current != -1 && current != v {
+ return 3
+ }
+ current = v
+ }
+ if current < 0 {
+ return 3
+ }
+ return current
+}
+func physicalRoutes(mux *http.ServeMux, cfg *Settings) {
+ if cfg.Physical == nil {
+ return
+ }
+ mux.HandleFunc("GET /api/physical-locks", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(cfg.Physical.Views())
+ })
+ mux.HandleFunc("GET /api/physical-locks/discovery", func(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
+ defer cancel()
+ d, e := sesame.Discover(ctx)
+ if e != nil {
+ http.Error(w, e.Error(), 503)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(d)
+ })
+ mux.HandleFunc("POST /api/physical-locks", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-Homekey-Action") != "physical-lock" {
+ http.Error(w, "missing action header", 403)
+ return
+ }
+ var payload physical.Enrollment
+ r.Body = http.MaxBytesReader(w, r.Body, 8192)
+ d := json.NewDecoder(r.Body)
+ d.DisallowUnknownFields()
+ if e := d.Decode(&payload); e != nil {
+ http.Error(w, "invalid physical lock JSON", 400)
+ return
+ }
+ var extra any
+ if e := d.Decode(&extra); e != io.EOF {
+ http.Error(w, "trailing data", 400)
+ return
+ }
+ record, e := cfg.Physical.Add(r.Context(), payload)
+ if e != nil {
+ http.Error(w, e.Error(), 400)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(record)
+ })
+ mux.HandleFunc("POST /api/physical-locks/{id}/{action}", func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("X-Homekey-Action") != "physical-lock" {
+ http.Error(w, "missing action header", 403)
+ return
+ }
+ action := r.PathValue("action")
+ switch action {
+ case "lock", "unlock", "set-lock", "set-unlock", "set-boundary":
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ if !cfg.Physical.Has(r.PathValue("id")) {
+ http.NotFound(w, r)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
+ defer cancel()
+ if e := cfg.Physical.Action(ctx, r.PathValue("id"), action); e != nil {
+ http.Error(w, e.Error(), 409)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
diff --git a/internal/app/physical_test.go b/internal/app/physical_test.go
new file mode 100644
index 0000000..37d0744
--- /dev/null
+++ b/internal/app/physical_test.go
@@ -0,0 +1,75 @@
+package app
+
+import (
+ "context"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "homekey.local/provisioner/drivers/sesame"
+ "homekey.local/provisioner/internal/physical"
+)
+
+func TestPhysicalHTTPGuardsAndEmptyRegistry(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ m, e := physical.Open(ctx, t.TempDir(), func(context.Context, sesame.Target) (sesame.Link, sesame.Target, error) {
+ t.Fatal("unexpected Bluetooth action")
+ return nil, sesame.Target{}, nil
+ })
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer m.Close()
+ s, e := OpenSettings(filepath.Join(t.TempDir(), "config.json"))
+ if e != nil {
+ t.Fatal(e)
+ }
+ s.Physical = m
+ h := Handler(nil, nil, false, s)
+ for _, tc := range []struct {
+ method, path, body, header string
+ want int
+ }{{"GET", "/api/physical-locks", "", "", 200}, {"POST", "/api/physical-locks", "{}", "", 403}, {"POST", "/api/physical-locks", "{} {}", "physical-lock", 400}, {"POST", "/api/physical-locks", "{\"secret\":\"bad\"}", "physical-lock", 400}, {"POST", "/api/physical-locks/unknown/unlock", "", "", 403}, {"POST", "/api/physical-locks/unknown/unlock", "", "physical-lock", 404}, {"POST", "/api/physical-locks/unknown/reset", "", "physical-lock", 404}} {
+ r := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
+ r.Header.Set("X-Homekey-Action", tc.header)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != tc.want {
+ t.Fatalf("%s: %d %s", tc.path, w.Code, w.Body.String())
+ }
+ }
+ c, rev := s.Snapshot()
+ c.Locks = []LockConfig{{ID: "front", Name: "Front", Port: 51826, Finish: "silver", PhysicalLocks: []string{"sesame-missing"}}}
+ if e = s.Save(c, rev); e == nil {
+ t.Fatal("accepted unregistered assignment")
+ }
+}
+func TestAggregatePhysicalState(t *testing.T) {
+ p := int16(90)
+ good := sesame.State{Online: true, Position: &p, Stopped: true, Locked: true}
+ states := map[string]sesame.State{"a": good, "b": good}
+ if aggregatePhysical([]string{"a", "b"}, states) != 1 {
+ t.Fatal("locked")
+ }
+ good.Locked = false
+ states["b"] = good
+ if aggregatePhysical([]string{"a", "b"}, states) != 3 {
+ t.Fatal("mixed state must be unknown")
+ }
+ if aggregatePhysical([]string{"b"}, states) != 0 {
+ t.Fatal("unlocked")
+ }
+ good.Online = false
+ states["b"] = good
+ if aggregatePhysical([]string{"b"}, states) != 3 {
+ t.Fatal("offline state must be unknown")
+ }
+ good.Online = true
+ good.Stopped = false
+ states["b"] = good
+ if aggregatePhysical([]string{"b"}, states) != 3 {
+ t.Fatal("moving state must be unknown")
+ }
+}
diff --git a/internal/app/reader.go b/internal/app/reader.go
index 1f14123..617a70e 100644
--- a/internal/app/reader.go
+++ b/internal/app/reader.go
@@ -112,26 +112,63 @@ func Authenticate(ctx, serviceCtx context.Context, locks []*Lock, reader string,
}
func unlockAuthorized(ctx context.Context, locks []*Lock, snapshots map[*Lock]provision.Credentials, groupKey string, result *nfcauth.Result, reader string, session uint32) int {
+ // Share a result between virtual locks targeting the same actuator in this tap.
+ results := map[string]<-chan error{}
+ completed := map[string]error{}
unlocked := 0
for _, l := range locks {
expected := snapshots[l]
if expected.ReaderPrivateKey != groupKey {
continue
}
+ var physicalIDs []string
e := l.Store.WithAuthorization(expected, result.IssuerID, result.EndpointID, result.PublicKey, func() error {
if err := ctx.Err(); err != nil {
return err
}
- if err := l.Device.Lock.LockTargetState.SetValue(0); err != nil {
- return err
+ if len(l.Config.PhysicalLocks) == 0 {
+ if err := l.Device.Lock.LockTargetState.SetValue(0); err != nil {
+ return err
+ }
+ return l.Device.Lock.LockCurrentState.SetValue(0)
}
- return l.Device.Lock.LockCurrentState.SetValue(0)
+ if l.Physical == nil {
+ return fmt.Errorf("physical lock controller unavailable")
+ }
+ for _, id := range l.Config.PhysicalLocks {
+ physicalIDs = append(physicalIDs, id)
+ if _, ok := results[id]; !ok {
+ results[id] = l.Physical.Dispatch(ctx, []string{id}, false)
+ }
+ }
+ return nil
})
if e != nil {
continue
}
+ for _, id := range physicalIDs {
+ err, ok := completed[id]
+ if !ok {
+ select {
+ case err = <-results[id]:
+ case <-ctx.Done():
+ err = ctx.Err()
+ }
+ completed[id] = err
+ }
+ if err != nil {
+ e = err
+ }
+ }
+ if e != nil {
+ log.Printf("Home Key physical unlock failed lock=%s: %v", l.Config.ID, e)
+ continue
+ }
+ if len(physicalIDs) > 0 {
+ _ = l.Device.Lock.LockTargetState.SetValue(0)
+ }
unlocked++
- log.Printf("Home Key authenticated reader=%s session=%d endpoint=%s lock=%s; virtual lock unlocked", reader, session, result.EndpointID, l.Config.ID)
+ log.Printf("Home Key authenticated reader=%s session=%d endpoint=%s lock=%s; unlock accepted", reader, session, result.EndpointID, l.Config.ID)
}
return unlocked
}
diff --git a/internal/app/settings.go b/internal/app/settings.go
index b0d22a3..a055799 100644
--- a/internal/app/settings.go
+++ b/internal/app/settings.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "homekey.local/provisioner/internal/physical"
"os"
"path/filepath"
"sync"
@@ -15,6 +16,7 @@ var ErrConflict = errors.New("configuration changed; reload the page before savi
var ErrReloading = errors.New("configuration is restarting; retry after the page reconnects")
type Settings struct {
+ Physical *physical.Manager
mu sync.Mutex
path string
config Config
@@ -66,6 +68,13 @@ func (s *Settings) Save(c Config, expected string) error {
if err := c.Validate(); err != nil {
return err
}
+ for _, l := range c.Locks {
+ for _, id := range l.PhysicalLocks {
+ if s.Physical == nil || !s.Physical.Has(id) {
+ return fmt.Errorf("unknown physical lock %q", id)
+ }
+ }
+ }
if s.BeforeSave != nil {
if err := s.BeforeSave(c); err != nil {
return err
diff --git a/internal/app/settings_test.go b/internal/app/settings_test.go
index 1270a11..2f61bda 100644
--- a/internal/app/settings_test.go
+++ b/internal/app/settings_test.go
@@ -10,19 +10,26 @@ import (
"testing"
)
-func TestSettingsImportNormalizeAndConflict(t *testing.T) {
+func TestSettingsNormalizeAndConflict(t *testing.T) {
dir := t.TempDir()
seed := filepath.Join(dir, "options.json")
path := filepath.Join(dir, "config.json")
os.WriteFile(seed, []byte(`{"interface":" ","locks":[{"id":"front ","name":"Front ","port":51826,"finish":"silver","readers":["TestReader01 "]}],"readers":[{"id":" TestReader01 ","address":"70:af:09:16:42:5a ","adapter":" hci0 "}]}`), 0600)
- s, err := OpenSettings(path, seed, true)
+ s, err := OpenSettings(path, true)
if err != nil {
t.Fatal(err)
}
if _, err = os.Stat(path); !os.IsNotExist(err) {
t.Fatal("status imported settings to disk")
}
- s, err = OpenSettings(path, seed)
+ raw, err := os.ReadFile(seed)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = os.WriteFile(path, raw, 0600); err != nil {
+ t.Fatal(err)
+ }
+ s, err = OpenSettings(path)
if err != nil {
t.Fatal(err)
}
@@ -41,7 +48,7 @@ func TestSettingsImportNormalizeAndConflict(t *testing.T) {
t.Fatal("second save accepted during reload")
}
os.WriteFile(seed, []byte(`{"locks":[],"readers":[]}`), 0600)
- reopened, err := OpenSettings(path, seed)
+ reopened, err := OpenSettings(path)
if err != nil {
t.Fatal(err)
}
@@ -57,7 +64,7 @@ func TestSettingsImportNormalizeAndConflict(t *testing.T) {
func TestConfigurationHTTPValidation(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
writeConfig(path, Config{Locks: []LockConfig{}, Readers: []ReaderConfig{}})
- s, _ := OpenSettings(path, "")
+ s, _ := OpenSettings(path)
calls := 0
s.Reload = func() { calls++ }
handler := Handler(nil, nil, false, s)
diff --git a/internal/app/web.go b/internal/app/web.go
index 5d0a6a7..d6c8b96 100644
--- a/internal/app/web.go
+++ b/internal/app/web.go
@@ -19,6 +19,7 @@ func Handler(locks []*Lock, readers []ReaderConfig, ingress bool, settings ...*S
mux := http.NewServeMux()
if len(settings) > 0 && settings[0] != nil {
cfg := settings[0]
+ physicalRoutes(mux, cfg)
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
c, rev := cfg.Snapshot()
w.Header().Set("Content-Type", "application/json")
diff --git a/internal/physical/manager.go b/internal/physical/manager.go
new file mode 100644
index 0000000..d4f2f06
--- /dev/null
+++ b/internal/physical/manager.go
@@ -0,0 +1,303 @@
+// 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
+ 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{}}
+ 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")
+ }
+ 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
+ }
+ 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) {
+ c := sesame.NewClient(r.Target, key, m.dial)
+ m.clients[r.ID] = c
+ m.wg.Add(1)
+ go func() { defer m.wg.Done(); c.Run(m.ctx) }()
+}
+func (m *Manager) Close() { m.cancel(); m.enroll.Lock(); defer m.enroll.Unlock(); 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")
+ }
+ ctx, cancel := context.WithTimeout(ctx, 30*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 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 {
+ e = sesame.Register(ctx, in.Target, m.dial, func(c sesame.Credential) error { key = c; return writeJSON(m.keyPath(id), c) })
+ }
+ 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
+ m.start(r, key)
+ 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
+}
diff --git a/internal/physical/manager_test.go b/internal/physical/manager_test.go
new file mode 100644
index 0000000..8a098ac
--- /dev/null
+++ b/internal/physical/manager_test.go
@@ -0,0 +1,154 @@
+package physical
+
+import (
+ "context"
+ "encoding/json"
+ "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")
+ }
+}
diff --git a/scripts/stage-ha-app.sh b/scripts/stage-ha-app.sh
index 6352eec..9cf7a8f 100755
--- a/scripts/stage-ha-app.sh
+++ b/scripts/stage-ha-app.sh
@@ -20,7 +20,7 @@ mkdir -p "$dest"
cp "$root/Dockerfile" "$root/.dockerignore" "$root/go.mod" "$root/go.sum" \
"$root/LICENSE" "$root/NOTICE" "$dest/"
-cp -R "$root/cmd" "$root/internal" "$dest/"
+cp -R "$root/cmd" "$root/internal" "$root/drivers" "$dest/"
mkdir -p "$dest/ha-app"