commit 1c7c6756abaed1e6b645ee73dcf3eef7148bc1bc
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-05T02:28:20Z |
| subject | Added service for key exchange |
commit 1c7c6756abaed1e6b645ee73dcf3eef7148bc1bc
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-05T02:28:20Z
Added service for key exchange
---
Dockerfile | 2 +-
NOTICE | 11 +-
README.md | 22 ++-
cmd/homekey/main.go | 50 ++++++-
go.mod | 2 +-
ha-app/CHANGELOG.md | 8 +
ha-app/DOCS.md | 90 ++++-------
ha-app/NFC.md | 100 +++++++++++++
ha-app/config.yaml | 4 +-
internal/blegateway/gateway.go | 109 +++++++++++---
internal/blegateway/relay.go | 148 ++++++++++++++++++
internal/blegateway/relay_test.go | 44 ++++++
internal/nfcauth/auth.go | 258 ++++++++++++++++++++++++++++++++
internal/nfcauth/auth_test.go | 221 +++++++++++++++++++++++++++
internal/nfcauth/secure.go | 93 ++++++++++++
internal/nfcauth/testdata/standard.json | 11 ++
internal/provision/auth.go | 56 +++++++
internal/provision/auth_test.go | 85 +++++++++++
scripts/stage-ha-app.sh | 2 +-
19 files changed, 1212 insertions(+), 104 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 1de8228..975cae6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,7 +10,7 @@ RUN CGO_ENABLED=0 go build -mod=readonly -buildvcs=false -trimpath \
-ldflags='-s -w' -o /out/homekey ./cmd/homekey
FROM debian:bookworm-slim
-ARG BUILD_VERSION=0.0.2
+ARG BUILD_VERSION=0.0.3
ARG BUILD_ARCH=amd64
LABEL io.hass.type="app" \
io.hass.version="${BUILD_VERSION}" \
diff --git a/NOTICE b/NOTICE
index a9975d9..d13a95a 100644
--- a/NOTICE
+++ b/NOTICE
@@ -10,9 +10,16 @@ Upstream credits include kormax (ECP/NFC protocol research), kupa22
(HAP and NFC protocol analysis), and the project's contributors.
Additional protocol documentation: https://github.com/kupa22/apple-homekey
-Changes: Go implementation of provisioning only; bounded TLV8 parsing;
+NFC STANDARD authentication and Digital Key secure-response framing adapted from
+ kormax/apple-home-key-reader homekey.py, entity.py, util/digital_key.py
+ Commit 42454594069529d9d945581afc1cdac59fc187c8
+ Apache License 2.0
+
+Changes: Go implementation of provisioning and NFC STANDARD authentication; bounded TLV8 parsing;
validated keys; atomic combined HAP/credential persistence; lifecycle cleanup;
-standalone virtual lock; Go tests and Makefile. NFC authentication is omitted.
+standalone virtual lock; Go tests and Makefile. NFC transport is a bounded BLE APDU mailbox; response MAC and signature
+verification, active endpoint authorization and revocation checks are included.
+FAST authentication, ECP and NFC attestation exchange are omitted.
The bundled third_party/hap code is from github.com/brutella/hap v0.0.35,
commit bc03ca629d44dd58570c2979843f9f677818e31c. Original license and source
diff --git a/README.md b/README.md
index 234997d..62ed1a4 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Home Key provisioning in Go
+# Home Key in Go
Basically [kormax/apple-home-key-reader](https://github.com/kormax/apple-home-key-reader) in go.
@@ -24,9 +24,10 @@ Use a compatible iPhone with a passcode and Apple Home/iCloud configured.
If setup creates only a normal lock, check the provisioning log and status
below; pairing alone does not prove that Wallet provisioned a key.
-This accessory simulates locked/unlocked state for the Home app. Changing it
-**does not operate a physical lock**, and the provisioned key cannot yet be
-used at an NFC reader. Those connections are the next development phase.
+This accessory simulates locked/unlocked state for the Home app. A matching
+ESP32/PN532 BLE reader can authenticate an enrolled Home Key and unlock this
+virtual state. Changing it **does not operate a physical lock**. See
+[the NFC guide](ha-app/NFC.md) for deployment and first-tap testing.
## Linux
@@ -141,11 +142,11 @@ REMOVE return Not Supported because their complete wire semantics are not
established by the reference. Entire issuer removal through HomeKit unpairing
is implemented. Per-device removal while keeping an issuer paired needs a
follow-up implementation before this controls a real lock. Active/inactive
-credential state is preserved for later authentication policy.
+credential state is enforced by NFC authentication.
-There is no NFC authentication, attestation, ESP32 bridge, physical-lock
-adapter or cloud API in this project. First validate provisioning on the
-phone; then add the ESP32 transport and authentication service.
+NFC STANDARD authentication and the ESP32 BLE APDU bridge are implemented.
+FAST, ECP express wakeup, NFC attestation, a physical-lock adapter and a cloud
+API are not implemented. Unknown/inactive endpoints are rejected.
## Build and test
@@ -180,3 +181,8 @@ Protocol sources:
- https://github.com/ikalchev/HAP-python (Home Key characteristic metadata)
Apache-2.0; see LICENSE, NOTICE and the retained third-party license.
+
+## NFC authentication
+
+Version 0.0.3 adds BLE APDU relay and Home Key STANDARD authentication for
+already-enrolled devices. See [NFC deployment and testing](ha-app/NFC.md).
diff --git a/cmd/homekey/main.go b/cmd/homekey/main.go
index 9c74530..cca5263 100644
--- a/cmd/homekey/main.go
+++ b/cmd/homekey/main.go
@@ -9,6 +9,7 @@ import (
"flag"
"fmt"
"homekey.local/provisioner/internal/blegateway"
+ "homekey.local/provisioner/internal/nfcauth"
"log"
"math/big"
"net"
@@ -40,7 +41,7 @@ func run() error {
iface := flag.String("interface", "", "LAN interface for mDNS; empty selects eligible interfaces")
finish := flag.String("finish", "silver", "Wallet artwork: silver, black, gold, tan")
status := flag.Bool("status", false, "print credential counts without starting the server")
- bleReader := flag.String("ble-reader", "", "ESP32 Bluetooth MAC; empty disables BLE probe")
+ bleReader := flag.String("ble-reader", "", "ESP32 Bluetooth MAC; empty disables NFC gateway")
bleAdapter := flag.String("ble-adapter", "hci0", "BlueZ adapter name")
bleInterval := flag.Duration("ble-ping-interval", 10*time.Second, "BLE PING interval")
flag.Parse()
@@ -130,14 +131,57 @@ func run() error {
fmt.Printf("%s — virtual HomeKit lock\n", *name)
fmt.Printf("\"Home\" app from Apple > Add Accessory > More Options > %s\n", *name)
fmt.Printf("Pairing code: %s-%s\n", p[:4], p[4:])
- fmt.Println("Provisioning only: NFC reader and physical lock are not connected.")
+ fmt.Println("NFC authentication enabled when a BLE reader is configured; virtual lock only.")
log.Printf("HAP address %s; persistent state %s", *addr, *state)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
var bleDone chan struct{}
if *bleReader != "" {
bleDone = make(chan struct{})
- go func() { defer close(bleDone); blegateway.Run(ctx, *bleReader, *bleAdapter, *bleInterval) }()
+ go func() {
+ defer close(bleDone)
+ blegateway.Run(ctx, *bleReader, *bleAdapter, *bleInterval, func(authCtx context.Context, card blegateway.Card, relay *blegateway.Relay) (err error) {
+ log.Printf("NFC card reader=%s session=%d uid_len=%d sak=0x%02x", *bleReader, card.Session, len(card.UID), card.SAK)
+ if card.SAK&0x20 == 0 {
+ return fmt.Errorf("card does not support ISO-DEP")
+ }
+ success := false
+ defer func() {
+ // Best-effort failure UX even if the authentication deadline expired.
+ finishCtx, done := context.WithTimeout(ctx, 2*time.Second)
+ defer done()
+ if e := nfcauth.Finish(finishCtx, relay, success); e != nil {
+ log.Printf("NFC control flow: %v", e)
+ }
+ }()
+ credentials := store.AuthenticationCredentials()
+ result, err := nfcauth.Authenticate(authCtx, relay, credentials)
+ if err != nil {
+ return err
+ }
+ if err = store.SaveAuthenticatedKey(credentials, result.IssuerID, result.EndpointID, result.PublicKey, result.PersistentKey); err != nil {
+ return err
+ }
+ if authCtx.Err() != nil {
+ return authCtx.Err()
+ }
+ err = store.WithAuthorization(credentials, result.IssuerID, result.EndpointID, result.PublicKey, func() error {
+ if authCtx.Err() != nil {
+ return authCtx.Err()
+ }
+ if e := dev.Lock.LockTargetState.SetValue(0); e != nil {
+ return e
+ }
+ return dev.Lock.LockCurrentState.SetValue(0)
+ })
+ if err != nil {
+ return err
+ }
+ success = true
+ log.Printf("Home Key authenticated reader=%s session=%d endpoint=%s; virtual lock unlocked", *bleReader, card.Session, result.EndpointID)
+ return nil
+ })
+ }()
}
err = dev.Server.ListenAndServe(ctx)
stopped := ctx.Err() != nil
diff --git a/go.mod b/go.mod
index dbfe4e5..6144754 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.23.0
require (
github.com/brutella/hap v0.0.35
github.com/godbus/dbus/v5 v5.1.0
+ golang.org/x/crypto v0.24.0
)
require (
@@ -15,7 +16,6 @@ require (
github.com/vishvananda/netlink v1.2.1-beta.2 // indirect
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae // indirect
github.com/xiam/to v0.0.0-20200126224905-d60d31e03561 // indirect
- golang.org/x/crypto v0.24.0 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/sync v0.7.0 // indirect
diff --git a/ha-app/CHANGELOG.md b/ha-app/CHANGELOG.md
index 0af9d40..c8f2bfc 100644
--- a/ha-app/CHANGELOG.md
+++ b/ha-app/CHANGELOG.md
@@ -1,3 +1,11 @@
+## 0.0.3
+
+- Relay NFC APDUs through the ESP32 BLE reader, keeping the card active.
+- Authenticate enrolled Home Keys using SELECT, AUTH0 and STANDARD AUTH1.
+- Verify response MACs and device signatures before unlocking the virtual lock.
+- Recheck credentials against concurrent HomeKit revocation; save persistent keys.
+- Preserve PING/PONG, PN532 startup wake prefixes and GPIO mapping.
+
## 0.0.2
- Optional direct BlueZ BLE PING/PONG probe with reconnect and matching sequence numbers.
diff --git a/ha-app/DOCS.md b/ha-app/DOCS.md
index 8aeb83d..f2b4783 100644
--- a/ha-app/DOCS.md
+++ b/ha-app/DOCS.md
@@ -118,77 +118,41 @@ and repository identity to keep existing installs on their update path.
## Current functionality
-This packages the existing provisioning service unchanged. The HAP listener
-supports Apple Home pairing and Home Key provisioning; lock toggles are still
-simulated. No reader API, MQTT connection, Home Assistant entity integration,
-or ESP32/PN532 authentication transport is added by these files.
+The HAP listener supports Apple Home pairing and Home Key provisioning.
+Version 0.0.3 adds a direct BlueZ BLE connection to the ESP32/PN532 reader and
+STANDARD NFC authentication for enrolled keys. A verified active key unlocks
+the virtual lock; physical actuator integration is not included.
+See [NFC.md](NFC.md) for matching firmware, Wallet test steps, expected logs
+and current limitations. Keep the existing HomeKit state and app slug when
+updating.
-# Initial BLE PING/PONG transport
+## BLE gateway configuration
-Copy the files under `homekey-go/` and `homekey-relay/` into their corresponding
-repositories. This bundle contains changed/new files only, plus unified patches.
-Do not replace the whole repositories with these directories.
-
-## ESP32
-
-Build and flash as before:
-
- cd homekey-relay/esp32
- idf.py set-target esp32c3
- idf.py build
- idf.py -p /dev/cu.usbmodem101 flash monitor
-
-Copy `BLE address AA:BB:CC:DD:EE:FF` from the serial log.
-The reader now continuously advertises a connectable GATT service until connected.
-PN532 polling continues unchanged. The previous non-connectable UID broadcasts
-are replaced; the old HACS UID sensor will no longer receive those events.
-Card events are logged locally; forwarding them is a subsequent step.
-
-## Go / HA app
-
-Publish the rebuilt image as `ghcr.io/tgckpg/homekey-go:0.0.2` and update the app,
-or use `sh scripts/stage-ha-app.sh` for a local build as before.
-The app config enables `host_dbus: true`; no raw USB access is needed.
-Keep the existing HomeKit options and add:
-
- ble_reader: "AA:BB:CC:DD:EE:FF"
- ble_adapter: "hci0"
-
-Restart the app. Expected Go log:
-
- BLE gateway: connected to AA:BB:CC:DD:EE:FF
- BLE PONG reader=AA:BB:CC:DD:EE:FF seq=1 round_trip=...
-
-Expected ESP32 log:
-
- BLE gateway connected
- BLE PING received; PONG ready
+```yaml
+ble_reader: "70:AF:09:16:42:5A"
+ble_adapter: hci0
+```
-Empty `ble_reader` disables the probe. It sends every 10 seconds; failures retry
-in 5 seconds without taking down HAP. The BlueZ adapter must be powered and
-recognized by HA OS. If logs show D-Bus/AppArmor access denied, inspect the host
-denial before changing permissions. This backend does not use ESPHome proxies.
+An empty `ble_reader` disables the gateway. The app uses host D-Bus; the BlueZ
+adapter must be powered and recognized by HA OS. Failures reconnect after
+5 seconds without stopping HAP. Only one gateway should connect to the reader.
+This backend connects directly and does not use ESPHome proxies or HACS.
Standalone Linux:
- go run ./cmd/homekey -ble-reader AA:BB:CC:DD:EE:FF -ble-adapter hci0
-
-Optional `-ble-ping-interval 10s`, minimum 1 second.
-
-## Protocol v0
+```sh
+go run ./cmd/homekey -ble-reader 70:AF:09:16:42:5A -ble-adapter hci0
+```
-Service UUID: 7a6b0001-5b21-4f36-8e5d-9c3a26d74210
-Read/write characteristic: 7a6b0002-5b21-4f36-8e5d-9c3a26d74210
-Write with response: 8 bytes = ASCII PING + uint32 sequence, big endian.
-Read: 8 bytes = ASCII PONG + matching sequence; empty before first PING.
-All messages fit the default ATT MTU. The ESP32 prepares PONG in the write callback.
-The Go client verifies the sequence and records write-plus-read elapsed time.
-No notifications, pairing, bonding, NFC authentication, or unlocking in this probe.
-Only one gateway should connect to the reader at a time.
+PING/PONG remains on the original read/write characteristic. The optional
+`-ble-ping-interval 10s` flag has a minimum of 1 second. Ping pauses while a
+card's authentication transaction is running. Two additional characteristics
+provide NFC session status and APDU exchange.
## Validation
-Go tests, vet, Linux build, and shell syntax checked in the build environment.
-ESP-IDF compilation and end-to-end radio testing require your ESP-IDF toolchain
-and hardware and have not been performed here.
+Go tests with the race detector, vet and a Linux build pass. Firmware UART and
+mailbox host tests pass with AddressSanitizer/UndefinedBehaviorSanitizer.
+ESP-IDF compilation and end-to-end radio testing still require your toolchain
+and hardware; they have not been performed here.
diff --git a/ha-app/NFC.md b/ha-app/NFC.md
new file mode 100644
index 0000000..495e5ab
--- /dev/null
+++ b/ha-app/NFC.md
@@ -0,0 +1,100 @@
+# NFC STANDARD authentication (0.0.3)
+
+This release connects the HomeKit provisioning store to the ESP32 reader. Go
+runs SELECT, AUTH0 and STANDARD AUTH1; the ESP32 keeps the PN532 target active
+and relays APDUs. A verified, active enrolled endpoint unlocks the **virtual**
+HomeKit lock and updates its target/current state to unlocked. There is no
+physical actuator integration in this release.
+
+## Deploy both sides
+
+1. Apply the matching `homekey-relay` changes and build/flash its ESP32 project:
+
+ ```sh
+ cd esp32
+ idf.py build flash monitor
+ ```
+
+ The PN532 UART remains TX GPIO3 / RX GPIO4 at 115200 baud. The firmware
+ preserves the startup wake prefix on firmware and SAM commands and their
+ 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.3`, then stage
+ the HA app metadata using the existing repository workflow. Update the
+ installed app and restart it. Keep its existing persistent `/data/state`.
+
+3. Keep the current app configuration:
+
+ ```yaml
+ ble_reader: "70:AF:09:16:42:5A"
+ ble_adapter: hci0
+ ```
+
+ 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
+ between attempts. Set the virtual lock to locked first if you want to see
+ the successful transition in Home.
+
+Expected Go milestones:
+
+```text
+BLE gateway: connected to ...; APDU write_size=...
+NFC card reader=... session=... uid_len=... sak=0x20
+Home Key SELECT accepted; protocol=2.0
+Home Key AUTH0 key exchange complete
+Home Key authenticated reader=... session=... endpoint=...; virtual lock unlocked
+```
+
+An ordinary card can be detected without having the Home Key applet. SELECT
+status `6a82` means the applet was unavailable for that attempt. It does not
+mean that UID detection or BLE is broken. `AUTH1: unknown, inactive or invalid
+endpoint` does not grant access; check that the phone has an enrolled key.
+
+If the app reports missing relay characteristics after flashing, BlueZ may
+still have the old GATT services cached. Disconnect/reconnect first. If they
+remain missing, remove **only this reader's** BlueZ device entry on the host
+and let the gateway rediscover it. There is no need to remove the HomeKit lock
+or its provisioned keys.
+
+## Scope
+
+- STANDARD authentication for active endpoints enrolled through HomeKit.
+- Fresh P-256 ephemeral keys and a nonce for each attempt.
+- Reader proof, secure response MAC, endpoint signature verification.
+- Persistent key saved only after successful verification.
+- Current credentials rechecked before persistence and again under the store
+ lock while updating the virtual lock, preventing concurrent revocation from
+ using an old snapshot.
+- 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
+performs STANDARD on every tap.
+
+The NFC session expires after 15 seconds. Go allows 12 seconds for authentication,
+5 seconds per APDU and 2 seconds for completion UX. APDUs are bounded to 240
+bytes; extended/chained PN532 APDUs and ISO18013 attestation transfers are not
+supported. An error, removal, timeout or disconnect never authenticates a key.
+
+## Verification
+
+Go tests cover an end-to-end simulated phone, response tampering, unknown and
+inactive endpoints, revocation and reader changes, BER-TLV failures and stale
+BLE responses. Independent Python `cryptography` vectors verify X9.63/HKDF and
+secure-response decryption; RFC 4493 vectors verify AES-CMAC.
+
+The firmware host tests exercise UART frame parsing and the BLE mailbox with
+AddressSanitizer/UndefinedBehaviorSanitizer:
+
+```sh
+./tests/run.sh
+```
+
+These host tests do not replace an ESP-IDF build or an actual phone tap. This
+patch was not tested on ESP32/PN532 hardware.
diff --git a/ha-app/config.yaml b/ha-app/config.yaml
index debdfbd..3a35002 100644
--- a/ha-app/config.yaml
+++ b/ha-app/config.yaml
@@ -1,7 +1,7 @@
name: Home Key Go
-version: "0.0.2"
+version: "0.0.3"
slug: homekey_go
-description: Virtual HomeKit lock and Apple Home Key provisioning service
+description: HomeKit virtual lock with Apple Home Key NFC authentication
url: https://sgit.k8s.astropenguin.net/penguin/homekey-go/
arch:
- aarch64
diff --git a/internal/blegateway/gateway.go b/internal/blegateway/gateway.go
index 52444dc..0b9de0b 100644
--- a/internal/blegateway/gateway.go
+++ b/internal/blegateway/gateway.go
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
-// Package blegateway implements the initial Linux BlueZ PING/PONG probe.
+// Package blegateway implements the Linux BlueZ NFC APDU relay and PING/PONG probe.
package blegateway
import (
@@ -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) {
+func Run(ctx context.Context, address, adapter string, interval time.Duration, handler Handler) {
for ctx.Err() == nil {
- if err := session(ctx, strings.ToUpper(address), adapter, interval); err != nil && ctx.Err() == nil {
+ if err := session(ctx, strings.ToUpper(address), adapter, interval, handler); 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) {
}
}
}
-func session(ctx context.Context, address, adapter string, interval time.Duration) error {
+func session(ctx context.Context, address, adapter string, interval time.Duration, handler Handler) error {
c, err := dbus.ConnectSystemBus()
if err != nil {
return fmt.Errorf("system D-Bus: %w", err)
@@ -158,26 +158,89 @@ func session(ctx context.Context, address, adapter string, interval time.Duratio
return fmt.Errorf("PING service resolution: %w", resolveCtx.Err())
}
}
- log.Printf("BLE gateway: connected to %s", address)
+ o, err := getObjects(ctx, c)
+ if err != nil {
+ return err
+ }
+ var statusPath, apduPath dbus.ObjectPath
+ writeSize := 20 // Works even at the mandatory ATT MTU of 23.
+ for path, ifs := range o {
+ props, ok := ifs["org.bluez.GattCharacteristic1"]
+ if !ok {
+ continue
+ }
+ service, _ := props["Service"].Value().(dbus.ObjectPath)
+ sp := o[service]["org.bluez.GattService1"]
+ owner, _ := sp["Device"].Value().(dbus.ObjectPath)
+ if owner != device || !strings.EqualFold(text(sp, "UUID"), ServiceUUID) {
+ continue
+ }
+ switch strings.ToLower(text(props, "UUID")) {
+ case StatusUUID:
+ statusPath = path
+ case APDUUUID:
+ apduPath = path
+ if mtu, ok := props["MTU"].Value().(uint16); ok && mtu >= 23 {
+ writeSize = int(mtu) - 3
+ if writeSize > 244 {
+ writeSize = 244
+ }
+ }
+ }
+ }
+ if statusPath == "" || apduPath == "" {
+ return fmt.Errorf("NFC relay characteristics missing; flash the matching ESP32 firmware")
+ }
+ log.Printf("BLE gateway: connected to %s; APDU write_size=%d", address, writeSize)
ch := c.Object("org.bluez", characteristic)
- for seq := uint32(1); ctx.Err() == nil; seq++ {
- pingCtx, pingDone := context.WithTimeout(ctx, 5*time.Second)
- started := time.Now()
- err := ch.CallWithContext(pingCtx, "org.bluez.GattCharacteristic1.WriteValue", 0,
- request(seq), map[string]dbus.Variant{"type": dbus.MakeVariant("request")}).Err
- var reply []byte
- if err == nil {
- err = ch.CallWithContext(pingCtx, "org.bluez.GattCharacteristic1.ReadValue", 0, map[string]dbus.Variant{}).Store(&reply)
- }
- pingDone()
- if err != nil {
- return fmt.Errorf("PING %d: %w", seq, err)
- }
- if !validReply(reply, seq) {
- return fmt.Errorf("PING %d: unexpected reply %x", seq, reply)
- }
- log.Printf("BLE PONG reader=%s seq=%d round_trip=%s", address, seq, time.Since(started).Round(time.Millisecond))
- if !wait(ctx, interval) {
+ status := c.Object("org.bluez", statusPath)
+ apdu := c.Object("org.bluez", apduPath)
+ var lastSession uint32
+ seq := uint32(0)
+ nextPing := time.Time{}
+ for ctx.Err() == nil {
+ card, e := readCard(ctx, status)
+ if e != nil {
+ return fmt.Errorf("NFC status: %w", e)
+ }
+ if card.Session != 0 && card.Session != lastSession {
+ lastSession = card.Session
+ relay := &Relay{characteristic: apdu, session: card.Session, writeSize: writeSize}
+ authCtx, done := context.WithTimeout(ctx, 12*time.Second)
+ if handler != nil {
+ e = handler(authCtx, card, relay)
+ }
+ done()
+ cleanup, finish := context.WithTimeout(context.Background(), 3*time.Second)
+ releaseError := relay.Release(cleanup)
+ finish()
+ if e != nil {
+ log.Printf("NFC authentication rejected reader=%s session=%d: %v", address, card.Session, e)
+ }
+ if releaseError != nil && ctx.Err() == nil {
+ log.Printf("NFC release: %v", releaseError)
+ }
+ }
+ if !time.Now().Before(nextPing) {
+ seq++
+ pingCtx, pingDone := context.WithTimeout(ctx, 5*time.Second)
+ started := time.Now()
+ err := ch.CallWithContext(pingCtx, "org.bluez.GattCharacteristic1.WriteValue", 0, request(seq), map[string]dbus.Variant{"type": dbus.MakeVariant("request")}).Err
+ var reply []byte
+ if err == nil {
+ err = ch.CallWithContext(pingCtx, "org.bluez.GattCharacteristic1.ReadValue", 0, map[string]dbus.Variant{}).Store(&reply)
+ }
+ pingDone()
+ if err != nil {
+ return fmt.Errorf("PING %d: %w", seq, err)
+ }
+ if !validReply(reply, seq) {
+ return fmt.Errorf("PING %d: unexpected reply", seq)
+ }
+ log.Printf("BLE PONG reader=%s seq=%d round_trip=%s", address, seq, time.Since(started).Round(time.Millisecond))
+ nextPing = time.Now().Add(interval)
+ }
+ if !wait(ctx, 100*time.Millisecond) {
return nil
}
}
diff --git a/internal/blegateway/relay.go b/internal/blegateway/relay.go
new file mode 100644
index 0000000..45de6ce
--- /dev/null
+++ b/internal/blegateway/relay.go
@@ -0,0 +1,148 @@
+// SPDX-License-Identifier: Apache-2.0
+package blegateway
+
+import (
+ "context"
+ "encoding/binary"
+ "fmt"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+)
+
+const StatusUUID = "7a6b0003-5b21-4f36-8e5d-9c3a26d74210"
+const APDUUUID = "7a6b0004-5b21-4f36-8e5d-9c3a26d74210"
+const headerSize = 12
+const maxAPDU = 240
+
+type Card struct {
+ Session uint32
+ UID []byte
+ SAK byte
+}
+type Handler func(context.Context, Card, *Relay) error
+
+// The gateway goroutine serializes all requests, including PING and release.
+type Relay struct {
+ characteristic dbus.BusObject
+ session uint32
+ seq uint16
+ writeSize int
+}
+
+func packet(kind byte, session uint32, seq uint16, offset, total int, data []byte) []byte {
+ b := make([]byte, headerSize+len(data))
+ b[0] = 1
+ b[1] = kind
+ binary.BigEndian.PutUint32(b[2:], session)
+ binary.BigEndian.PutUint16(b[6:], seq)
+ binary.BigEndian.PutUint16(b[8:], uint16(offset))
+ binary.BigEndian.PutUint16(b[10:], uint16(total))
+ copy(b[12:], data)
+ return b
+}
+func parseCard(b []byte) (Card, error) {
+ if len(b) != 18 || b[0] != 1 || b[1] > 1 {
+ return Card{}, fmt.Errorf("invalid NFC status")
+ }
+ if b[1] == 0 {
+ return Card{}, nil
+ }
+ n := int(b[2])
+ if n != 4 && n != 7 && n != 10 {
+ return Card{}, fmt.Errorf("invalid NFC UID length")
+ }
+ sid := binary.BigEndian.Uint32(b[4:])
+ if sid == 0 {
+ return Card{}, fmt.Errorf("invalid NFC session")
+ }
+ return Card{Session: sid, SAK: b[3], UID: append([]byte(nil), b[8:8+n]...)}, nil
+}
+func parseResponse(b []byte, sid uint32, seq uint16) ([]byte, bool, error) {
+ if len(b) == 0 {
+ return nil, false, nil
+ }
+ if len(b) < headerSize || b[0] != 1 {
+ return nil, false, fmt.Errorf("invalid relay response")
+ }
+ if binary.BigEndian.Uint32(b[2:]) != sid || binary.BigEndian.Uint16(b[6:]) != seq {
+ return nil, false, nil
+ }
+ n := int(binary.BigEndian.Uint16(b[10:]))
+ if n > maxAPDU || len(b) != headerSize+n || binary.BigEndian.Uint16(b[8:]) != 0 {
+ return nil, false, fmt.Errorf("invalid relay response length")
+ }
+ switch b[1] {
+ case 0x10:
+ if n != 0 {
+ return nil, false, fmt.Errorf("invalid pending response")
+ }
+ return nil, false, nil
+ case 0x11:
+ if n < 2 {
+ return nil, false, fmt.Errorf("missing APDU status")
+ }
+ return append([]byte(nil), b[headerSize:]...), true, nil
+ case 0x12:
+ if n != 1 {
+ return nil, false, fmt.Errorf("invalid relay error")
+ }
+ return nil, false, fmt.Errorf("PN532 exchange failed (code %d)", b[headerSize])
+ default:
+ return nil, false, fmt.Errorf("unknown relay response")
+ }
+}
+func (r *Relay) write(ctx context.Context, b []byte) error {
+ return r.characteristic.CallWithContext(ctx, "org.bluez.GattCharacteristic1.WriteValue", 0, b, map[string]dbus.Variant{"type": dbus.MakeVariant("request")}).Err
+}
+func (r *Relay) Exchange(ctx context.Context, apdu []byte) ([]byte, error) {
+ if len(apdu) < 4 || len(apdu) > maxAPDU {
+ return nil, fmt.Errorf("APDU length outside 4..%d", maxAPDU)
+ }
+ if r.seq == 65535 {
+ return nil, fmt.Errorf("APDU sequence exhausted")
+ }
+ r.seq++
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ limit := r.writeSize - headerSize
+ if limit < 1 {
+ return nil, fmt.Errorf("BLE MTU too small")
+ }
+ for off := 0; off < len(apdu); {
+ end := off + limit
+ if end > len(apdu) {
+ end = len(apdu)
+ }
+ if err := r.write(ctx, packet(1, r.session, r.seq, off, len(apdu), apdu[off:end])); err != nil {
+ return nil, err
+ }
+ off = end
+ }
+ for ctx.Err() == nil {
+ var b []byte
+ if err := r.characteristic.CallWithContext(ctx, "org.bluez.GattCharacteristic1.ReadValue", 0, map[string]dbus.Variant{}).Store(&b); err != nil {
+ return nil, err
+ }
+ data, ready, err := parseResponse(b, r.session, r.seq)
+ if err != nil || ready {
+ return data, err
+ }
+ if !wait(ctx, 20*time.Millisecond) {
+ break
+ }
+ }
+ return nil, ctx.Err()
+}
+func (r *Relay) Release(ctx context.Context) error {
+ return r.write(ctx, packet(2, r.session, 0, 0, 0, nil))
+}
+func readCard(ctx context.Context, ch dbus.BusObject) (Card, error) {
+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ var b []byte
+ if err := ch.CallWithContext(ctx, "org.bluez.GattCharacteristic1.ReadValue", 0, map[string]dbus.Variant{}).Store(&b); err != nil {
+ return Card{}, err
+ }
+ return parseCard(b)
+}
diff --git a/internal/blegateway/relay_test.go b/internal/blegateway/relay_test.go
new file mode 100644
index 0000000..6fe309f
--- /dev/null
+++ b/internal/blegateway/relay_test.go
@@ -0,0 +1,44 @@
+package blegateway
+
+import (
+ "encoding/binary"
+ "testing"
+)
+
+func TestRelayResponses(t *testing.T) {
+ sid := uint32(0x12345678)
+ seq := uint16(8)
+ good := packet(0x11, sid, seq, 0, 2, []byte{0x90, 0})
+ data, ready, e := parseResponse(good, sid, seq)
+ if e != nil || !ready || len(data) != 2 {
+ t.Fatal("valid response rejected")
+ }
+ for _, b := range [][]byte{packet(0x11, sid-1, seq, 0, 2, []byte{0x90, 0}), packet(0x11, sid, seq-1, 0, 2, []byte{0x90, 0}), packet(0x10, sid, seq, 0, 0, nil)} {
+ _, ready, e := parseResponse(b, sid, seq)
+ if e != nil || ready {
+ t.Fatal("stale or pending response accepted")
+ }
+ }
+ for _, b := range [][]byte{good[:13], packet(0x11, sid, seq, 1, 2, []byte{0x90, 0}), packet(0x12, sid, seq, 0, 1, []byte{3}), packet(0x11, sid, seq, 0, 1, []byte{0x90})} {
+ if _, _, e := parseResponse(b, sid, seq); e == nil {
+ t.Fatal("malformed/error response accepted")
+ }
+ }
+}
+func TestCardSession(t *testing.T) {
+ b := make([]byte, 18)
+ b[0] = 1
+ b[1] = 1
+ b[2] = 4
+ b[3] = 0x20
+ binary.BigEndian.PutUint32(b[4:], 123)
+ copy(b[8:], []byte{1, 2, 3, 4})
+ c, e := parseCard(b)
+ if e != nil || c.Session != 123 || len(c.UID) != 4 || c.SAK != 0x20 {
+ t.Fatal("card status rejected")
+ }
+ b[2] = 11
+ if _, e := parseCard(b); e == nil {
+ t.Fatal("bad UID accepted")
+ }
+}
diff --git a/internal/nfcauth/auth.go b/internal/nfcauth/auth.go
new file mode 100644
index 0000000..c3954d1
--- /dev/null
+++ b/internal/nfcauth/auth.go
@@ -0,0 +1,258 @@
+// SPDX-License-Identifier: Apache-2.0
+// Home Key NFC authentication, based on kormax/apple-home-key-reader;
+// see NOTICE. This implementation uses the STANDARD flow for enrolled devices.
+package nfcauth
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdh"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "log"
+ "math/big"
+
+ "golang.org/x/crypto/hkdf"
+ "homekey.local/provisioner/internal/provision"
+)
+
+type Transport interface {
+ Exchange(context.Context, []byte) ([]byte, error)
+}
+type Result struct {
+ IssuerID, EndpointID, PublicKey string
+ PersistentKey []byte
+}
+
+func tlv(tag byte, value []byte) []byte {
+ b := []byte{tag}
+ if len(value) < 128 {
+ b = append(b, byte(len(value)))
+ } else {
+ b = append(b, 0x81, byte(len(value)))
+ }
+ return append(b, value...)
+}
+
+// Authentication messages use BER-TLV, not HAP's TLV8.
+func fields(data []byte) (map[byte][]byte, error) {
+ f := map[byte][]byte{}
+ for len(data) > 0 {
+ if len(data) < 2 {
+ return nil, fmt.Errorf("truncated BER-TLV")
+ }
+ tag, n := data[0], int(data[1])
+ data = data[2:]
+ if tag&0x1f == 0x1f {
+ return nil, fmt.Errorf("unsupported BER-TLV tag")
+ }
+ if n == 0x81 {
+ if len(data) < 1 {
+ return nil, fmt.Errorf("truncated BER length")
+ }
+ n = int(data[0])
+ data = data[1:]
+ if n < 128 {
+ return nil, fmt.Errorf("noncanonical BER length")
+ }
+ } else if n >= 128 {
+ return nil, fmt.Errorf("unsupported BER length")
+ }
+ if n > len(data) {
+ return nil, fmt.Errorf("truncated BER value")
+ }
+ if _, ok := f[tag]; ok {
+ return nil, fmt.Errorf("duplicate BER tag %02x", tag)
+ }
+ f[tag] = append([]byte(nil), data[:n]...)
+ data = data[n:]
+ }
+ return f, nil
+}
+func checked(ctx context.Context, t Transport, name string, cmd []byte) ([]byte, error) {
+ r, e := t.Exchange(ctx, cmd)
+ if e != nil {
+ return nil, fmt.Errorf("%s: %w", name, e)
+ }
+ if len(r) < 2 {
+ return nil, fmt.Errorf("%s: missing status word", name)
+ }
+ if !bytes.Equal(r[len(r)-2:], []byte{0x90, 0}) {
+ return nil, fmt.Errorf("%s: status %02x%02x", name, r[len(r)-2], r[len(r)-1])
+ }
+ return r[:len(r)-2], nil
+}
+func command(ins, p1, p2 byte, data []byte) []byte {
+ return append([]byte{0x80, ins, p1, p2, byte(len(data))}, data...)
+}
+func transcript(id, deviceX, readerX, nonce, constant []byte) []byte {
+ b := tlv(0x4d, id)
+ b = append(b, tlv(0x86, deviceX)...)
+ b = append(b, tlv(0x87, readerX)...)
+ b = append(b, tlv(0x4c, nonce)...)
+ return append(b, tlv(0x93, constant)...)
+}
+func derive(shared, nonce, readerX, deviceX, versions []byte, contextName string, n int) ([]byte, error) {
+ // ANSI X9.63 SHA-256, 32 bytes: SHA256(Z || counter BE32 || nonce).
+ h := sha256.New()
+ h.Write(shared)
+ h.Write([]byte{0, 0, 0, 1})
+ h.Write(nonce)
+ key := h.Sum(nil)
+ info := append([]byte(nil), readerX...)
+ info = append(info, deviceX...)
+ info = append(info, nonce...)
+ info = append(info, 0x5e, 0, 1)
+ info = append(info, []byte(contextName)...)
+ info = append(info, tlv(0x5c, []byte{2, 0})...)
+ info = append(info, tlv(0x5c, versions)...)
+ out := make([]byte, n)
+ _, e := io.ReadFull(hkdf.New(sha256.New, key, nil, info), out)
+ return out, e
+}
+
+// Authenticate proves possession of an ACTIVE, already-enrolled device key.
+// A successful SELECT, ECDH or 9000 status alone never grants access.
+func Authenticate(ctx context.Context, t Transport, c provision.Credentials) (*Result, error) {
+ scalar, e := hex.DecodeString(c.ReaderPrivateKey)
+ if e != nil || len(scalar) != 32 {
+ return nil, fmt.Errorf("reader is not provisioned")
+ }
+ unique, e := hex.DecodeString(c.ReaderIdentifier)
+ if e != nil || len(unique) != 8 {
+ return nil, fmt.Errorf("invalid reader identifier")
+ }
+ d := new(big.Int).SetBytes(scalar)
+ curve := elliptic.P256()
+ if d.Sign() == 0 || d.Cmp(curve.Params().N) >= 0 {
+ return nil, fmt.Errorf("invalid reader scalar")
+ }
+ x, y := curve.ScalarBaseMult(scalar)
+ reader := &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: curve, X: x, Y: y}, D: d}
+ groupHash := sha256.Sum256(append([]byte("key-identifier"), scalar...))
+ id := append(append([]byte(nil), groupHash[:8]...), unique...)
+ selected, e := checked(ctx, t, "SELECT", []byte{0, 0xa4, 4, 0, 7, 0xa0, 0, 0, 8, 0x58, 1, 1, 0})
+ if e != nil {
+ return nil, e
+ }
+ sf, e := fields(selected)
+ if e != nil {
+ return nil, e
+ }
+ versions := sf[0x5c]
+ if len(versions) == 0 || len(versions)%2 != 0 {
+ return nil, fmt.Errorf("invalid supported versions")
+ }
+ supported := false
+ for i := 0; i < len(versions); i += 2 {
+ if bytes.Equal(versions[i:i+2], []byte{2, 0}) {
+ supported = true
+ }
+ }
+ if !supported {
+ return nil, fmt.Errorf("Home Key protocol 2.0 not supported")
+ }
+ log.Print("Home Key SELECT accepted; protocol=2.0")
+ eph, e := ecdh.P256().GenerateKey(rand.Reader)
+ if e != nil {
+ return nil, e
+ }
+ nonce := make([]byte, 16)
+ if _, e = rand.Read(nonce); e != nil {
+ return nil, e
+ }
+ pub := eph.PublicKey().Bytes()
+ data := tlv(0x5c, []byte{2, 0})
+ data = append(data, tlv(0x87, pub)...)
+ data = append(data, tlv(0x4c, nonce)...)
+ data = append(data, tlv(0x4d, id)...)
+ // STANDARD flag 0, UNLOCK transaction code 1.
+ auth0, e := checked(ctx, t, "AUTH0", command(0x80, 0, 1, data))
+ if e != nil {
+ return nil, e
+ }
+ af, e := fields(auth0)
+ if e != nil {
+ return nil, e
+ }
+ devicePub, e := ecdh.P256().NewPublicKey(af[0x86])
+ if e != nil {
+ return nil, fmt.Errorf("AUTH0: invalid device ephemeral key")
+ }
+ log.Print("Home Key AUTH0 key exchange complete")
+ shared, e := eph.ECDH(devicePub)
+ if e != nil {
+ return nil, e
+ }
+ deviceX := devicePub.Bytes()[1:33]
+ readerX := pub[1:33]
+ digest := sha256.Sum256(transcript(id, deviceX, readerX, nonce, []byte{0x41, 0x5d, 0x95, 0x69}))
+ r, s, e := ecdsa.Sign(rand.Reader, reader, digest[:])
+ if e != nil {
+ return nil, e
+ }
+ signature := make([]byte, 64)
+ r.FillBytes(signature[:32])
+ s.FillBytes(signature[32:])
+ encrypted, e := checked(ctx, t, "AUTH1", command(0x81, 0, 0, tlv(0x9e, signature)))
+ if e != nil {
+ return nil, e
+ }
+ keys, e := derive(shared, nonce, readerX, deviceX, versions, "Volatile", 48)
+ if e != nil {
+ return nil, e
+ }
+ plain, e := decryptResponse(encrypted, keys[:16], keys[32:], 0)
+ if e != nil {
+ return nil, fmt.Errorf("AUTH1: %w", e)
+ }
+ df, e := fields(plain)
+ if e != nil {
+ return nil, e
+ }
+ if len(df[0x4e]) != 6 || len(df[0x9e]) != 64 {
+ return nil, fmt.Errorf("AUTH1: invalid endpoint proof")
+ }
+ eid := hex.EncodeToString(df[0x4e])
+ proof := df[0x9e]
+ digest = sha256.Sum256(transcript(id, deviceX, readerX, nonce, []byte{0x4e, 0x88, 0x7b, 0x4c}))
+ for iid, issuer := range c.Issuers {
+ ep, ok := issuer.Endpoints[eid]
+ if !ok || ep.KeyState != 1 || ep.KeyType != 2 {
+ continue
+ }
+ raw, e := hex.DecodeString(ep.PublicKey)
+ if e != nil {
+ continue
+ }
+ x, y := elliptic.Unmarshal(curve, raw)
+ if x == nil {
+ continue
+ }
+ if !ecdsa.Verify(&ecdsa.PublicKey{Curve: curve, X: x, Y: y}, digest[:], new(big.Int).SetBytes(proof[:32]), new(big.Int).SetBytes(proof[32:])) {
+ continue
+ }
+ persistent, e := derive(shared, nonce, readerX, deviceX, versions, "Persistent", 32)
+ if e != nil {
+ return nil, e
+ }
+ return &Result{IssuerID: iid, EndpointID: eid, PublicKey: ep.PublicKey, PersistentKey: persistent}, nil
+ }
+ return nil, fmt.Errorf("AUTH1: unknown, inactive or invalid endpoint")
+}
+
+// Finish controls phone UX. The caller must recheck current authorization
+// before sending success. Failure is also sent when authentication is rejected.
+func Finish(ctx context.Context, t Transport, success bool) error {
+ p1 := byte(0)
+ if success {
+ p1 = 1
+ }
+ _, e := checked(ctx, t, "CONTROL FLOW", []byte{0x80, 0x3c, p1, 0})
+ return e
+}
diff --git a/internal/nfcauth/auth_test.go b/internal/nfcauth/auth_test.go
new file mode 100644
index 0000000..07d536f
--- /dev/null
+++ b/internal/nfcauth/auth_test.go
@@ -0,0 +1,221 @@
+package nfcauth
+
+import (
+ "bytes"
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/ecdh"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/sha1"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "math/big"
+ "os"
+ "testing"
+
+ "homekey.local/provisioner/internal/provision"
+)
+
+func hx(s string) []byte { b, _ := hex.DecodeString(s); return b }
+func TestIndependentVectors(t *testing.T) {
+ b, e := os.ReadFile("testdata/standard.json")
+ if e != nil {
+ t.Fatal(e)
+ }
+ var f map[string]string
+ if e = json.Unmarshal(b, &f); e != nil {
+ t.Fatal(e)
+ }
+ for _, v := range []struct {
+ name, key string
+ n int
+ }{{"Volatile", "volatile", 48}, {"Persistent", "persistent", 32}} {
+ got, e := derive(hx(f["shared"]), hx(f["nonce"]), hx(f["reader_pub"])[1:33], hx(f["device_pub"])[1:33], hx(f["versions"]), v.name, v.n)
+ if e != nil || !bytes.Equal(got, hx(f[v.key])) {
+ t.Fatalf("%s vector mismatch: %v", v.name, e)
+ }
+ }
+ keys := hx(f["volatile"])
+ got, e := decryptResponse(hx(f["response"]), keys[:16], keys[32:], 0)
+ if e != nil || !bytes.Equal(got, hx(f["plaintext"])) {
+ t.Fatalf("secure response vector: %v", e)
+ }
+ for i := range hx(f["response"]) {
+ bad := hx(f["response"])
+ bad[i] ^= 1
+ if _, e := decryptResponse(bad, keys[:16], keys[32:], 0); e == nil {
+ t.Fatalf("tampered byte %d accepted", i)
+ }
+ }
+}
+func TestCMACRFC4493(t *testing.T) {
+ key := hx("2b7e151628aed2a6abf7158809cf4f3c")
+ for _, v := range []struct{ data, mac string }{
+ {"", "bb1d6929e95937287fa37d129b756746"},
+ {"6bc1bee22e409f96e93d7e117393172a", "070a16b46b4d4144f79bdd9dd04a287c"},
+ {"6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411", "dfa66747de9ae63030ca32611497c827"},
+ } {
+ got, e := cmac(key, hx(v.data))
+ if e != nil || !bytes.Equal(got, hx(v.mac)) {
+ t.Fatalf("CMAC vector failed: %v", e)
+ }
+ }
+}
+
+// A phone simulator exercises reader proof, response MAC and endpoint proof.
+// The independent vectors above separately verify KDF and encryption framing.
+type phone struct {
+ t *testing.T
+ creds provision.Credentials
+ key *ecdsa.PrivateKey
+ eph *ecdh.PrivateKey
+ auth0 map[byte][]byte
+ step int
+ mode string
+}
+
+func (p *phone) Exchange(ctx context.Context, cmd []byte) ([]byte, error) {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ p.step++
+ ok := func(b []byte) []byte { return append(b, 0x90, 0) }
+ switch p.step {
+ case 1:
+ if !bytes.Equal(cmd, []byte{0, 0xa4, 4, 0, 7, 0xa0, 0, 0, 8, 0x58, 1, 1, 0}) {
+ p.t.Fatal("wrong SELECT")
+ }
+ if p.mode == "status" {
+ return []byte{0x6a, 0x82}, nil
+ }
+ return ok(tlv(0x5c, []byte{2, 0, 1, 0})), nil
+ case 2:
+ if len(cmd) < 5 || !bytes.Equal(cmd[:4], []byte{0x80, 0x80, 0, 1}) || int(cmd[4]) != len(cmd)-5 {
+ p.t.Fatal("wrong AUTH0 framing")
+ }
+ f, e := fields(cmd[5:])
+ if e != nil {
+ return nil, e
+ }
+ p.auth0 = f
+ scalar := hx(p.creds.ReaderPrivateKey)
+ h := sha256.Sum256(append([]byte("key-identifier"), scalar...))
+ id := append(h[:8], hx(p.creds.ReaderIdentifier)...)
+ if !bytes.Equal(f[0x4d], id) || len(f[0x4c]) != 16 {
+ p.t.Fatal("wrong reader ID or nonce")
+ }
+ if p.mode == "bad-point" {
+ return ok(tlv(0x86, make([]byte, 65))), nil
+ }
+ p.eph, e = ecdh.P256().GenerateKey(rand.Reader)
+ if e != nil {
+ return nil, e
+ }
+ return ok(tlv(0x86, p.eph.PublicKey().Bytes())), nil
+ case 3:
+ if !bytes.Equal(cmd[:4], []byte{0x80, 0x81, 0, 0}) {
+ p.t.Fatal("wrong AUTH1 framing")
+ }
+ f, e := fields(cmd[5:])
+ if e != nil {
+ return nil, e
+ }
+ a := p.auth0
+ dx := p.eph.PublicKey().Bytes()[1:33]
+ rx := a[0x87][1:33]
+ scalar := hx(p.creds.ReaderPrivateKey)
+ x, y := elliptic.P256().ScalarBaseMult(scalar)
+ digest := sha256.Sum256(transcript(a[0x4d], dx, rx, a[0x4c], []byte{0x41, 0x5d, 0x95, 0x69}))
+ sig := f[0x9e]
+ if len(sig) != 64 || !ecdsa.Verify(&ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, digest[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) {
+ p.t.Fatal("reader proof invalid")
+ }
+ devicePub := elliptic.Marshal(elliptic.P256(), p.key.X, p.key.Y)
+ eid := sha1.Sum(devicePub)
+ digest = sha256.Sum256(transcript(a[0x4d], dx, rx, a[0x4c], []byte{0x4e, 0x88, 0x7b, 0x4c}))
+ r, s, e := ecdsa.Sign(rand.Reader, p.key, digest[:])
+ if e != nil {
+ return nil, e
+ }
+ proof := make([]byte, 64)
+ r.FillBytes(proof[:32])
+ s.FillBytes(proof[32:])
+ if p.mode == "bad-signature" {
+ proof[0] ^= 1
+ }
+ plain := append(tlv(0x4e, eid[:6]), tlv(0x9e, proof)...)
+ readerEph, e := ecdh.P256().NewPublicKey(a[0x87])
+ if e != nil {
+ return nil, e
+ }
+ shared, e := p.eph.ECDH(readerEph)
+ if e != nil {
+ return nil, e
+ }
+ keys, e := derive(shared, a[0x4c], rx, dx, []byte{2, 0, 1, 0}, "Volatile", 48)
+ if e != nil {
+ return nil, e
+ }
+ block, _ := aes.NewCipher(keys[:16])
+ iv := make([]byte, 16)
+ pcb := make([]byte, 16)
+ pcb[0] = 0x80
+ block.Encrypt(iv, pcb)
+ plain = append(plain, 0x80)
+ for len(plain)%16 != 0 {
+ plain = append(plain, 0)
+ }
+ encrypted := make([]byte, len(plain))
+ cipher.NewCBCEncrypter(block, iv).CryptBlocks(encrypted, plain)
+ mac, _ := cmac(keys[32:], append(make([]byte, 16), encrypted...))
+ out := append(encrypted, mac[:8]...)
+ if p.mode == "bad-mac" {
+ out[len(out)-1] ^= 1
+ }
+ return ok(out), nil
+ }
+ return nil, fmt.Errorf("unexpected command")
+}
+func TestAuthenticate(t *testing.T) {
+ for _, mode := range []string{"valid", "inactive", "unknown", "bad-point", "bad-signature", "bad-mac", "status"} {
+ t.Run(mode, func(t *testing.T) {
+ reader, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ device, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ scalar := make([]byte, 32)
+ reader.D.FillBytes(scalar)
+ raw := elliptic.Marshal(elliptic.P256(), device.X, device.Y)
+ id := sha1.Sum(raw)
+ eid := hex.EncodeToString(id[:6])
+ ep := provision.Endpoint{PublicKey: hex.EncodeToString(raw), KeyType: 2, KeyState: 1}
+ if mode == "inactive" {
+ ep.KeyState = 0
+ }
+ endpoints := map[string]provision.Endpoint{eid: ep}
+ if mode == "unknown" {
+ endpoints = map[string]provision.Endpoint{}
+ }
+ c := provision.Credentials{ReaderPrivateKey: hex.EncodeToString(scalar), ReaderIdentifier: "0102030405060708", Issuers: map[string]provision.Issuer{"issuer": {Endpoints: endpoints}}}
+ p := &phone{t: t, creds: c, key: device, mode: mode}
+ result, e := Authenticate(context.Background(), p, c)
+ if mode == "valid" {
+ if e != nil || result == nil || result.EndpointID != eid || len(result.PersistentKey) != 32 {
+ t.Fatalf("valid auth failed: %v", e)
+ }
+ } else if e == nil || result != nil {
+ t.Fatalf("%s accepted", mode)
+ }
+ })
+ }
+}
+func TestBERRejectsMalformed(t *testing.T) {
+ for _, b := range [][]byte{{0x5c}, {0x5c, 4, 2, 0}, {0x5c, 0x80}, {0x5c, 0x81, 2, 2, 0}, {0x5c, 2, 2, 0, 0x5c, 2, 1, 0}} {
+ if _, e := fields(b); e == nil {
+ t.Fatalf("accepted malformed BER %x", b)
+ }
+ }
+}
diff --git a/internal/nfcauth/secure.go b/internal/nfcauth/secure.go
new file mode 100644
index 0000000..9773c6b
--- /dev/null
+++ b/internal/nfcauth/secure.go
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: Apache-2.0
+package nfcauth
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/subtle"
+ "fmt"
+)
+
+func xor(a, b []byte) []byte {
+ r := make([]byte, len(a))
+ for i := range a {
+ r[i] = a[i] ^ b[i]
+ }
+ return r
+}
+func double(b []byte) []byte {
+ r := make([]byte, 16)
+ carry := byte(0)
+ for i := 15; i >= 0; i-- {
+ r[i] = (b[i] << 1) | carry
+ carry = b[i] >> 7
+ }
+ if carry != 0 {
+ r[15] ^= 0x87
+ }
+ return r
+}
+
+// RFC 4493 AES-CMAC.
+func cmac(key, data []byte) ([]byte, error) {
+ block, e := aes.NewCipher(key)
+ if e != nil {
+ return nil, e
+ }
+ l := make([]byte, 16)
+ block.Encrypt(l, l)
+ k1 := double(l)
+ k2 := double(k1)
+ n := (len(data) + 15) / 16
+ if n == 0 {
+ n = 1
+ }
+ last := make([]byte, 16)
+ if len(data) > 0 && len(data)%16 == 0 {
+ copy(last, data[(n-1)*16:])
+ last = xor(last, k1)
+ } else {
+ remaining := data[(n-1)*16:]
+ copy(last, remaining)
+ last[len(remaining)] = 0x80
+ last = xor(last, k2)
+ }
+ state := make([]byte, 16)
+ for i := 0; i < n-1; i++ {
+ block.Encrypt(state, xor(state, data[i*16:i*16+16]))
+ }
+ block.Encrypt(state, xor(state, last))
+ return state, nil
+}
+func decryptResponse(data, kenc, krmac []byte, counter byte) ([]byte, error) {
+ if len(data) < 24 || (len(data)-8)%16 != 0 {
+ return nil, fmt.Errorf("invalid secure response length")
+ }
+ encrypted, mac := data[:len(data)-8], data[len(data)-8:]
+ full, e := cmac(krmac, append(make([]byte, 16), encrypted...))
+ if e != nil {
+ return nil, e
+ }
+ if subtle.ConstantTimeCompare(mac, full[:8]) != 1 {
+ return nil, fmt.Errorf("response MAC verification failed")
+ }
+ block, e := aes.NewCipher(kenc)
+ if e != nil {
+ return nil, e
+ }
+ pcb := make([]byte, 16)
+ pcb[0] = 0x80
+ pcb[15] = counter
+ iv := make([]byte, 16)
+ block.Encrypt(iv, pcb)
+ plain := make([]byte, len(encrypted))
+ cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, encrypted)
+ i := len(plain) - 1
+ for i >= 0 && plain[i] == 0 {
+ i--
+ }
+ if i < 0 || plain[i] != 0x80 || len(plain)-i > 16 {
+ return nil, fmt.Errorf("invalid secure response padding")
+ }
+ return plain[:i], nil
+}
diff --git a/internal/nfcauth/testdata/standard.json b/internal/nfcauth/testdata/standard.json
new file mode 100644
index 0000000..824c17a
--- /dev/null
+++ b/internal/nfcauth/testdata/standard.json
@@ -0,0 +1,11 @@
+{
+ "shared": "b01a172a76a4602c92d3242cb897dde3024c740debb215b4c6b0aae93c2291a9",
+ "nonce": "000102030405060708090a0b0c0d0e0f",
+ "reader_pub": "047cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc4766997807775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1",
+ "device_pub": "045ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d5032",
+ "versions": "02000100",
+ "volatile": "d3b272b17f0a69bbf46fa25eee2cf45dab03ffc8323bca190891b39e9876094ea41c4b27018197c6ce5b9675063792dd",
+ "persistent": "4c989fef14e639852dea449d67d60799d3be7aac0d5329b77bc5147acf82dc9c",
+ "plaintext": "4e061122334455669e40000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
+ "response": "28b75b30f9a60d33b15c2d103b2f9d17412585326df6c428dd483c5fd8e9442403c9e3c77f18fde5b790f8935b64d8c170f50ceb0d05fe205b919096a4a50e5ade38a094aa070f29e0a778e22138f716ca24a6c74d4d292b"
+}
\ No newline at end of file
diff --git a/internal/provision/auth.go b/internal/provision/auth.go
new file mode 100644
index 0000000..e9c0304
--- /dev/null
+++ b/internal/provision/auth.go
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: Apache-2.0
+package provision
+
+import (
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+)
+
+// AuthenticationCredentials returns an isolated snapshot; never log it.
+func (s *Store) AuthenticationCredentials() Credentials {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ b, _ := json.Marshal(s.data.HomeKey)
+ var c Credentials
+ _ = json.Unmarshal(b, &c)
+ return c
+}
+func authorized(current, expected Credentials, iid, eid, pub string) bool {
+ if current.ReaderPrivateKey != expected.ReaderPrivateKey || current.ReaderIdentifier != expected.ReaderIdentifier {
+ return false
+ }
+ issuer, ok := current.Issuers[iid]
+ if !ok {
+ return false
+ }
+ ep, ok := issuer.Endpoints[eid]
+ return ok && ep.PublicKey == pub && ep.KeyState == 1 && ep.KeyType == 2
+}
+func (s *Store) SaveAuthenticatedKey(expected Credentials, iid, eid, pub string, key []byte) error {
+ if len(key) != 32 {
+ return fmt.Errorf("invalid persistent key")
+ }
+ return s.update(func(d *Data) error {
+ if !authorized(d.HomeKey, expected, iid, eid, pub) {
+ return fmt.Errorf("credentials revoked or changed during authentication")
+ }
+ issuer := d.HomeKey.Issuers[iid]
+ ep := issuer.Endpoints[eid]
+ ep.PersistentKey = hex.EncodeToString(key)
+ issuer.Endpoints[eid] = ep
+ d.HomeKey.Issuers[iid] = issuer
+ return nil
+ })
+}
+
+// WithAuthorization serializes the final unlock with HomeKit revocations.
+// The callback must not call back into Store.
+func (s *Store) WithAuthorization(expected Credentials, iid, eid, pub string, fn func() error) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if !authorized(s.data.HomeKey, expected, iid, eid, pub) {
+ return fmt.Errorf("credentials revoked or changed before unlock")
+ }
+ return fn()
+}
diff --git a/internal/provision/auth_test.go b/internal/provision/auth_test.go
new file mode 100644
index 0000000..fb16290
--- /dev/null
+++ b/internal/provision/auth_test.go
@@ -0,0 +1,85 @@
+package provision
+
+import (
+ "bytes"
+ "testing"
+)
+
+func TestAuthenticationPersistenceAndRevocation(t *testing.T) {
+ s, dir, iid := paired(t)
+ if _, e := s.Handle(readerAdd()); e != nil {
+ t.Fatal(e)
+ }
+ if _, e := s.Handle(endpointAdd(iid)); e != nil {
+ t.Fatal(e)
+ }
+ c := s.AuthenticationCredentials()
+ var eid, pub string
+ for id, ep := range c.Issuers[iid].Endpoints {
+ eid = id
+ pub = ep.PublicKey
+ }
+ key := bytes.Repeat([]byte{7}, 32)
+ if e := s.SaveAuthenticatedKey(c, iid, eid, pub, key); e != nil {
+ t.Fatal(e)
+ }
+ restored, e := Open(dir)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if restored.AuthenticationCredentials().Issuers[iid].Endpoints[eid].PersistentKey == "" {
+ t.Fatal("persistent key lost")
+ }
+ // Mutating a snapshot must never modify the live credential store.
+ delete(c.Issuers, iid)
+ c = s.AuthenticationCredentials()
+ calls := 0
+ if e := s.WithAuthorization(c, iid, eid, pub, func() error { calls++; return nil }); e != nil {
+ t.Fatal(e)
+ }
+ if e := s.Delete("controller.pairing"); e != nil {
+ t.Fatal(e)
+ }
+ if e := s.SaveAuthenticatedKey(c, iid, eid, pub, key); e == nil {
+ t.Fatal("revoked credential persisted")
+ }
+ if e := s.WithAuthorization(c, iid, eid, pub, func() error { calls++; return nil }); e == nil {
+ t.Fatal("revoked credential unlocked")
+ }
+ if calls != 1 {
+ t.Fatal("callback ran after revocation")
+ }
+}
+func TestAuthenticationRejectsReaderChangeAndInactive(t *testing.T) {
+ for _, mode := range []string{"reader", "inactive"} {
+ t.Run(mode, func(t *testing.T) {
+ s, _, iid := paired(t)
+ s.Handle(readerAdd())
+ s.Handle(endpointAdd(iid))
+ c := s.AuthenticationCredentials()
+ var eid, pub string
+ for id, ep := range c.Issuers[iid].Endpoints {
+ eid = id
+ pub = ep.PublicKey
+ }
+ e := s.update(func(d *Data) error {
+ if mode == "reader" {
+ d.HomeKey.ReaderIdentifier = "0908070605040302"
+ } else {
+ i := d.HomeKey.Issuers[iid]
+ ep := i.Endpoints[eid]
+ ep.KeyState = 0
+ i.Endpoints[eid] = ep
+ d.HomeKey.Issuers[iid] = i
+ }
+ return nil
+ })
+ if e != nil {
+ t.Fatal(e)
+ }
+ if e = s.WithAuthorization(c, iid, eid, pub, func() error { t.Fatal("unauthorized callback"); return nil }); e == nil {
+ t.Fatal("changed credentials accepted")
+ }
+ })
+ }
+}
diff --git a/scripts/stage-ha-app.sh b/scripts/stage-ha-app.sh
index c6e4553..6352eec 100755
--- a/scripts/stage-ha-app.sh
+++ b/scripts/stage-ha-app.sh
@@ -30,7 +30,7 @@ cp "$root/ha-app/run.sh" "$dest/ha-app/run.sh"
sed '/^image:/d' "$root/ha-app/config.yaml" > "$dest/config.yaml"
cp "$root/ha-app/DOCS.md" "$root/ha-app/README.md" \
- "$root/ha-app/CHANGELOG.md" "$dest/"
+ "$root/ha-app/CHANGELOG.md" "$root/ha-app/NFC.md" "$dest/"
cp -R "$root/ha-app/translations" "$dest/"