penguin/homekey-go

homekey-go

commit 94e32c34840a07687276e81b7030d224d10a413f

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-05T08:19:32Z
subjectExpress Mode routing
commit 94e32c34840a07687276e81b7030d224d10a413f
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-05T08:19:32Z

    Express Mode routing
---
 README.md                         | 10 +++++++++-
 build.env                         |  2 +-
 cmd/homekey/main.go               |  2 +-
 ha-app/CHANGELOG.md               |  7 +++++++
 ha-app/NFC.md                     | 19 +++++++++++++------
 ha-app/config.yaml                |  2 +-
 internal/blegateway/gateway.go    | 28 +++++++++++++++++++++++++---
 internal/blegateway/relay.go      |  7 +++++++
 internal/blegateway/relay_test.go | 25 +++++++++++++++++++++++++
 internal/provision/auth.go        | 14 ++++++++++++++
 internal/provision/auth_test.go   | 27 +++++++++++++++++++++++++++
 11 files changed, 130 insertions(+), 13 deletions(-)

diff --git a/README.md b/README.md
index abb0c04..02c4b96 100644
--- a/README.md
+++ b/README.md
@@ -145,7 +145,7 @@ follow-up implementation before this controls a real lock. Active/inactive
 credential state is enforced by NFC authentication.
 
 NFC STANDARD authentication and the ESP32 BLE APDU bridge are implemented.
-FAST, ECP express wakeup, NFC attestation, a physical-lock adapter and a cloud
+FAST, NFC attestation, a physical-lock adapter and a cloud
 API are not implemented. Unknown/inactive endpoints are rejected.
 
 ## Build and test
@@ -181,3 +181,11 @@ Protocol sources:
 - https://github.com/ikalchev/HAP-python (Home Key characteristic metadata)
 
 Apache-2.0; see LICENSE, NOTICE and the retained third-party license.
+
+### Home Key ECP
+
+Version 0.0.5 requires the matching Home Key ECP relay firmware. Go supplies the
+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).
diff --git a/build.env b/build.env
index d3cd6ca..4c09e9b 100644
--- a/build.env
+++ b/build.env
@@ -1,4 +1,4 @@
 IMAGE_NAME=ghcr.io/tgckpg/homekey-go
-IMAGE_TAG=0.0.4
+IMAGE_TAG=0.0.5
 
 APT_PROXY=
diff --git a/cmd/homekey/main.go b/cmd/homekey/main.go
index cca5263..adbb438 100644
--- a/cmd/homekey/main.go
+++ b/cmd/homekey/main.go
@@ -180,7 +180,7 @@ func run() error {
 				success = true
 				log.Printf("Home Key authenticated reader=%s session=%d endpoint=%s; virtual lock unlocked", *bleReader, card.Session, result.EndpointID)
 				return nil
-			})
+			}, store.ReaderGroupIdentifier)
 		}()
 	}
 	err = dev.Server.ListenAndServe(ctx)
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index 568095b..2b4d13c 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,10 @@
+## 0.0.5
+
+- Supply the provisioned Home group identifier to matching ESP32 firmware over BLE.
+- Refresh ECP configuration after provisioning changes and every reconnect.
+- Enable Home Key ECP polling before NFC activation, including Express Mode routing.
+- Requires matching homekey-relay ECP firmware; no new app configuration.
+
 ## 0.0.4
 DEBUG added logs
 
diff --git a/ha-app/NFC.md b/ha-app/NFC.md
index 4c5f868..fc85b0d 100644
--- a/ha-app/NFC.md
+++ b/ha-app/NFC.md
@@ -20,7 +20,7 @@ physical actuator integration in this release.
    existing initialization order. NFC runs in its own task with an 8192-byte
    stack; there is no need to edit an existing sdkconfig's main-task stack.
 
-2. Build and publish your usual `homekey-go` image tagged `0.0.4`, then stage
+2. Build and publish your usual `homekey-go` image tagged `0.0.5`, then stage
    the HA app metadata using the existing repository workflow. Update the
    installed app and restart it. Keep its existing persistent `/data/state`.
 
@@ -34,8 +34,9 @@ physical actuator integration in this release.
    The HA app still needs host D-Bus and host networking. No new port is added.
    The HACS integration is not involved in this direct Go-to-ESP32 connection.
 
-4. For the first test, open the already-provisioned Home Key in Apple Wallet
-   and hold the phone at the reader until the exchange finishes. Remove it
+4. Hold the phone at the reader with its existing Home Key installed. ECP
+   should select the key; Express Mode can authenticate without opening Wallet.
+   If Express Mode is disabled, approve the presented Home Key. Remove it
    between attempts. Set the virtual lock to locked first if you want to see
    the successful transition in Home.
 
@@ -43,6 +44,7 @@ Expected Go milestones:
 
 ```text
 BLE gateway: connected to ...; APDU write_size=...
+BLE gateway: Home Key ECP configured reader=... enabled=true
 NFC card reader=... session=... uid_len=... sak=0x20
 Home Key SELECT accepted; protocol=2.0
 Home Key AUTH0 key exchange complete
@@ -72,9 +74,14 @@ or its provisioned keys.
 - PING/PONG remains available; its polling pauses during authentication.
 - No credential payloads, private/session keys or APDU contents in normal logs.
 
-FAST authentication, ECP express-mode wakeup and NFC attestation for previously
-unknown/shared devices are not implemented yet. Use Wallet explicitly for this
-release. The stored persistent key is preparation for FAST; this release still
+Home Key ECP routing is implemented in the matching firmware. It requires both
+updates: Go supplies the public Home group ID, and the ESP32 broadcasts it before
+card activation. No private key is sent over BLE. Firmware waits with RF off
+until configured and clears the configuration on reconnect. Go updates the
+group when provisioning changes. ECP itself does not authenticate a device.
+
+FAST authentication and NFC attestation for previously unknown/shared devices
+are not implemented yet. The stored persistent key is preparation for FAST; this release still
 performs STANDARD on every tap.
 
 The NFC session expires after 15 seconds. Go allows 12 seconds for authentication,
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index 5dbce2e..6092ba0 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,5 +1,5 @@
 name: Home Key Go
-version: "0.0.4"
+version: "0.0.5"
 slug: homekey_go
 description: HomeKit virtual lock with Apple Home Key NFC authentication
 url: https://sgit.k8s.astropenguin.net/penguin/homekey-go/
diff --git a/internal/blegateway/gateway.go b/internal/blegateway/gateway.go
index 0b9de0b..e15a222 100644
--- a/internal/blegateway/gateway.go
+++ b/internal/blegateway/gateway.go
@@ -60,9 +60,9 @@ func text(p map[string]dbus.Variant, k string) string { v, _ := p[k].Value().(st
 
 // Run reconnects independently of HAP. A Bluetooth failure does not stop
 // HomeKit provisioning. Each attempt gets a fresh system-bus connection.
-func Run(ctx context.Context, address, adapter string, interval time.Duration, handler Handler) {
+func Run(ctx context.Context, address, adapter string, interval time.Duration, handler Handler, groupProvider func() []byte) {
 	for ctx.Err() == nil {
-		if err := session(ctx, strings.ToUpper(address), adapter, interval, handler); err != nil && ctx.Err() == nil {
+		if err := session(ctx, strings.ToUpper(address), adapter, interval, handler, groupProvider); err != nil && ctx.Err() == nil {
 			log.Printf("BLE gateway: %v; retrying in 5s", err)
 		}
 		if !wait(ctx, 5*time.Second) {
@@ -70,7 +70,7 @@ func Run(ctx context.Context, address, adapter string, interval time.Duration, h
 		}
 	}
 }
-func session(ctx context.Context, address, adapter string, interval time.Duration, handler Handler) error {
+func session(ctx context.Context, address, adapter string, interval time.Duration, handler Handler, groupProvider func() []byte) error {
 	c, err := dbus.ConnectSystemBus()
 	if err != nil {
 		return fmt.Errorf("system D-Bus: %w", err)
@@ -195,6 +195,8 @@ func session(ctx context.Context, address, adapter string, interval time.Duratio
 	ch := c.Object("org.bluez", characteristic)
 	status := c.Object("org.bluez", statusPath)
 	apdu := c.Object("org.bluez", apduPath)
+	var configuredGroup []byte
+	configured := false
 	var lastSession uint32
 	seq := uint32(0)
 	nextPing := time.Time{}
@@ -203,6 +205,26 @@ func session(ctx context.Context, address, adapter string, interval time.Duratio
 		if e != nil {
 			return fmt.Errorf("NFC status: %w", e)
 		}
+		// Configure only between sessions; the NFC owner takes a snapshot for
+		// each scan. Every new BLE connection gets a fresh configuration.
+		if card.Session == 0 && groupProvider != nil {
+			group := groupProvider()
+			if !configured || !bytes.Equal(group, configuredGroup) {
+				b, err := ecpConfigPacket(group)
+				if err != nil {
+					return err
+				}
+				configCtx, done := context.WithTimeout(ctx, 3*time.Second)
+				err = (&Relay{characteristic: apdu}).write(configCtx, b)
+				done()
+				if err != nil {
+					return fmt.Errorf("ECP configuration (flash matching firmware): %w", err)
+				}
+				configuredGroup = append([]byte(nil), group...)
+				configured = true
+				log.Printf("BLE gateway: Home Key ECP configured reader=%s enabled=%t", address, len(group) == 8)
+			}
+		}
 		if card.Session != 0 && card.Session != lastSession {
 			lastSession = card.Session
 			relay := &Relay{characteristic: apdu, session: card.Session, writeSize: writeSize}
diff --git a/internal/blegateway/relay.go b/internal/blegateway/relay.go
index 45de6ce..376143b 100644
--- a/internal/blegateway/relay.go
+++ b/internal/blegateway/relay.go
@@ -146,3 +146,10 @@ func readCard(ctx context.Context, ch dbus.BusObject) (Card, error) {
 	}
 	return parseCard(b)
 }
+
+func ecpConfigPacket(group []byte) ([]byte, error) {
+	if len(group) != 0 && len(group) != 8 {
+		return nil, fmt.Errorf("ECP group identifier must be 8 bytes")
+	}
+	return packet(3, 0, 0, 0, len(group), group), nil
+}
diff --git a/internal/blegateway/relay_test.go b/internal/blegateway/relay_test.go
index 6fe309f..142f645 100644
--- a/internal/blegateway/relay_test.go
+++ b/internal/blegateway/relay_test.go
@@ -42,3 +42,28 @@ func TestCardSession(t *testing.T) {
 		t.Fatal("bad UID accepted")
 	}
 }
+
+func TestECPConfiguration(t *testing.T) {
+	group := []byte{1, 2, 3, 4, 5, 6, 7, 8}
+	b, err := ecpConfigPacket(group)
+	if err != nil || len(b) != 20 || b[1] != 3 || binary.BigEndian.Uint16(b[10:]) != 8 {
+		t.Fatal("bad ECP packet")
+	}
+	for _, v := range b[2:10] {
+		if v != 0 {
+			t.Fatal("configuration must be session-independent")
+		}
+	}
+	for i, v := range group {
+		if b[12+i] != v {
+			t.Fatal("wrong group")
+		}
+	}
+	b, err = ecpConfigPacket(nil)
+	if err != nil || len(b) != 12 {
+		t.Fatal("bad disable packet")
+	}
+	if _, err = ecpConfigPacket(group[:7]); err == nil {
+		t.Fatal("accepted invalid group")
+	}
+}
diff --git a/internal/provision/auth.go b/internal/provision/auth.go
index e9c0304..67a2cfb 100644
--- a/internal/provision/auth.go
+++ b/internal/provision/auth.go
@@ -54,3 +54,17 @@ func (s *Store) WithAuthorization(expected Credentials, iid, eid, pub string, fn
 	}
 	return fn()
 }
+
+// ReaderGroupIdentifier is public ECP routing metadata derived from the
+// provisioned reader key, matching the first half of the NFC reader ID.
+// A nil result disables ECP while the Home has no provisioned reader key.
+func (s *Store) ReaderGroupIdentifier() []byte {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	scalar, err := hex.DecodeString(s.data.HomeKey.ReaderPrivateKey)
+	if err != nil || len(scalar) != 32 {
+		return nil
+	}
+	group, _ := hex.DecodeString(keyID(scalar))
+	return group
+}
diff --git a/internal/provision/auth_test.go b/internal/provision/auth_test.go
index fb16290..e99843f 100644
--- a/internal/provision/auth_test.go
+++ b/internal/provision/auth_test.go
@@ -2,6 +2,7 @@ package provision
 
 import (
 	"bytes"
+	"encoding/hex"
 	"testing"
 )
 
@@ -83,3 +84,29 @@ func TestAuthenticationRejectsReaderChangeAndInactive(t *testing.T) {
 		})
 	}
 }
+
+func TestReaderGroupIdentifier(t *testing.T) {
+	s, _, _ := paired(t)
+	if s.ReaderGroupIdentifier() != nil {
+		t.Fatal("unprovisioned reader has ECP group")
+	}
+	if _, err := s.Handle(readerAdd()); err != nil {
+		t.Fatal(err)
+	}
+	// Independent SHA256("key-identifier" || scalar=1) vector.
+	want := mustHex("539a3f91ed603fef")
+	group := s.ReaderGroupIdentifier()
+	if !bytes.Equal(group, want) {
+		t.Fatalf("wrong group: %s", hex.EncodeToString(group))
+	}
+	group[0] ^= 1
+	if !bytes.Equal(s.ReaderGroupIdentifier(), want) {
+		t.Fatal("group snapshot aliases store")
+	}
+	if _, err := s.Handle(request(3, 6, TLV(4, want))); err != nil {
+		t.Fatal(err)
+	}
+	if s.ReaderGroupIdentifier() != nil {
+		t.Fatal("removed reader still has ECP group")
+	}
+}