penguin/homekey-go

homekey-go

commit 1c7c6756abaed1e6b645ee73dcf3eef7148bc1bc

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-05T02:28:20Z
subjectAdded 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/"