penguin/homekey-go

homekey-go

commit 6bed6702d67c8a0fed0c0f2e8a90c2ddaa6a871c

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-04T03:15:45Z
subjectInitial commit
commit 6bed6702d67c8a0fed0c0f2e8a90c2ddaa6a871c
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-04T03:15:45Z

    Initial commit
---
 .gitignore                           |   3 +
 LICENSE                              | 201 ++++++++++++++++++++++++
 Makefile                             |  16 ++
 NOTICE                               |  22 +++
 README.md                            | 197 ++++++++++++++++++++++++
 cmd/homekey/lock_unix.go             |  22 +++
 cmd/homekey/main.go                  | 126 +++++++++++++++
 go.mod                               |  25 +++
 go.sum                               | 112 ++++++++++++++
 internal/hkserver/server.go          | 103 +++++++++++++
 internal/hkserver/server_test.go     | 109 +++++++++++++
 internal/provision/control.go        | 189 +++++++++++++++++++++++
 internal/provision/provision_test.go | 193 +++++++++++++++++++++++
 internal/provision/store.go          | 287 +++++++++++++++++++++++++++++++++++
 internal/provision/tlv.go            |  57 +++++++
 15 files changed, 1662 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..afbd467
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/bin/
+/state/
+*.test
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..9d0d72b
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,16 @@
+GO ?= go
+.PHONY: all build test race vet run clean
+all: build
+build:
+	mkdir -p bin
+	$(GO) build -buildvcs=false -trimpath -o bin/homekey ./cmd/homekey
+test:
+	$(GO) test ./...
+race:
+	$(GO) test -race ./...
+vet:
+	$(GO) vet ./...
+run:
+	$(GO) run -buildvcs=false ./cmd/homekey
+clean:
+	rm -rf bin
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..a9975d9
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,22 @@
+Home Key provisioning in Go
+
+Home Key provisioning structures and operations are adapted from:
+  kormax/apple-home-key-reader
+  https://github.com/kormax/apple-home-key-reader
+  Commit 96754078121cfe1792a1e2c402873d105c8016e3
+  Apache License 2.0
+
+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;
+validated keys; atomic combined HAP/credential persistence; lifecycle cleanup;
+standalone virtual lock; Go tests and Makefile. NFC authentication is omitted.
+
+The bundled third_party/hap code is from github.com/brutella/hap v0.0.35,
+commit bc03ca629d44dd58570c2979843f9f677818e31c. Original license and source
+notices are retained. Locally modified files carry notices; the changes are
+recorded in patches/hap.patch. Local regression tests are in
+third_party/hap/homekey_patch_test.go. Generator, example and image assets
+not needed for building the library were omitted.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8fba45a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,197 @@
+# Home Key provisioning in Go
+
+Basically [kormax/apple-home-key-reader](https://github.com/kormax/apple-home-key-reader) in go.
+
+## Quick start on macOS
+
+```sh
+brew install go
+make
+./bin/homekey -name "Test Lock"
+```
+
+Go 1.23 or newer is required. The first build downloads the pinned Go modules.
+The executable prints a generated pairing code, for example `XXXX-XXXX`.
+It saves that code and its accessory identity for later runs.
+
+1. Put your iPhone and the computer running this program on the same LAN.
+2. Open **Home → + → Add Accessory → More Options** (wording can vary by iOS).
+3. Select **Penguin Door** and enter the printed pairing code.
+4. Accept the uncertified-accessory prompt if displayed and complete setup.
+5. Look for the Home Key setup/Express Mode flow and the resulting key in Wallet.
+
+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.
+
+## Linux
+
+Install Go 1.23+ through your usual package manager or the Go distribution:
+
+```sh
+make
+./bin/homekey -name "Penguin Door" -interface eth0
+```
+
+No Avahi daemon is required; the process advertises mDNS itself. For the first
+iPhone test, run directly on a machine on the phone's LAN. Containers and
+Kubernetes need additional multicast/network configuration; a published TCP
+port alone does not provide HomeKit discovery. Linux host networking is a
+possible container setup; host networking on Docker Desktop is not equivalent
+to a native macOS process for discovery.
+
+## Options
+
+```text
+-name       Name in Home (default: Go Home Key)
+-serial     Stable serial (default: GO-HOMEKEY-001)
+-state      Persistent directory (default: ./state)
+-pin        Eight-digit PIN, optionally with hyphens; generated if omitted
+-addr       HAP TCP listen address (default: :51826)
+-interface  LAN interface for mDNS; empty uses eligible interfaces
+-finish     silver, black, gold, or tan (default: silver)
+-status     Print credential counts and exit; safe while the server is running
+```
+
+The key artwork color may be determined by the first Home Key lock in the
+home, so changing `-finish` later may not change the existing Wallet artwork.
+
+On macOS an explicit interface might be `en0` or `en1`:
+
+```sh
+./bin/homekey -name "Penguin Door" -interface en0 -state ./state
+```
+
+Choose your actual LAN interface. Allow the executable through the firewall:
+TCP 51826 (or the port you select), and local mDNS UDP 5353. Keep this service
+on the LAN; HomeKit protocol traffic should not be put behind an HTTP reverse
+proxy or published as an Internet service. Discovery usually does not cross
+VLANs without an mDNS reflector and suitable routing.
+
+## What successful provisioning looks like
+
+Logs contain summaries such as:
+
+```text
+Provisioning exchange complete: reader=true issuers=1 endpoints=1
+```
+
+You can inspect the current counts without displaying any keys:
+
+```sh
+./bin/homekey -status -state ./state
+```
+
+Example:
+
+```json
+{"paired_controllers":1,"reader_provisioned":true,"issuers":1,"endpoints":1}
+```
+
+- `paired_controllers > 0`: HomeKit pairing completed.
+- `reader_provisioned = true`: the controller supplied the reader identity/key.
+- `endpoints > 0`: at least one phone/watch public credential was enrolled.
+- The actual Home Key appearing in Wallet is the final confirmation on iOS.
+
+## Persistence and lifecycle
+
+`state/state.json` contains **both HAP pairing secrets and Home Key credentials**.
+Keep the whole directory across restarts and back it up securely. Directory
+permissions are 0700 and state files are 0600. Writes use a synced temporary
+file plus atomic rename; directory syncing is best effort. A process lock
+prevents two servers from accidentally sharing the same state directory.
+
+The reader private key is provided during HomeKit provisioning; this program
+does not invent a separate key and expect Wallet to accept it. Issuer public
+keys are derived from HAP pairing records. Provisioned endpoint public keys
+are validated on P-256 and saved under the corresponding issuer.
+
+Removing a HAP pairing removes that issuer and its enrolled endpoints in the
+same state update. Removing the last pairing also clears the reader key.
+Re-adding an identical credential returns Duplicate. Disk errors reject the
+operation without updating live state. Corrupt saved state stops startup
+rather than silently losing the pairing.
+
+No private keys, credential TLVs or endpoint public keys are logged. The
+printed setup PIN is intentionally visible in the startup output. Do not
+turn on the HAP library's raw debug logging when using real credentials.
+
+To start over, first remove the accessory in Apple Home, stop the process,
+then **move the complete state directory to a backup location** and restart.
+That creates a new accessory identity. Do not delete state merely to restart.
+
+## Implemented protocol surface
+
+| Service / characteristic | Type | Behavior |
+| --- | --- | --- |
+| Accessory Information / Hardware Finish | 26C | Read-only Wallet finish TLV |
+| Lock Mechanism | 45 | Simulated current/target lock state |
+| Lock Management | 44 | Version; management commands return unsupported |
+| NFC Access | 266 | Home Key provisioning service |
+| Configuration State | 263 | Read/events, fixed 0 as in Python reference |
+| NFC Access Control Point | 264 | Read/write/write-response; nested TLV8 |
+| NFC Access Supported Configuration | 265 | Advertises 16 issuers and 16 credentials |
+
+Reader-key GET/ADD/REMOVE and endpoint ADD are implemented. Endpoint GET and
+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.
+
+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.
+
+## Build and test
+
+```sh
+make                 # bin/homekey
+make test            # application tests + relevant patched HAP tests
+make race            # same tests with Go race detection (requires C compiler)
+make vet
+make clean           # removes binaries; preserves credentials
+```
+
+The application builds without CGO. Linux amd64 was executed; macOS arm64 and
+amd64 were cross-compiled. Actual LAN discovery and Wallet setup on your
+Mac/iPhone remain to be verified.
+
+Tests cover TLV fragmentation and malformed input, reader/device provisioning,
+unknown issuers, invalid keys, duplicate writes, atomic persistence failure,
+restart, unpair cleanup, concurrent access, characteristic metadata, and
+rejection of unauthenticated HAP requests.
+
+An independent `aiohomekit` client was also used during development to verify
+SRP Pair Setup, Pair Verify, encrypted accessory discovery and provisioning,
+repeated requests, reconnect after server restart, and unpair/revocation.
+That client is a development test tool only and is not shipped or required.
+It does not emulate Apple's Wallet issuance service.
+
+## Dependencies and local patches
+
+`third_party/hap` contains `github.com/brutella/hap v0.0.35` at commit
+`bc03ca629d44dd58570c2979843f9f677818e31c`, with three focused changes recorded
+in `patches/hap.patch`:
+
+1. Opt-in stateless command characteristics: process repeated requests and
+   never retain the incoming key-bearing TLV as a characteristic value.
+2. Reject missing timed-write context instead of dereferencing nil.
+3. Flush the complete unpair response before closing the controller connection.
+
+The local `replace` in `go.mod` is intentional. **Keep `third_party/hap` with
+the project**; switching directly to the upstream module drops these fixes.
+Application tests plus selected HAP regression tests are run by `make test`.
+The upstream full test suite has an unrelated existing `TestConfigHash`
+service-ID reuse failure; this project does not claim it passes that suite.
+
+Protocol sources:
+- https://github.com/kormax/apple-home-key-reader (commit 96754078121cfe1792a1e2c402873d105c8016e3)
+- https://github.com/kupa22/apple-homekey
+- https://github.com/ikalchev/HAP-python (Home Key characteristic metadata)
+
+Apache-2.0; see LICENSE, NOTICE and the retained third-party license.
diff --git a/cmd/homekey/lock_unix.go b/cmd/homekey/lock_unix.go
new file mode 100644
index 0000000..713260d
--- /dev/null
+++ b/cmd/homekey/lock_unix.go
@@ -0,0 +1,22 @@
+//go:build linux || darwin || freebsd || openbsd || netbsd
+
+// SPDX-License-Identifier: Apache-2.0
+package main
+
+import (
+	"fmt"
+	"os"
+	"syscall"
+)
+
+func lockState(path string) (func(), error) {
+	f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
+	if err != nil {
+		return nil, err
+	}
+	if err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
+		f.Close()
+		return nil, fmt.Errorf("state directory is in use: stop the other process first")
+	}
+	return func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); _ = f.Close() }, nil
+}
diff --git a/cmd/homekey/main.go b/cmd/homekey/main.go
new file mode 100644
index 0000000..6e320fd
--- /dev/null
+++ b/cmd/homekey/main.go
@@ -0,0 +1,126 @@
+// SPDX-License-Identifier: Apache-2.0
+package main
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/json"
+	"errors"
+	"flag"
+	"fmt"
+	"log"
+	"math/big"
+	"net"
+	"net/http"
+	"os"
+	"os/signal"
+	"path/filepath"
+	"strings"
+	"syscall"
+
+	"github.com/brutella/hap"
+	"homekey.local/provisioner/internal/hkserver"
+	"homekey.local/provisioner/internal/provision"
+)
+
+func main() {
+	if err := run(); err != nil {
+		log.Print(err)
+		os.Exit(1)
+	}
+}
+func run() error {
+	name := flag.String("name", "Go Home Key", "name shown in Apple Home")
+	serial := flag.String("serial", "GO-HOMEKEY-001", "stable accessory serial number")
+	state := flag.String("state", "./state", "persistent state directory; keep across restarts")
+	pin := flag.String("pin", "", "8-digit pairing PIN (generated and saved if omitted)")
+	addr := flag.String("addr", ":51826", "HAP listen address")
+	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")
+	flag.Parse()
+	if flag.NArg() != 0 {
+		return fmt.Errorf("unexpected positional argument")
+	}
+	if err := os.MkdirAll(*state, 0700); err != nil {
+		return err
+	}
+	if err := os.Chmod(*state, 0700); err != nil {
+		return err
+	}
+	// Atomic state replacement permits a read-only summary while the server runs.
+	if *status {
+		store, err := provision.Open(*state)
+		if err != nil {
+			return err
+		}
+		return json.NewEncoder(os.Stdout).Encode(store.Summary())
+	}
+	unlock, err := lockState(filepath.Join(*state, ".lock"))
+	if err != nil {
+		return err
+	}
+	defer unlock()
+	store, err := provision.Open(*state)
+	if err != nil {
+		return err
+	}
+	if _, _, err := net.SplitHostPort(*addr); err != nil {
+		return fmt.Errorf("invalid -addr: %w", err)
+	}
+	var ifaces []string
+	if *iface != "" {
+		if _, err := net.InterfaceByName(*iface); err != nil {
+			return err
+		}
+		ifaces = []string{*iface}
+	}
+	p := strings.ReplaceAll(*pin, "-", "")
+	if p == "" {
+		v, e := store.Get("provisioning-pin")
+		if e == nil {
+			p = string(v)
+		} else if !errors.Is(e, os.ErrNotExist) {
+			return e
+		}
+	}
+	if p == "" {
+		for {
+			n, e := rand.Int(rand.Reader, big.NewInt(100000000))
+			if e != nil {
+				return e
+			}
+			p = fmt.Sprintf("%08d", n)
+			if !hap.InvalidPins[p] {
+				break
+			}
+		}
+	}
+	if len(p) != 8 || hap.InvalidPins[p] {
+		return fmt.Errorf("PIN must be 8 digits and not a prohibited pattern")
+	}
+	for _, r := range p {
+		if r < '0' || r > '9' {
+			return fmt.Errorf("PIN must contain only digits")
+		}
+	}
+	if err := store.Set("provisioning-pin", []byte(p)); err != nil {
+		return err
+	}
+	dev, err := hkserver.New(store, hkserver.Config{Name: *name, Serial: *serial, Finish: *finish, PIN: p, Address: *addr, Interfaces: ifaces})
+	if err != nil {
+		return err
+	}
+	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.")
+	log.Printf("HAP address %s; persistent state %s", *addr, *state)
+	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer cancel()
+	err = dev.Server.ListenAndServe(ctx)
+	if ctx.Err() != nil || errors.Is(err, http.ErrServerClosed) {
+		return nil
+	}
+	return err
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..9dbd1d0
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,25 @@
+module homekey.local/provisioner
+
+go 1.23.0
+
+require github.com/brutella/hap v0.0.35
+
+require (
+	github.com/brutella/dnssd v1.2.14 // indirect
+	github.com/go-chi/chi v1.5.4 // indirect
+	github.com/miekg/dns v1.1.61 // indirect
+	github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect
+	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
+	golang.org/x/sys v0.21.0 // indirect
+	golang.org/x/text v0.16.0 // indirect
+	golang.org/x/tools v0.22.0 // indirect
+	gopkg.in/Regis24GmbH/go-diacritics.v2 v2.0.3 // indirect
+)
+
+replace github.com/brutella/hap => github.com/tgckpg/hap v0.0.0-20261003193106-01c81197e42b
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..4abdff0
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,112 @@
+github.com/brutella/dnssd v1.2.14 h1:qLpTnRTm5peo2jA30hqMIbCuWn8x3sFg3e9o9ODOobw=
+github.com/brutella/dnssd v1.2.14/go.mod h1:tG4GE8orv6+irE5rdsNgb6MJSxm6cyMUKdC5jmD22gk=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs=
+github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs=
+github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
+github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE=
+github.com/tgckpg/hap v0.0.0-20261003193106-01c81197e42b h1:ZvsknLq10jv3InDjHCU4v0SURwd2hXsCkXhhhLcMqrU=
+github.com/tgckpg/hap v0.0.0-20261003193106-01c81197e42b/go.mod h1:vWJ+URAmB9aEXZ6bWeqO9iHwz+pcb89eR1pNYK2ZAUM=
+github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs=
+github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
+github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae h1:4hwBBUfQCFe3Cym0ZtKyq7L16eZUtYKs+BaHDN6mAns=
+github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
+github.com/xiam/to v0.0.0-20200126224905-d60d31e03561 h1:SVoNK97S6JlaYlHcaC+79tg3JUlQABcc0dH2VQ4Y+9s=
+github.com/xiam/to v0.0.0-20200126224905-d60d31e03561/go.mod h1:cqbG7phSzrbdg3aj+Kn63bpVruzwDZi58CpxlZkjwzw=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
+golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
+golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
+golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
+golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
+golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/Regis24GmbH/go-diacritics.v2 v2.0.3 h1:rz88vn1OH2B9kKorR+QCrcuw6WbizVwahU2Y9Q09xqU=
+gopkg.in/Regis24GmbH/go-diacritics.v2 v2.0.3/go.mod h1:vJmfdx2L0+30M90zUd0GCjLV14Ip3ZgWR5+MV1qljOo=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/hkserver/server.go b/internal/hkserver/server.go
new file mode 100644
index 0000000..12c202b
--- /dev/null
+++ b/internal/hkserver/server.go
@@ -0,0 +1,103 @@
+// SPDX-License-Identifier: Apache-2.0
+package hkserver
+
+import (
+	"encoding/base64"
+	"encoding/hex"
+	"fmt"
+	"log"
+	"net/http"
+
+	"github.com/brutella/hap"
+	"github.com/brutella/hap/accessory"
+	"github.com/brutella/hap/characteristic"
+	"github.com/brutella/hap/service"
+	"homekey.local/provisioner/internal/provision"
+)
+
+type Config struct {
+	Name, Serial, Finish, PIN, Address string
+	Interfaces                         []string
+}
+type Device struct {
+	Server    *hap.Server
+	Accessory *accessory.A
+	Control   *characteristic.C
+	Lock      *service.LockMechanism
+}
+
+func bytesCharacteristic(t string, raw []byte, permissions ...string) *characteristic.C {
+	c := characteristic.NewBytes(t)
+	c.Permissions = permissions
+	c.SetValue(raw)
+	return c.C
+}
+
+func New(store *provision.Store, cfg Config) (*Device, error) {
+	colors := map[string]string{"black": "00000000", "tan": "ced5da00", "gold": "aad6ec00", "silver": "e3e3e300"}
+	color, ok := colors[cfg.Finish]
+	if !ok {
+		return nil, fmt.Errorf("finish must be black, tan, gold, or silver")
+	}
+	finish, _ := hex.DecodeString(color)
+	// HAP category 6 is a standalone door lock, not a bridge.
+	a := accessory.New(accessory.Info{Name: cfg.Name, SerialNumber: cfg.Serial, Manufacturer: "DIY", Model: "Go Home Key", Firmware: "0.1.0"}, 6)
+	a.IdentifyFunc = func(*http.Request) { log.Print("Identify: virtual Home Key lock") }
+	a.Info.AddC(bytesCharacteristic("26C", provision.TLV(1, finish), characteristic.PermissionRead))
+	lock := service.NewLockMechanism()
+	lock.Primary = true
+	lock.LockCurrentState.SetValue(1)
+	lock.LockTargetState.SetValue(1)
+	lock.LockTargetState.ValidVals = []int{0, 1}
+	lock.LockTargetState.OnSetRemoteValue(func(v int) error {
+		log.Printf("Virtual lock state: %d (simulation; no physical lock connected)", v)
+		return lock.LockCurrentState.SetValue(v)
+	})
+	a.AddS(lock.S)
+	management := service.NewLockManagement()
+	management.Version.SetValue("1.0")
+	management.LockControlPoint.SetValue([]byte{})
+	management.LockControlPoint.Stateless = true
+	management.LockControlPoint.SetValueRequestFunc = func(any, *http.Request) (any, int) { return nil, -70406 }
+	a.AddS(management.S)
+	nfc := service.New("266")
+	state := characteristic.NewInt("263")
+	state.Format = characteristic.FormatUInt16
+	state.Permissions = []string{characteristic.PermissionRead, characteristic.PermissionEvents}
+	state.SetValue(0) // Matches the reference implementation; semantics not established.
+	nfc.AddC(state.C)
+	control := bytesCharacteristic("264", []byte{}, characteristic.PermissionRead, characteristic.PermissionWrite, characteristic.PermissionWriteResponse)
+	control.Stateless = true
+	control.ValueRequestFunc = func(*http.Request) (any, int) { return "", 0 }
+	control.SetValueRequestFunc = func(value any, _ *http.Request) (any, int) {
+		encoded, ok := value.(string)
+		if !ok || len(encoded) > base64.StdEncoding.EncodedLen(provision.MaxMessage) {
+			return nil, -70410
+		}
+		raw, err := base64.StdEncoding.Strict().DecodeString(encoded)
+		if err != nil {
+			return nil, -70410
+		}
+		response, err := store.Handle(raw)
+		if err != nil {
+			// No raw TLVs, keys, public keys or credential payloads in logs.
+			log.Printf("Provisioning request rejected: %v", err)
+			return nil, -70402
+		}
+		v := store.Summary()
+		log.Printf("Provisioning exchange complete: reader=%t issuers=%d endpoints=%d", v.ReaderProvisioned, v.Issuers, v.Endpoints)
+		return base64.StdEncoding.EncodeToString(response), 0
+	}
+	nfc.AddC(control)
+	supported := append(provision.TLV(1, []byte{provision.Capacity}), provision.TLV(2, []byte{provision.Capacity})...)
+	nfc.AddC(bytesCharacteristic("265", supported, characteristic.PermissionRead))
+	a.AddS(nfc)
+	s, err := hap.NewServer(store, a)
+	if err != nil {
+		return nil, err
+	}
+	s.Pin = cfg.PIN
+	s.Addr = cfg.Address
+	s.Ifaces = cfg.Interfaces
+	return &Device{Server: s, Accessory: a, Control: control, Lock: lock}, nil
+}
diff --git a/internal/hkserver/server_test.go b/internal/hkserver/server_test.go
new file mode 100644
index 0000000..9e0a34b
--- /dev/null
+++ b/internal/hkserver/server_test.go
@@ -0,0 +1,109 @@
+package hkserver
+
+import (
+	"bytes"
+	"encoding/base64"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"homekey.local/provisioner/internal/provision"
+)
+
+func testDevice(t *testing.T) *Device {
+	t.Helper()
+	s, e := provision.Open(t.TempDir())
+	if e != nil {
+		t.Fatal(e)
+	}
+	pub := bytes.Repeat([]byte{42}, 32)
+	p, _ := json.Marshal(struct {
+		Name       string
+		PublicKey  []byte
+		Permission byte
+	}{"phone", pub, 1})
+	if e = s.Set("phone.pairing", p); e != nil {
+		t.Fatal(e)
+	}
+	d, e := New(s, Config{Name: "Test Lock", Serial: "test", Finish: "silver", PIN: "51808582", Address: ":51826"})
+	if e != nil {
+		t.Fatal(e)
+	}
+	return d
+}
+func TestAccessoryAndProvisioningRetry(t *testing.T) {
+	d := testDevice(t)
+	b, e := json.Marshal(d.Accessory)
+	if e != nil {
+		t.Fatal(e)
+	}
+	var a struct {
+		Services []struct {
+			Type            string `json:"type"`
+			Characteristics []struct {
+				Type  string   `json:"type"`
+				Perms []string `json:"perms"`
+				Value any      `json:"value"`
+			} `json:"characteristics"`
+		} `json:"services"`
+	}
+	if e = json.Unmarshal(b, &a); e != nil {
+		t.Fatal(e)
+	}
+	seen := map[string]bool{}
+	for _, s := range a.Services {
+		seen["service:"+s.Type] = true
+		for _, c := range s.Characteristics {
+			seen[c.Type] = true
+			if c.Type == "265" && c.Value != "AQEQAgEQ" {
+				t.Fatalf("supported configuration = %v", c.Value)
+			}
+			if c.Type == "26C" && c.Value != base64.StdEncoding.EncodeToString([]byte{1, 4, 0xe3, 0xe3, 0xe3, 0}) {
+				t.Fatal("bad finish")
+			}
+		}
+	}
+	for _, k := range []string{"service:266", "service:45", "service:44", "263", "264", "265", "26C"} {
+		if !seen[k] {
+			t.Fatal("missing", k)
+		}
+	}
+	key := make([]byte, 32)
+	key[31] = 1
+	body := append(provision.TLV(1, []byte{2}), provision.TLV(2, key)...)
+	body = append(body, provision.TLV(3, []byte("reader01"))...)
+	raw := append(provision.TLV(1, []byte{2}), provision.TLV(6, body)...)
+	encoded := base64.StdEncoding.EncodeToString(raw)
+	req := httptest.NewRequest(http.MethodPut, "/characteristics", nil)
+	for i, want := range [][]byte{{7, 3, 2, 1, 0}, {7, 3, 2, 1, 2}} {
+		v, code := d.Control.SetValueRequest(encoded, req)
+		if code != 0 || v != base64.StdEncoding.EncodeToString(want) {
+			t.Fatalf("write %d: %v %d", i, v, code)
+		}
+		if d.Control.Value() != "" {
+			t.Fatal("private request retained as characteristic value")
+		}
+	}
+	v, code := d.Control.ValueRequest(req)
+	if code != 0 || v != "" {
+		t.Fatal("GET leaked command payload")
+	}
+	if _, code := d.Control.SetValueRequest("not base64!", req); code == 0 {
+		t.Fatal("invalid base64 accepted")
+	}
+}
+func TestUnauthenticatedHTTPDenied(t *testing.T) {
+	d := testDevice(t)
+	mux, ok := d.Server.ServeMux().(http.Handler)
+	if !ok {
+		t.Fatal("mux is not a handler")
+	}
+	for _, path := range []string{"/accessories", "/characteristics?id=1.1"} {
+		r := httptest.NewRecorder()
+		mux.ServeHTTP(r, httptest.NewRequest(http.MethodGet, path, nil))
+		if !bytes.Contains(r.Body.Bytes(), []byte("-70401")) {
+			t.Fatalf("request not rejected: %d %s", r.Code, r.Body.String())
+		}
+	}
+}
diff --git a/internal/provision/control.go b/internal/provision/control.go
new file mode 100644
index 0000000..49ffcf0
--- /dev/null
+++ b/internal/provision/control.go
@@ -0,0 +1,189 @@
+// SPDX-License-Identifier: Apache-2.0
+// Adapted from kormax/apple-home-key-reader entity.py/service.py; see NOTICE.
+package provision
+
+import (
+	"crypto/elliptic"
+	"crypto/sha1"
+	"encoding/hex"
+	"fmt"
+	"math/big"
+	"time"
+)
+
+const (
+	Success byte = iota
+	OutOfResources
+	Duplicate
+	DoesNotExist
+	NotSupported
+)
+const Capacity = 16
+
+func validScalar(b []byte) bool {
+	n := new(big.Int).SetBytes(b)
+	return len(b) == 32 && n.Sign() > 0 && n.Cmp(elliptic.P256().Params().N) < 0
+}
+func endpointID(pub []byte) string                  { h := sha1.Sum(pub); return hex.EncodeToString(h[:6]) }
+func statusResponse(outer, tag, status byte) []byte { return TLV(outer, TLV(tag, []byte{status})) }
+
+// Handle validates and commits a control point operation before returning its
+// response. Malformed input is a HAP error, semantic failures are TLV statuses.
+func (s *Store) Handle(raw []byte) (response []byte, err error) {
+	f, err := DecodeTLV(raw)
+	if err != nil {
+		return nil, err
+	}
+	op, err := one(f, 1)
+	if err != nil {
+		return nil, err
+	}
+	r, hasReader := f[6]
+	e, hasEndpoint := f[4]
+	if hasReader == hasEndpoint {
+		return nil, fmt.Errorf("exactly one request object required")
+	}
+	body := r
+	if hasEndpoint {
+		body = e
+	}
+	inner, err := DecodeTLV(body)
+	if err != nil {
+		return nil, err
+	}
+	err = s.update(func(d *Data) error {
+		if hasReader {
+			response, err = readerOperation(&d.HomeKey, op, inner)
+		} else {
+			response, err = endpointOperation(&d.HomeKey, op, inner)
+		}
+		return err
+	})
+	if err != nil {
+		return nil, err
+	}
+	return response, nil
+}
+
+func readerOperation(c *Credentials, op byte, f Fields) ([]byte, error) {
+	status := func(v byte) ([]byte, error) { return statusResponse(7, 2, v), nil }
+	switch op {
+	case 1:
+		// Match the working Python implementation before enrollment: hash a zero
+		// scalar when no reader key exists. This is an identifier, not a usable key.
+		key := make([]byte, 32)
+		if c.ReaderPrivateKey != "" {
+			key, _ = hex.DecodeString(c.ReaderPrivateKey)
+		}
+		id, _ := hex.DecodeString(keyID(key))
+		return TLV(7, TLV(1, id)), nil
+	case 2:
+		kt, err := one(f, 1)
+		if err != nil {
+			return nil, err
+		}
+		if kt != 2 {
+			return status(NotSupported)
+		}
+		if !validScalar(f[2]) || len(f[3]) != 8 {
+			return nil, fmt.Errorf("invalid reader key or identifier")
+		}
+		key, id := hex.EncodeToString(f[2]), hex.EncodeToString(f[3])
+		if key == c.ReaderPrivateKey && id == c.ReaderIdentifier {
+			return status(Duplicate)
+		}
+		// A new reader identity invalidates previous FAST secrets.
+		for iid, issuer := range c.Issuers {
+			for eid, ep := range issuer.Endpoints {
+				ep.PersistentKey = ""
+				issuer.Endpoints[eid] = ep
+			}
+			c.Issuers[iid] = issuer
+		}
+		c.ReaderPrivateKey = key
+		c.ReaderIdentifier = id
+		return status(Success)
+	case 3:
+		if len(f[4]) != 8 {
+			return nil, fmt.Errorf("invalid reader key identifier")
+		}
+		if c.ReaderPrivateKey == "" {
+			return status(DoesNotExist)
+		}
+		key, _ := hex.DecodeString(c.ReaderPrivateKey)
+		if hex.EncodeToString(f[4]) != keyID(key) {
+			return status(DoesNotExist)
+		}
+		c.ReaderPrivateKey = ""
+		c.ReaderIdentifier = ""
+		for id, issuer := range c.Issuers {
+			issuer.Endpoints = map[string]Endpoint{}
+			c.Issuers[id] = issuer
+		}
+		return status(Success)
+	default:
+		return status(NotSupported)
+	}
+}
+
+func endpointOperation(c *Credentials, op byte, f Fields) ([]byte, error) {
+	status := func(v byte) ([]byte, error) { return statusResponse(5, 3, v), nil }
+	if op != 2 {
+		return status(NotSupported)
+	} // GET/REMOVE wire semantics not established in reference.
+	kt, err := one(f, 1)
+	if err != nil {
+		return nil, err
+	}
+	if kt != 2 {
+		return status(NotSupported)
+	}
+	if len(f[2]) != 64 || len(f[3]) != 8 {
+		return nil, fmt.Errorf("invalid device credential length")
+	}
+	state := byte(1)
+	if _, ok := f[4]; ok {
+		state, err = one(f, 4)
+		if err != nil || state > 1 {
+			return nil, fmt.Errorf("invalid credential state")
+		}
+	}
+	pub := append([]byte{4}, f[2]...)
+	x, _ := elliptic.Unmarshal(elliptic.P256(), pub)
+	if x == nil {
+		return nil, fmt.Errorf("invalid P-256 public key")
+	}
+	iid := hex.EncodeToString(f[3])
+	issuer, ok := c.Issuers[iid]
+	if !ok {
+		return status(DoesNotExist)
+	}
+	if c.ReaderPrivateKey == "" {
+		return status(DoesNotExist)
+	}
+	eid := endpointID(pub)
+	old, exists := issuer.Endpoints[eid]
+	if exists && old.PublicKey != hex.EncodeToString(pub) {
+		return status(NotSupported)
+	}
+	code := Success
+	if exists && old.KeyState == state {
+		code = Duplicate
+	} else {
+		total := 0
+		for _, i := range c.Issuers {
+			total += len(i.Endpoints)
+		}
+		if !exists && total >= Capacity {
+			return status(OutOfResources)
+		}
+		if !exists {
+			old = Endpoint{PublicKey: hex.EncodeToString(pub), KeyType: 2, EnrolledAt: time.Now().Unix()}
+		}
+		old.KeyState = state
+		issuer.Endpoints[eid] = old
+		c.Issuers[iid] = issuer
+	}
+	body := append(TLV(2, f[3]), TLV(3, []byte{code})...)
+	return TLV(5, body), nil
+}
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go
new file mode 100644
index 0000000..193bfa0
--- /dev/null
+++ b/internal/provision/provision_test.go
@@ -0,0 +1,193 @@
+package provision
+
+import (
+	"bytes"
+	"crypto/elliptic"
+	"encoding/hex"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"sync"
+	"testing"
+)
+
+func mustHex(s string) []byte {
+	b, e := hex.DecodeString(s)
+	if e != nil {
+		panic(e)
+	}
+	return b
+}
+func request(op, tag byte, inner []byte) []byte {
+	return append(TLV(1, []byte{op}), TLV(tag, inner)...)
+}
+func readerAdd() []byte {
+	key := make([]byte, 32)
+	key[31] = 1
+	b := append(TLV(1, []byte{2}), TLV(2, key)...)
+	b = append(b, TLV(3, []byte("reader01"))...)
+	return request(2, 6, b)
+}
+func paired(t *testing.T) (*Store, string, string) {
+	t.Helper()
+	dir := t.TempDir()
+	s, e := Open(dir)
+	if e != nil {
+		t.Fatal(e)
+	}
+	pub := bytes.Repeat([]byte{0x42}, 32)
+	p, _ := json.Marshal(struct {
+		Name       string
+		PublicKey  []byte
+		Permission byte
+	}{"controller", pub, 1})
+	if e = s.Set("controller.pairing", p); e != nil {
+		t.Fatal(e)
+	}
+	return s, dir, keyID(pub)
+}
+func endpointAdd(iid string) []byte {
+	x, y := elliptic.P256().ScalarBaseMult([]byte{2})
+	pub := elliptic.Marshal(elliptic.P256(), x, y)
+	b := append(TLV(1, []byte{2}), TLV(2, pub[1:])...)
+	b = append(b, TLV(3, mustHex(iid))...)
+	b = append(b, TLV(4, []byte{1})...)
+	return request(2, 4, b)
+}
+func checkResponse(t *testing.T, s *Store, req []byte, want []byte) {
+	t.Helper()
+	got, e := s.Handle(req)
+	if e != nil {
+		t.Fatal(e)
+	}
+	if !bytes.Equal(got, want) {
+		t.Fatalf("got %x, want %x", got, want)
+	}
+}
+func TestProvisionAndRestart(t *testing.T) {
+	s, dir, iid := paired(t)
+	checkResponse(t, s, readerAdd(), mustHex("0703020100"))
+	checkResponse(t, s, readerAdd(), mustHex("0703020102"))
+	key := make([]byte, 32)
+	key[31] = 1
+	checkResponse(t, s, request(1, 6, nil), TLV(7, TLV(1, mustHex(keyID(key)))))
+	want := TLV(5, append(TLV(2, mustHex(iid)), TLV(3, []byte{Success})...))
+	checkResponse(t, s, endpointAdd(iid), want)
+	want = TLV(5, append(TLV(2, mustHex(iid)), TLV(3, []byte{Duplicate})...))
+	checkResponse(t, s, endpointAdd(iid), want)
+	s2, e := Open(dir)
+	if e != nil {
+		t.Fatal(e)
+	}
+	if v := s2.Summary(); !v.ReaderProvisioned || v.Endpoints != 1 || v.Issuers != 1 {
+		t.Fatalf("bad summary %+v", v)
+	}
+	info, e := os.Stat(filepath.Join(dir, "state.json"))
+	if e != nil {
+		t.Fatal(e)
+	}
+	if info.Mode().Perm() != 0600 {
+		t.Fatalf("permissions %o", info.Mode().Perm())
+	}
+	if e = s2.Delete("controller.pairing"); e != nil {
+		t.Fatal(e)
+	}
+	if v := s2.Summary(); v.ReaderProvisioned || v.Endpoints != 0 || v.Issuers != 0 {
+		t.Fatalf("unpair did not clear credentials %+v", v)
+	}
+	s3, e := Open(dir)
+	if e != nil {
+		t.Fatal(e)
+	}
+	if s3.Summary().ReaderProvisioned {
+		t.Fatal("revocation not persisted")
+	}
+}
+func TestReaderRemove(t *testing.T) {
+	s, _, _ := paired(t)
+	checkResponse(t, s, readerAdd(), mustHex("0703020100"))
+	key := make([]byte, 32)
+	key[31] = 1
+	checkResponse(t, s, request(3, 6, TLV(4, mustHex(keyID(key)))), mustHex("0703020100"))
+	checkResponse(t, s, request(3, 6, TLV(4, mustHex(keyID(key)))), mustHex("0703020103"))
+}
+func TestUnknownIssuerAndInvalidKey(t *testing.T) {
+	s, _, _ := paired(t)
+	checkResponse(t, s, readerAdd(), mustHex("0703020100"))
+	checkResponse(t, s, endpointAdd("0000000000000000"), mustHex("0503030103"))
+	f, _ := DecodeTLV(readerAdd())
+	inner, _ := DecodeTLV(f[6])
+	inner[2] = make([]byte, 32)
+	b := append(TLV(1, inner[1]), TLV(2, inner[2])...)
+	b = append(b, TLV(3, inner[3])...)
+	if _, e := s.Handle(request(2, 6, b)); e == nil {
+		t.Fatal("zero scalar accepted")
+	}
+	if !s.Summary().ReaderProvisioned {
+		t.Fatal("valid reader key lost")
+	}
+}
+func TestRejectMalformedTLV(t *testing.T) {
+	for _, b := range [][]byte{{1}, {1, 2, 0}, {1, 1, 1, 1, 1, 2}, {255, 0}, make([]byte, MaxMessage+1)} {
+		if _, e := DecodeTLV(b); e == nil {
+			t.Fatalf("accepted malformed input %x", b[:min(8, len(b))])
+		}
+	}
+	for _, n := range []int{0, 1, 254, 255, 256, 510, 511} {
+		b := bytes.Repeat([]byte{42}, n)
+		f, e := DecodeTLV(TLV(6, b))
+		if e != nil {
+			t.Fatal(e)
+		}
+		v, ok := f[6]
+		if !ok || !bytes.Equal(v, b) {
+			t.Fatal("fragment roundtrip failed")
+		}
+	}
+}
+func TestDiskFailureRollsBack(t *testing.T) {
+	s, _, _ := paired(t)
+	s.path = filepath.Join(t.TempDir(), "missing", "state.json")
+	if _, e := s.Handle(readerAdd()); e == nil {
+		t.Fatal("disk failure ignored")
+	}
+	if s.Summary().ReaderProvisioned {
+		t.Fatal("memory committed on failed disk write")
+	}
+}
+func TestCorruptStateFailsClosed(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "state.json")
+	if e := os.WriteFile(p, []byte("{bad"), 0600); e != nil {
+		t.Fatal(e)
+	}
+	if _, e := Open(dir); e == nil {
+		t.Fatal("corrupt state silently reset")
+	}
+}
+func TestConcurrentRequests(t *testing.T) {
+	s, _, iid := paired(t)
+	checkResponse(t, s, readerAdd(), mustHex("0703020100"))
+	var wg sync.WaitGroup
+	for i := 0; i < 12; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			for j := 0; j < 5; j++ {
+				if _, e := s.Handle(endpointAdd(iid)); e != nil {
+					t.Error(e)
+				}
+				_ = s.Summary()
+			}
+		}()
+	}
+	wg.Wait()
+	if s.Summary().Endpoints != 1 {
+		t.Fatal("duplicate endpoints")
+	}
+}
+func FuzzDecodeTLV(f *testing.F) {
+	f.Add([]byte{1, 1, 2, 6, 0})
+	f.Add(bytes.Repeat([]byte{1}, 256))
+	f.Fuzz(func(t *testing.T, b []byte) { _, _ = DecodeTLV(b) })
+}
diff --git a/internal/provision/store.go b/internal/provision/store.go
new file mode 100644
index 0000000..fc11546
--- /dev/null
+++ b/internal/provision/store.go
@@ -0,0 +1,287 @@
+// SPDX-License-Identifier: Apache-2.0
+package provision
+
+import (
+	"bytes"
+	"crypto/elliptic"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"sync"
+)
+
+type Endpoint struct {
+	PublicKey     string `json:"public_key"`
+	KeyType       byte   `json:"key_type"`
+	KeyState      byte   `json:"key_state"`
+	EnrolledAt    int64  `json:"enrolled_at"`
+	PersistentKey string `json:"persistent_key,omitempty"` // populated by future NFC authentication
+}
+type Issuer struct {
+	PublicKey string              `json:"public_key"`
+	Endpoints map[string]Endpoint `json:"endpoints"`
+}
+type Credentials struct {
+	ReaderPrivateKey string            `json:"reader_private_key,omitempty"`
+	ReaderIdentifier string            `json:"reader_identifier,omitempty"`
+	Issuers          map[string]Issuer `json:"issuers"`
+}
+type Data struct {
+	Version int               `json:"version"`
+	HAP     map[string][]byte `json:"hap"`
+	HomeKey Credentials       `json:"homekey"`
+}
+
+// Store implements hap.Store. Pairings and Home Key credentials are committed
+// together, so unpairing revokes the corresponding issuer in the same update.
+// One process must own a store; the CLI takes an OS file lock before opening it.
+type Store struct {
+	mu   sync.Mutex
+	path string
+	data Data
+}
+
+func Open(dir string) (*Store, error) {
+	if err := os.MkdirAll(dir, 0700); err != nil {
+		return nil, err
+	}
+	if err := os.Chmod(dir, 0700); err != nil {
+		return nil, err
+	}
+	s := &Store{path: filepath.Join(dir, "state.json"), data: Data{Version: 1, HAP: map[string][]byte{}, HomeKey: Credentials{Issuers: map[string]Issuer{}}}}
+	b, err := os.ReadFile(s.path)
+	if errors.Is(err, os.ErrNotExist) {
+		return s, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	d := json.NewDecoder(bytes.NewReader(b))
+	d.DisallowUnknownFields()
+	if err := d.Decode(&s.data); err != nil {
+		return nil, fmt.Errorf("invalid state file; refusing to reset: %w", err)
+	}
+	var extra any
+	if err := d.Decode(&extra); err != io.EOF {
+		return nil, fmt.Errorf("trailing content in state file")
+	}
+	if s.data.Version != 1 || s.data.HAP == nil || s.data.HomeKey.Issuers == nil {
+		return nil, fmt.Errorf("invalid state schema")
+	}
+	if err := validateCredentials(s.data.HomeKey); err != nil {
+		return nil, err
+	}
+	if err := reconcile(&s.data); err != nil {
+		return nil, err
+	}
+	if err := os.Chmod(s.path, 0600); err != nil {
+		return nil, err
+	}
+	return s, nil
+}
+
+func keyID(b []byte) string {
+	h := sha256.New()
+	h.Write([]byte("key-identifier"))
+	h.Write(b)
+	return hex.EncodeToString(h.Sum(nil)[:8])
+}
+
+func reconcile(d *Data) error {
+	current := make(map[string]Issuer)
+	for name, raw := range d.HAP {
+		if !strings.HasSuffix(name, ".pairing") {
+			continue
+		}
+		var p struct {
+			Name       string
+			PublicKey  []byte
+			Permission byte
+		}
+		if err := json.Unmarshal(raw, &p); err != nil || len(p.PublicKey) != 32 || p.Name == "" {
+			return fmt.Errorf("invalid stored HAP pairing")
+		}
+		id := keyID(p.PublicKey)
+		issuer, ok := d.HomeKey.Issuers[id]
+		if !ok {
+			issuer = Issuer{PublicKey: hex.EncodeToString(p.PublicKey), Endpoints: map[string]Endpoint{}}
+		}
+		current[id] = issuer
+	}
+	if len(current) > Capacity {
+		return fmt.Errorf("issuer capacity exceeded")
+	}
+	d.HomeKey.Issuers = current
+	if len(current) == 0 {
+		d.HomeKey.ReaderPrivateKey = ""
+		d.HomeKey.ReaderIdentifier = ""
+	}
+	return nil
+}
+
+func validateCredentials(c Credentials) error {
+	if c.ReaderPrivateKey != "" {
+		p, e := hex.DecodeString(c.ReaderPrivateKey)
+		if e != nil || !validScalar(p) {
+			return fmt.Errorf("invalid saved reader key")
+		}
+		id, e := hex.DecodeString(c.ReaderIdentifier)
+		if e != nil || len(id) != 8 {
+			return fmt.Errorf("invalid saved reader identifier")
+		}
+	} else if c.ReaderIdentifier != "" {
+		return fmt.Errorf("reader identifier without key")
+	}
+	for id, i := range c.Issuers {
+		p, e := hex.DecodeString(i.PublicKey)
+		if e != nil || len(p) != 32 || id != keyID(p) || i.Endpoints == nil {
+			return fmt.Errorf("invalid saved issuer")
+		}
+		for eid, ep := range i.Endpoints {
+			p, e := hex.DecodeString(ep.PublicKey)
+			if e != nil || len(p) != 65 {
+				return fmt.Errorf("invalid saved endpoint")
+			}
+			x, _ := elliptic.Unmarshal(elliptic.P256(), p)
+			if x == nil || eid != endpointID(p) || ep.KeyType != 2 || ep.KeyState > 1 {
+				return fmt.Errorf("invalid saved endpoint")
+			}
+			if ep.PersistentKey != "" {
+				k, e := hex.DecodeString(ep.PersistentKey)
+				if e != nil || len(k) != 32 {
+					return fmt.Errorf("invalid saved persistent key")
+				}
+			}
+		}
+	}
+	return nil
+}
+
+func (s *Store) update(fn func(*Data) error) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	// Clone before mutation. Failed validation or persistence leaves live state intact.
+	old, err := json.Marshal(s.data)
+	if err != nil {
+		return err
+	}
+	var next Data
+	if err = json.Unmarshal(old, &next); err != nil {
+		return err
+	}
+	if err = fn(&next); err != nil {
+		return err
+	}
+	b, err := json.Marshal(next)
+	if err != nil {
+		return err
+	}
+	if bytes.Equal(b, old) {
+		return nil
+	}
+	if err = atomicWrite(s.path, b); err != nil {
+		return err
+	}
+	s.data = next
+	return nil
+}
+
+func atomicWrite(path string, b []byte) error {
+	f, err := os.CreateTemp(filepath.Dir(path), ".state-*")
+	if err != nil {
+		return err
+	}
+	defer os.Remove(f.Name())
+	if _, err = f.Write(b); err != nil {
+		f.Close()
+		return err
+	}
+	if err = f.Sync(); err != nil {
+		f.Close()
+		return err
+	}
+	if err = f.Close(); err != nil {
+		return err
+	}
+	if err = os.Rename(f.Name(), path); err != nil {
+		return err
+	}
+	// Best effort directory fsync: rename is the commit point. Reporting a later
+	// failure as rollback would leave memory inconsistent with the committed file.
+	if dir, e := os.Open(filepath.Dir(path)); e == nil {
+		_ = dir.Sync()
+		_ = dir.Close()
+	}
+	return nil
+}
+func (s *Store) Set(key string, value []byte) error {
+	return s.update(func(d *Data) error {
+		d.HAP[key] = append([]byte(nil), value...)
+		if strings.HasSuffix(key, ".pairing") {
+			return reconcile(d)
+		}
+		return nil
+	})
+}
+func (s *Store) Get(key string) ([]byte, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	v, ok := s.data.HAP[key]
+	if !ok {
+		return nil, os.ErrNotExist
+	}
+	return append([]byte(nil), v...), nil
+}
+func (s *Store) Delete(key string) error {
+	return s.update(func(d *Data) error {
+		if _, ok := d.HAP[key]; !ok {
+			return os.ErrNotExist
+		}
+		delete(d.HAP, key)
+		if strings.HasSuffix(key, ".pairing") {
+			return reconcile(d)
+		}
+		return nil
+	})
+}
+func (s *Store) KeysWithSuffix(suffix string) ([]string, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	var keys []string
+	for key := range s.data.HAP {
+		if strings.HasSuffix(key, suffix) {
+			keys = append(keys, key)
+		}
+	}
+	sort.Strings(keys)
+	return keys, nil
+}
+
+type Summary struct {
+	PairedControllers int  `json:"paired_controllers"`
+	ReaderProvisioned bool `json:"reader_provisioned"`
+	Issuers           int  `json:"issuers"`
+	Endpoints         int  `json:"endpoints"`
+}
+
+func (s *Store) Summary() Summary {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	v := Summary{ReaderProvisioned: s.data.HomeKey.ReaderPrivateKey != "", Issuers: len(s.data.HomeKey.Issuers)}
+	for k := range s.data.HAP {
+		if strings.HasSuffix(k, ".pairing") {
+			v.PairedControllers++
+		}
+	}
+	for _, i := range s.data.HomeKey.Issuers {
+		v.Endpoints += len(i.Endpoints)
+	}
+	return v
+}
diff --git a/internal/provision/tlv.go b/internal/provision/tlv.go
new file mode 100644
index 0000000..89ce85e
--- /dev/null
+++ b/internal/provision/tlv.go
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: Apache-2.0
+package provision
+
+import "fmt"
+
+const MaxMessage = 4096
+
+type Fields map[byte][]byte
+
+// DecodeTLV accepts a single TLV8 object. Adjacent 255-byte fragments are
+// reassembled; ambiguous duplicate fields and list separators are rejected.
+func DecodeTLV(b []byte) (Fields, error) {
+	if len(b) > MaxMessage {
+		return nil, fmt.Errorf("TLV exceeds limit")
+	}
+	out := make(Fields)
+	var previous byte
+	previousLen := -1
+	for len(b) != 0 {
+		if len(b) < 2 {
+			return nil, fmt.Errorf("truncated TLV header")
+		}
+		t, n := b[0], int(b[1])
+		b = b[2:]
+		if t == 0xff || len(b) < n {
+			return nil, fmt.Errorf("invalid TLV field")
+		}
+		if _, exists := out[t]; exists && (previous != t || previousLen != 255) {
+			return nil, fmt.Errorf("duplicate TLV field")
+		}
+		out[t] = append(out[t], b[:n]...)
+		// Preserve the presence of an explicitly empty field.
+		if out[t] == nil {
+			out[t] = []byte{}
+		}
+		previous, previousLen, b = t, n, b[n:]
+	}
+	return out, nil
+}
+
+func TLV(t byte, value []byte) []byte {
+	var out []byte
+	for len(value) > 255 {
+		out = append(out, t, 255)
+		out = append(out, value[:255]...)
+		value = value[255:]
+	}
+	out = append(out, t, byte(len(value)))
+	return append(out, value...)
+}
+
+func one(f Fields, t byte) (byte, error) {
+	if len(f[t]) != 1 {
+		return 0, fmt.Errorf("missing or invalid one-byte field %d", t)
+	}
+	return f[t][0], nil
+}