feat(hap): implement authenticated pairing and transport

This commit is contained in:
ruv
2026-07-27 15:05:45 -04:00
parent b41b8c8a82
commit 42684a7a1e
13 changed files with 2734 additions and 474 deletions
@@ -224,8 +224,9 @@ touched:
SHA-256-checks the module, Ed25519-verifies the signature against
`publisher_key`, and enforces a `PluginPolicy` trust allowlist
(secure-default rejects unsigned/untrusted/tampered modules).
- **HAP real pairing (P2)** — SRP/HKDF pairing + encrypted sessions; current
bridge is an accessory-mapping surface. **ACCEPTED-FUTURE (honestly stubbed).**
- **HAP real pairing (P2)** — **DONE (2026-07-27 addendum below).** SRP/HKDF
Pair-Setup, transcript-authenticated Pair-Verify, encrypted sessions, and
administrator-only pairing management now land as one fail-closed boundary.
- **`RunMode::Queued`/`Restart`/`max` ordering** — ~~`Single`/`Parallel` are
honored; bounded queueing, restart-kill, and `max` concurrency are not yet
wired (every non-Single mode is parallel).~~ **DONE — ADR-162 §A5.** Restart
@@ -336,3 +337,35 @@ is still delivered (old code: 5s-timeout panic).
+1 api-root accept-guard, +1 WS lag-survival), 0 failed. Workspace green.
Python deterministic proof unchanged (homecore-api is off the signal proof
path).
## Addendum — HAP cryptographic boundary completed (2026-07-27)
The P2 HAP deferral recorded above is closed as a single security boundary in
`homecore-hap`; it was not replaced with a success-shaped partial protocol.
- Pair-Setup M1-M6 uses RustCrypto SRP-6a with the RFC 5054 3072-bit group,
SHA-512 and HAP proof compatibility, followed by the specified
HKDF-SHA512, ChaCha20-Poly1305, and Ed25519 transcript construction.
- Pair-Verify M1-M4 uses ephemeral X25519, strict Ed25519 transcript
verification, and separately derived directional control keys.
- The TCP server changes to authenticated HAP record framing only after the
plaintext M4 response is written. Record lengths are authenticated, plaintext
is capped at 1024 bytes, counters are independent and monotonic, and any
authentication/replay/framing failure closes without an oracle response.
- Accessory identity, signing seed, SRP verifier, and controller pairings share
one versioned, bounded, permission-checked, atomically replaced store. The raw
setup code is disclosed only on first provisioning and is not persisted.
- Protected endpoints require an encrypted Pair-Verify session. Pairing
management rechecks current persisted administrator authority, handles the
last-admin invariant, updates mDNS paired state, and revokes live sessions.
Evidence includes a deterministic HAP SRP vector, complete in-process
Pair-Setup and Pair-Verify ceremonies, malformed/proof/transcript tests, record
tamper/replay/oversize tests, persistence lifecycle tests, and a real TCP test
that verifies Pair-Verify, accesses `/accessories` over encrypted records, then
proves replay closes the connection.
This closes the cryptographic implementation item, not the entire Apple Home
product surface. Current-Apple/MFi interoperability has not been certified;
transient/split Pair-Setup, writable/timed characteristics, resource endpoints,
and persisted AID/IID allocation remain explicitly unsupported.
Generated
+273 -49
View File
@@ -17,6 +17,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array 0.14.7",
]
[[package]]
name = "aes"
version = "0.8.4"
@@ -398,6 +408,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base16ct"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
[[package]]
name = "base64"
version = "0.21.7"
@@ -518,6 +534,15 @@ dependencies = [
"generic-array 0.14.7",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -894,6 +919,30 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]]
name = "chrono"
version = "0.4.44"
@@ -941,8 +990,9 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"inout",
"zeroize",
]
[[package]]
@@ -985,6 +1035,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "cobs"
version = "0.3.0"
@@ -1003,7 +1059,7 @@ dependencies = [
"mdns-sd",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"tempfile",
"thiserror 1.0.69",
"tokio",
@@ -1025,7 +1081,7 @@ dependencies = [
"safetensors 0.4.5",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"tempfile",
"thiserror 1.0.69",
"tokio",
@@ -1046,7 +1102,7 @@ dependencies = [
"safetensors 0.4.5",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"tempfile",
"thiserror 1.0.69",
"tokio",
@@ -1149,6 +1205,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "constant_time_eq"
version = "0.1.5"
@@ -1246,6 +1308,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1548,6 +1616,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "crypto-bigint"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
dependencies = [
"cpubits",
"ctutils",
"getrandom 0.4.1",
"num-traits",
"rand_core 0.10.1",
"serdect",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -1555,9 +1637,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array 0.14.7",
"rand_core 0.6.4",
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"getrandom 0.4.1",
"hybrid-array",
"rand_core 0.10.1",
]
[[package]]
name = "cssparser"
version = "0.29.6"
@@ -1616,6 +1710,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]]
name = "cty"
version = "0.2.2"
@@ -1652,7 +1755,7 @@ dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest",
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"subtle",
@@ -1740,7 +1843,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
]
@@ -1791,12 +1894,23 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"block-buffer 0.10.4",
"const-oid 0.9.6",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid 0.10.2",
"crypto-common 0.2.2",
]
[[package]]
name = "directories-next"
version = "2.0.0"
@@ -1991,7 +2105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
@@ -2017,7 +2131,7 @@ dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"sha2 0.10.9",
"subtle",
"zeroize",
]
@@ -2037,9 +2151,9 @@ version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"base16ct 0.2.0",
"crypto-bigint 0.5.5",
"digest 0.10.7",
"ff",
"generic-array 0.14.7",
"group",
@@ -3099,6 +3213,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasip2",
"wasip3",
]
@@ -3483,7 +3598,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -3603,17 +3718,25 @@ name = "homecore-hap"
version = "0.1.0-alpha.0"
dependencies = [
"async-trait",
"chacha20poly1305",
"ed25519-dalek",
"getrandom 0.2.17",
"hkdf",
"homecore",
"httparse",
"mdns-sd",
"serde",
"serde_json",
"sha2 0.10.9",
"sha2 0.11.0",
"srp",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid",
"x25519-dalek",
"zeroize",
]
[[package]]
@@ -3644,7 +3767,7 @@ dependencies = [
"homecore",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"thiserror 1.0.69",
"tokio",
"uuid",
@@ -3663,7 +3786,7 @@ dependencies = [
"ruvector-core",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"sqlx",
"thiserror 1.0.69",
"tokio",
@@ -3784,6 +3907,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "0.14.32"
@@ -4727,7 +4859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -5380,7 +5512,7 @@ dependencies = [
"serde",
"serde-wasm-bindgen",
"serde_json",
"sha2",
"sha2 0.10.9",
"thiserror 2.0.18",
"tracing",
"wasm-bindgen",
@@ -5558,6 +5690,12 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.3"
@@ -5703,7 +5841,7 @@ dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -5802,10 +5940,10 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
dependencies = [
"digest",
"digest 0.10.7",
"hmac",
"password-hash",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -5879,7 +6017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
dependencies = [
"pest",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -6161,6 +6299,17 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -6686,6 +6835,12 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_distr"
version = "0.4.3"
@@ -7152,8 +7307,8 @@ version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest",
"const-oid 0.9.6",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
@@ -7260,7 +7415,7 @@ dependencies = [
"rufield-core",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -7532,7 +7687,7 @@ checksum = "6c8ec5e03cc7a435945c81f1b151a2bc5f64f2206bf50150cab0f89981ce8c94"
dependencies = [
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -7654,7 +7809,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -7678,7 +7833,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"thiserror 2.0.18",
"tokio",
"tokio-test",
@@ -7839,7 +7994,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"base16ct 0.2.0",
"der",
"generic-array 0.14.7",
"pkcs8",
@@ -8099,6 +8254,16 @@ dependencies = [
"unsafe-libyaml",
]
[[package]]
name = "serdect"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
dependencies = [
"base16ct 1.0.0",
"serde",
]
[[package]]
name = "serial"
version = "0.4.0"
@@ -8200,7 +8365,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -8211,7 +8376,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -8283,7 +8459,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"digest 0.10.7",
"rand_core 0.6.4",
]
@@ -8520,7 +8696,7 @@ dependencies = [
"percent-encoding",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"smallvec",
"thiserror 2.0.18",
"tokio",
@@ -8558,7 +8734,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
@@ -8581,7 +8757,7 @@ dependencies = [
"bytes",
"chrono",
"crc",
"digest",
"digest 0.10.7",
"dotenvy",
"either",
"futures-channel",
@@ -8602,7 +8778,7 @@ dependencies = [
"rsa",
"serde",
"sha1",
"sha2",
"sha2 0.10.9",
"smallvec",
"sqlx-core",
"stringprep",
@@ -8641,7 +8817,7 @@ dependencies = [
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"smallvec",
"sqlx-core",
"stringprep",
@@ -8677,6 +8853,18 @@ dependencies = [
"uuid",
]
[[package]]
name = "srp"
version = "0.7.0-rc.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b5f622c2d21b826b3501f4c620ccc4696e4a09a6b51727fcb21036dec87c101"
dependencies = [
"crypto-bigint 0.7.5",
"crypto-common 0.2.2",
"digest 0.11.3",
"subtle",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -9087,7 +9275,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"syn 2.0.117",
"tauri-utils",
"thiserror 2.0.18",
@@ -9984,9 +10172,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "ucd-trie"
@@ -10135,6 +10323,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
@@ -10730,7 +10928,7 @@ dependencies = [
"rustix",
"serde",
"serde_derive",
"sha2",
"sha2 0.10.9",
"toml 0.9.12+spec-1.1.0",
"windows-sys 0.61.2",
"zstd 0.13.3",
@@ -11178,7 +11376,7 @@ dependencies = [
"serde",
"serde_json",
"serialport",
"sha2",
"sha2 0.10.9",
"sysinfo",
"tauri",
"tauri-build",
@@ -11232,7 +11430,7 @@ dependencies = [
"midstreamer-scheduler",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -11352,7 +11550,7 @@ dependencies = [
"ruvector-temporal-tensor",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"thiserror 2.0.18",
]
@@ -11378,7 +11576,7 @@ dependencies = [
"ruview-auth",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"subtle",
"tempfile",
"thiserror 1.0.69",
@@ -11420,7 +11618,7 @@ dependencies = [
"ruvector-solver",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"thiserror 2.0.18",
"uuid",
"wifi-densepose-core",
@@ -11452,7 +11650,7 @@ dependencies = [
"ruvector-temporal-tensor",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"tch",
"tempfile",
"thiserror 2.0.18",
@@ -12297,7 +12495,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2",
"sha2 0.10.9",
"soup3",
"tao-macros",
"thiserror 2.0.18",
@@ -12332,6 +12530,18 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x25519-dalek"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
dependencies = [
"curve25519-dalek",
"rand_core 0.6.4",
"serde",
"zeroize",
]
[[package]]
name = "xattr"
version = "1.6.1"
@@ -12444,6 +12654,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerotrie"
+8 -2
View File
@@ -20,8 +20,6 @@ path = "src/lib.rs"
[features]
default = []
# Enables the bounded TCP/HTTP listener and real `_hap._tcp` mDNS advertiser.
# Pair-Setup, Pair-Verify, and encrypted HAP transport remain deliberately
# unavailable until their complete cryptographic phases are implemented.
hap-server = ["dep:httparse", "dep:mdns-sd"]
[dependencies]
@@ -35,6 +33,14 @@ async-trait = "0.1"
uuid = { version = "1", features = ["v4", "serde"] }
ed25519-dalek = "2.1"
tempfile = "3"
chacha20poly1305 = "0.10"
getrandom = "0.2"
hkdf = "0.12"
sha2 = "0.10"
sha2_11 = { package = "sha2", version = "0.11" }
srp = "=0.7.0-rc.3"
x25519-dalek = { version = "2", features = ["static_secrets"] }
zeroize = { version = "1", features = ["derive"] }
httparse = { version = "1", optional = true }
mdns-sd = { version = "0.11", optional = true }
+67 -65
View File
@@ -1,63 +1,41 @@
# homecore-hap
`homecore-hap` is the fail-closed network foundation for HOMECORE's Apple
HomeKit Accessory Protocol bridge (ADR-125). It maps HOMECORE entities to HAP
services and provides the bounded server, persistence, discovery, and request
gating needed by a complete HAP implementation.
`homecore-hap` is HOMECORE's bounded, fail-closed HAP IP accessory server
(ADR-125). It implements the HAP R2 cryptographic pairing and transport
boundary without relying on the broken `hap` 0.1 pre-release crate.
It does **not currently complete Apple Home pairing**. Pairing requests receive
a valid TLV8 `Unavailable` error, and accessory/characteristic endpoints return
HTTP 470 until an authenticated Pair-Verify session exists. There is no
plaintext header, bearer-token, or test credential bypass.
## Security and protocol coverage
## Implemented
- Pair-Setup M1-M6 uses the RFC 5054 3072-bit group with SHA-512, the HAP
compatibility proof construction, HKDF-SHA512, ChaCha20-Poly1305, and
Ed25519 long-term keys.
- Pair-Verify M1-M4 uses ephemeral X25519, strict Ed25519 transcript
verification, HKDF-SHA512, and authenticated encrypted sub-TLVs.
- After successful M4, all HTTP and `EVENT/1.0` traffic uses HAP records:
two-byte little-endian authenticated lengths, at most 1024 plaintext bytes,
independent directional keys, and monotonic 64-bit nonces. Authentication,
replay, truncation, and oversize failures close the connection without an
oracle response.
- `/accessories`, `/characteristics`, and `/pairings` are inaccessible until
Pair-Verify succeeds on that TCP connection. Pairings add/remove/list is
restricted to a currently persisted administrator.
- Accessory identity, Ed25519 seed, SRP salt/verifier, and controller records
are stored in a versioned file using same-directory atomic replacement.
Created Unix directories use mode `0700`, files use `0600`, and permissive,
oversized, symlinked, legacy, or malformed stores fail closed.
- The raw setup code is returned only during first provisioning. Only its SRP
verifier is persisted; `SetupCode` redacts `Debug` output and zeroizes on
drop.
- Removing the last administrator atomically clears every pairing. Live
sessions observe pairing revisions and are revoked, while the removal
response is delivered before the requesting session closes.
- Bounded Tokio TCP lifecycle with connection, header, body, request-time, and
shutdown limits.
- Incremental HTTP/1.1 parsing through `httparse`; duplicate
`Content-Length`, transfer encoding, truncated input, and oversized input
fail closed.
- Versioned controller pairing records with bounded parsing, atomic same-
directory replacement, Unix `0600` files/`0700` created directories, and
refusal to load permissive or symlinked files.
- Controller identifiers, administrator invariants, and Ed25519 public keys
validated through `ed25519-dalek`.
- Session state machine for Connected, Pair-Setup, Pair-Verify, Authenticated,
and Closing. Authentication requires a valid signature from a persisted
controller over the Pair-Verify transcript supplied by the future protocol
phase.
- Real `_hap._tcp.local.` advertisement through `mdns-sd` when
`hap-server` is enabled. `NullAdvertiser` provides deterministic,
network-free tests and deployments.
- HAP-shaped `/accessories`, `/characteristics`, event subscription, and
`EVENT/1.0` flow backed by `HapBridge` snapshots and bounded broadcasts.
These handlers are structurally present but network-inaccessible until
encrypted Pair-Verify is complete.
The server also bounds connections, headers, bodies, request time, shutdown,
TLV sizes, controller counts, setup attempts, and concurrent Pair-Setup.
mDNS advertises the persisted accessory identifier and updates `sf` after
pairing or unpairing.
## Deliberately incomplete protocol phases
The following must land together before this crate may claim Apple Home
interoperability:
1. Pair-Setup M1-M6: SRP-6a proof exchange, setup-code policy, accessory
Ed25519 identity persistence, HKDF derivation, and ChaCha20-Poly1305
encrypted sub-TLVs.
2. Pair-Verify M1-M4: ephemeral X25519 exchange, accessory/controller Ed25519
transcript signatures, HKDF session derivation, and encrypted sub-TLVs.
3. Encrypted HAP transport: length-prefixed frames, independent read/write
ChaCha20-Poly1305 keys and monotonically increasing nonces, with strict
frame limits and connection teardown on authentication failure.
4. Authenticated `/pairings` add/remove/list semantics and live mDNS `sf`
updates.
5. Stable persisted AID/IID allocation and a HOMECORE service-call adapter for
writable characteristics. The present endpoint is read/event-only.
6. Validation against Apple Home or a known-conformant HAP controller,
including pair, restart, event delivery, write, unpair, and re-pair.
No cryptographic primitive should be implemented locally. The remaining work
must use reviewed RustCrypto/PAKE crates and protocol test vectors.
## Server integration
## Provisioning and server integration
```rust,no_run
use std::{net::IpAddr, sync::Arc};
@@ -67,15 +45,20 @@ use homecore_hap::{
};
# async fn run() -> Result<(), Box<dyn std::error::Error>> {
let provisioned = PairingStore::load_or_create(
"/var/lib/homecore-hap/security.json",
)?;
if let Some(setup_code) = provisioned.setup_code.as_ref() {
// Send this once to a trusted local display or provisioning boundary.
println!("HAP setup code: {}", setup_code.expose());
}
let pairings = Arc::new(provisioned.store);
let record = HapServiceRecord::bridge(
"HOMECORE Bridge",
51826,
"AA:BB:CC:DD:EE:FF",
pairings.accessory_id()?,
);
let bridge = HapBridge::new(record);
let pairings = Arc::new(PairingStore::open(
"/var/lib/homecore-hap/pairings.json",
)?);
let advertiser = Arc::new(MdnsSdAdvertiser::new(
"homecore",
"192.168.1.50".parse::<IpAddr>()?,
@@ -89,24 +72,43 @@ let server = start_server(
).await?;
// Feed HOMECORE StateChanged events through bridge.update_accessory(...).
// On process shutdown:
server.shutdown().await?;
# Ok(())
# }
```
The mDNS device ID must equal the persisted accessory ID; startup rejects a
mismatch. Real mDNS also requires a LAN-routable advertised address and
multicast access.
## Validation and remaining interoperability limits
The deterministic suite covers the HAP SRP session-key vector, complete
Pair-Setup and Pair-Verify ceremonies, transcript tampering, wrong proofs,
malformed/replayed/oversized records, atomic restart, last-admin removal, and
a real TCP lifecycle from Pair-Verify through encrypted `/accessories`.
This is protocol-level HAP R2 coverage, not a claim of Apple certification:
- It has not yet been exercised against a current Apple Home controller or
the current commercial MFi specification.
- Transient and split Pair-Setup flags are rejected as `Unavailable`.
- Writable characteristic service calls, timed writes, resource endpoints,
and stable persisted AID/IID allocation are not implemented. The present
characteristic surface is read and event subscription only.
- Operational hardening still depends on protecting the host and the
`0600` security file; no hardware-backed key store is integrated.
Build and test:
```bash
cargo test -p homecore-hap --no-default-features
cargo test -p homecore-hap --features hap-server
cargo clippy -p homecore-hap --all-targets --features hap-server -- -D warnings
```
Real mDNS requires the advertised address to be LAN-routable and the runtime to
have multicast access. Containers normally need host networking or macvlan.
## Decisions
- [ADR-125 — native Apple Home HAP bridge](../../docs/adr/ADR-125-ruview-apple-home-native-hap-bridge.md)
- [ADR-130 — bounded async REST/WebSocket server patterns](../../docs/adr/ADR-130-homecore-rest-websocket-api.md)
- [ADR-161 — server-layer security and explicit HAP deferral](../../docs/adr/ADR-161-homecore-server-layer-security.md)
- [ADR-125 — native Apple Home HAP bridge](../../../docs/adr/ADR-125-ruview-apple-home-native-hap-bridge.md)
- [ADR-130 — bounded async REST/WebSocket server patterns](../../../docs/adr/ADR-130-homecore-rest-websocket-api.md)
- [ADR-161 — server-layer security and honest labeling](../../../docs/adr/ADR-161-homecore-server-layer-security.md)
+253
View File
@@ -0,0 +1,253 @@
//! HAP cryptographic composition over RustCrypto primitives.
#![cfg_attr(not(feature = "hap-server"), allow(dead_code))]
use chacha20poly1305::aead::{Aead, Payload};
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce};
use hkdf::Hkdf;
use sha2::Sha512;
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::error::HapError;
pub(crate) const MAX_RECORD_PLAINTEXT: usize = 1024;
pub(crate) const RECORD_TAG_BYTES: usize = 16;
pub(crate) fn hkdf_sha512(
salt: &[u8],
input_key: &[u8],
info: &[u8],
) -> Result<[u8; 32], HapError> {
let mut output = [0u8; 32];
Hkdf::<Sha512>::new(Some(salt), input_key)
.expand(info, &mut output)
.map_err(|_| HapError::Protocol("HKDF output length is invalid".into()))?;
Ok(output)
}
fn label_nonce(label: &[u8; 8]) -> [u8; 12] {
let mut nonce = [0u8; 12];
nonce[4..].copy_from_slice(label);
nonce
}
pub(crate) fn seal_labeled(
key: &[u8; 32],
label: &[u8; 8],
plaintext: &[u8],
) -> Result<Vec<u8>, HapError> {
seal(key, &label_nonce(label), plaintext, &[])
}
pub(crate) fn open_labeled(
key: &[u8; 32],
label: &[u8; 8],
ciphertext_and_tag: &[u8],
) -> Result<Vec<u8>, HapError> {
open(key, &label_nonce(label), ciphertext_and_tag, &[])
}
fn seal(
key: &[u8; 32],
nonce: &[u8; 12],
plaintext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, HapError> {
ChaCha20Poly1305::new(key.into())
.encrypt(
Nonce::from_slice(nonce),
Payload {
msg: plaintext,
aad,
},
)
.map_err(|_| HapError::Protocol("ChaCha20-Poly1305 encryption failed".into()))
}
fn open(
key: &[u8; 32],
nonce: &[u8; 12],
ciphertext_and_tag: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, HapError> {
ChaCha20Poly1305::new(key.into())
.decrypt(
Nonce::from_slice(nonce),
Payload {
msg: ciphertext_and_tag,
aad,
},
)
.map_err(|_| HapError::Protocol("ChaCha20-Poly1305 authentication failed".into()))
}
#[derive(Zeroize, ZeroizeOnDrop)]
pub(crate) struct SessionKeys {
accessory_to_controller: [u8; 32],
controller_to_accessory: [u8; 32],
}
impl SessionKeys {
pub(crate) fn derive(shared_secret: &[u8; 32]) -> Result<Self, HapError> {
Ok(Self {
accessory_to_controller: hkdf_sha512(
b"Control-Salt",
shared_secret,
b"Control-Read-Encryption-Key",
)?,
controller_to_accessory: hkdf_sha512(
b"Control-Salt",
shared_secret,
b"Control-Write-Encryption-Key",
)?,
})
}
#[cfg(test)]
pub(crate) fn controller_view(&self) -> Self {
Self {
accessory_to_controller: self.controller_to_accessory,
controller_to_accessory: self.accessory_to_controller,
}
}
}
/// Stateful HAP IP record protection. A failed decryption is terminal: callers
/// must close the connection and must never retry with the same counter.
#[derive(Zeroize, ZeroizeOnDrop)]
pub(crate) struct RecordLayer {
read_key: [u8; 32],
write_key: [u8; 32],
read_counter: u64,
write_counter: u64,
}
impl RecordLayer {
pub(crate) fn accessory(keys: SessionKeys) -> Self {
Self {
read_key: keys.controller_to_accessory,
write_key: keys.accessory_to_controller,
read_counter: 0,
write_counter: 0,
}
}
#[cfg(test)]
pub(crate) fn controller(keys: SessionKeys) -> Self {
Self {
read_key: keys.controller_to_accessory,
write_key: keys.accessory_to_controller,
read_counter: 0,
write_counter: 0,
}
}
pub(crate) fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, HapError> {
let mut output = Vec::with_capacity(
plaintext.len() + plaintext.len().div_ceil(MAX_RECORD_PLAINTEXT) * 18,
);
for chunk in plaintext.chunks(MAX_RECORD_PLAINTEXT) {
let length = u16::try_from(chunk.len())
.expect("HAP record chunks never exceed the u16 range")
.to_le_bytes();
let nonce = record_nonce(self.write_counter);
let encrypted = seal(&self.write_key, &nonce, chunk, &length)?;
self.write_counter = self
.write_counter
.checked_add(1)
.ok_or_else(|| HapError::Protocol("HAP write nonce exhausted".into()))?;
output.extend_from_slice(&length);
output.extend_from_slice(&encrypted);
}
Ok(output)
}
pub(crate) fn decrypt(
&mut self,
length_bytes: [u8; 2],
ciphertext_and_tag: &[u8],
) -> Result<Vec<u8>, HapError> {
let length = u16::from_le_bytes(length_bytes) as usize;
if length > MAX_RECORD_PLAINTEXT {
return Err(HapError::Protocol(
"encrypted HAP record exceeds 1024 bytes".into(),
));
}
if ciphertext_and_tag.len() != length + RECORD_TAG_BYTES {
return Err(HapError::Protocol(
"encrypted HAP record length does not match framing".into(),
));
}
let nonce = record_nonce(self.read_counter);
let plaintext = open(&self.read_key, &nonce, ciphertext_and_tag, &length_bytes)?;
self.read_counter = self
.read_counter
.checked_add(1)
.ok_or_else(|| HapError::Protocol("HAP read nonce exhausted".into()))?;
Ok(plaintext)
}
}
fn record_nonce(counter: u64) -> [u8; 12] {
let mut nonce = [0u8; 12];
nonce[4..].copy_from_slice(&counter.to_le_bytes());
nonce
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deterministic_record_vector_and_multiframe_roundtrip() {
let shared = [0x42; 32];
let keys = SessionKeys::derive(&shared).unwrap();
let mut vector_accessory = RecordLayer::accessory(keys);
let vector = vector_accessory.encrypt(b"HAP").unwrap();
assert_eq!(
vector,
[
0x03, 0x00, 0xa2, 0x37, 0x30, 0x29, 0xba, 0xe2, 0xa9, 0xa6, 0xbb, 0x5b, 0xff, 0xed,
0x6a, 0x29, 0x74, 0x12, 0xd1, 0x6d, 0x7a,
]
);
let mut accessory = RecordLayer::accessory(SessionKeys::derive(&shared).unwrap());
let controller_keys = SessionKeys::derive(&shared).unwrap().controller_view();
let mut controller = RecordLayer::controller(controller_keys);
let plaintext = vec![0x5a; 2050];
let encrypted = accessory.encrypt(&plaintext).unwrap();
assert_eq!(&encrypted[..2], &[0, 4]);
let mut offset = 0;
let mut decrypted = Vec::new();
while offset < encrypted.len() {
let length_bytes: [u8; 2] = encrypted[offset..offset + 2].try_into().unwrap();
let length = u16::from_le_bytes(length_bytes) as usize;
let end = offset + 2 + length + RECORD_TAG_BYTES;
decrypted.extend(
controller
.decrypt(length_bytes, &encrypted[offset + 2..end])
.unwrap(),
);
offset = end;
}
assert_eq!(decrypted, plaintext);
}
#[test]
fn replay_tamper_and_oversize_fail_closed() {
let shared = [7; 32];
let mut sender = RecordLayer::accessory(SessionKeys::derive(&shared).unwrap());
let frame = sender.encrypt(b"authenticated").unwrap();
let length: [u8; 2] = frame[..2].try_into().unwrap();
let mut receiver =
RecordLayer::controller(SessionKeys::derive(&shared).unwrap().controller_view());
assert_eq!(
receiver.decrypt(length, &frame[2..]).unwrap(),
b"authenticated"
);
assert!(receiver.decrypt(length, &frame[2..]).is_err());
assert!(receiver
.decrypt(1025u16.to_le_bytes(), &vec![0; 1025 + RECORD_TAG_BYTES])
.is_err());
}
}
+3
View File
@@ -33,6 +33,9 @@ pub enum HapError {
#[error("controller pairing not found: {0}")]
PairingNotFound(String),
#[error("maximum controller pairings reached")]
PairingCapacity,
#[error("insecure permissions on {path}: mode {mode:o}; expected no group/other access")]
InsecurePermissions { path: PathBuf, mode: u32 },
+9 -7
View File
@@ -2,11 +2,10 @@
//!
//! # Network foundation scope
//!
//! The crate provides persisted controller records, a fail-closed session
//! state machine, bounded TLV8 parsing, characteristic event flow, and (with
//! `hap-server`) a bounded TCP/HTTP listener plus real mDNS. It does **not**
//! yet implement SRP Pair-Setup, X25519/HKDF/ChaCha20-Poly1305 Pair-Verify, or
//! encrypted HAP framing, so Apple Home pairing is deliberately unavailable.
//! The crate provides persisted accessory/controller identity, SRP-6a
//! Pair-Setup, X25519/Ed25519 Pair-Verify, encrypted HAP IP framing, bounded
//! TLV8/HTTP parsing, characteristic event flow, and (with `hap-server`) a
//! bounded TCP listener plus real mDNS.
//!
//! # Module layout
//!
@@ -16,7 +15,7 @@
//! | [`mapping`] | `EntityToAccessoryMapper` — HOMECORE entity → HAP |
//! | [`bridge`] | `HapBridge` — owns exposed accessories |
//! | [`mdns`] | `MdnsAdvertiser` trait + `NullAdvertiser` stub |
//! | [`pairing`] | Atomic controller pairing persistence |
//! | [`pairing`] | Atomic accessory identity, setup, and pairing persistence |
//! | [`protocol`] | Bounded TLV8 protocol primitives |
//! | [`ruview`] | `RuViewToHapMapper` — sensing primitives → HAP |
//! | [`session`] | Authenticated request-gating state machine |
@@ -25,9 +24,12 @@
pub mod accessory;
pub mod bridge;
mod crypto;
pub mod error;
pub mod mapping;
pub mod mdns;
mod pair_setup;
mod pair_verify;
pub mod pairing;
pub mod protocol;
pub mod ruview;
@@ -42,7 +44,7 @@ pub use mapping::EntityToAccessoryMapper;
#[cfg(feature = "hap-server")]
pub use mdns::MdnsSdAdvertiser;
pub use mdns::{HapServiceRecord, MdnsAdvertiser, NullAdvertiser};
pub use pairing::{ControllerPairing, PairingStore};
pub use pairing::{ControllerPairing, PairingStore, PairingStoreProvisioning, SetupCode};
pub use ruview::RuViewToHapMapper;
#[cfg(feature = "hap-server")]
pub use server::{start_server, HapServerConfig, HapServerHandle};
+478
View File
@@ -0,0 +1,478 @@
//! Server-side HAP Pair-Setup M1-M6.
#![cfg_attr(not(feature = "hap-server"), allow(dead_code))]
use std::sync::Arc;
use ed25519_dalek::{Signature, Signer, VerifyingKey};
use sha2_11::Sha512;
use srp::ServerG3072;
use zeroize::{Zeroize, Zeroizing};
use crate::crypto::{hkdf_sha512, open_labeled, seal_labeled};
use crate::error::HapError;
use crate::pairing::{ControllerPairing, PairingStore};
use crate::protocol::{
encode_items, error_response, Tlv8, TLV_ENCRYPTED_DATA, TLV_ERROR_AUTHENTICATION,
TLV_ERROR_BUSY, TLV_ERROR_MAX_TRIES, TLV_ERROR_UNAVAILABLE, TLV_FLAGS, TLV_IDENTIFIER,
TLV_METHOD, TLV_PROOF, TLV_PUBLIC_KEY, TLV_SALT, TLV_SIGNATURE, TLV_STATE,
};
const SRP_USERNAME: &[u8] = b"Pair-Setup";
const PAIR_SETUP_METHOD: u8 = 0;
enum Phase {
Idle,
AwaitM3 {
salt: [u8; 16],
verifier: Vec<u8>,
server_secret: Zeroizing<Vec<u8>>,
},
AwaitM5 {
session_key: Zeroizing<Vec<u8>>,
},
}
pub(crate) struct PairSetup {
store: Arc<PairingStore>,
phase: Phase,
owns_global_slot: bool,
}
pub(crate) struct PairSetupResponse {
pub(crate) body: Vec<u8>,
pub(crate) paired: bool,
pub(crate) terminal: bool,
}
impl PairSetup {
pub(crate) fn new(store: Arc<PairingStore>) -> Self {
Self {
store,
phase: Phase::Idle,
owns_global_slot: false,
}
}
pub(crate) fn handle(&mut self, request: &[u8]) -> Result<PairSetupResponse, HapError> {
let tlv = Tlv8::parse(request)?;
let state = tlv
.byte(TLV_STATE)
.ok_or_else(|| HapError::Protocol("Pair-Setup requires one-byte State".into()))?;
match state {
1 => self.m1(&tlv),
3 => self.m3(&tlv),
5 => self.m5(&tlv),
_ => Err(HapError::Protocol(
"Pair-Setup state is out of sequence".into(),
)),
}
}
fn m1(&mut self, tlv: &Tlv8) -> Result<PairSetupResponse, HapError> {
if !matches!(self.phase, Phase::Idle) {
return Err(HapError::Protocol("Pair-Setup M1 was replayed".into()));
}
if tlv.byte(TLV_METHOD) != Some(PAIR_SETUP_METHOD) {
return Ok(response(
error_response(2, TLV_ERROR_UNAVAILABLE),
false,
true,
));
}
if tlv.get(TLV_FLAGS).is_some() {
// Transient/split setup changes the session-key lifecycle and is
// deliberately rejected instead of being partially implemented.
return Ok(response(
error_response(2, TLV_ERROR_UNAVAILABLE),
false,
true,
));
}
if self.store.is_paired()? {
return Ok(response(
error_response(2, TLV_ERROR_UNAVAILABLE),
false,
true,
));
}
if self.store.pair_setup_locked_out() {
return Ok(response(
error_response(2, TLV_ERROR_MAX_TRIES),
false,
true,
));
}
if !self.store.try_begin_pair_setup() {
return Ok(response(error_response(2, TLV_ERROR_BUSY), false, true));
}
self.owns_global_slot = true;
let (salt, verifier) = self.store.setup_record()?;
let mut server_secret = Zeroizing::new(vec![0u8; 64]);
getrandom::getrandom(server_secret.as_mut_slice())
.map_err(|error| HapError::Protocol(format!("generate SRP secret: {error}")))?;
let server = ServerG3072::<Sha512>::new_with_options(false);
let public_key = server.compute_public_ephemeral(&server_secret, &verifier);
self.phase = Phase::AwaitM3 {
salt,
verifier,
server_secret,
};
Ok(response(
encode_items([
(TLV_STATE, [2].as_slice()),
(TLV_PUBLIC_KEY, public_key.as_slice()),
(TLV_SALT, salt.as_slice()),
]),
false,
false,
))
}
fn m3(&mut self, tlv: &Tlv8) -> Result<PairSetupResponse, HapError> {
let Phase::AwaitM3 {
salt,
verifier,
server_secret,
} = std::mem::replace(&mut self.phase, Phase::Idle)
else {
return Err(HapError::Protocol(
"Pair-Setup M3 arrived without M1".into(),
));
};
let result = (|| {
let client_public = required_bounded(tlv, TLV_PUBLIC_KEY, 384, 384, "SRP public key")?;
let client_proof = required_bounded(tlv, TLV_PROOF, 64, 64, "SRP proof")?;
let server = ServerG3072::<Sha512>::new_with_options(false);
let verifier_state = server
.process_reply(
SRP_USERNAME,
&salt,
&server_secret,
&verifier,
client_public,
)
.map_err(|_| HapError::Protocol("invalid SRP public key".into()))?;
let session_key = verifier_state
.verify_client(client_proof)
.map_err(|_| HapError::Protocol("SRP proof rejected".into()))?
.to_vec();
let proof = verifier_state.proof().to_vec();
Ok::<_, HapError>((session_key, proof))
})();
match result {
Ok((session_key, proof)) => {
self.phase = Phase::AwaitM5 {
session_key: Zeroizing::new(session_key),
};
Ok(response(
encode_items([(TLV_STATE, [4].as_slice()), (TLV_PROOF, proof.as_slice())]),
false,
false,
))
}
Err(_) => {
self.store.record_pair_setup_failure();
self.release_slot();
Ok(response(
error_response(4, TLV_ERROR_AUTHENTICATION),
false,
true,
))
}
}
}
fn m5(&mut self, tlv: &Tlv8) -> Result<PairSetupResponse, HapError> {
let Phase::AwaitM5 { session_key } = std::mem::replace(&mut self.phase, Phase::Idle) else {
return Err(HapError::Protocol(
"Pair-Setup M5 arrived without authenticated M3".into(),
));
};
let result = self.finish_m5(tlv, &session_key);
if result.is_err() {
self.store.record_pair_setup_failure();
}
self.release_slot();
match result {
Ok(body) => Ok(response(body, true, true)),
Err(_) => Ok(response(
error_response(6, TLV_ERROR_AUTHENTICATION),
false,
true,
)),
}
}
fn finish_m5(&self, tlv: &Tlv8, session_key: &[u8]) -> Result<Vec<u8>, HapError> {
let encrypted = required_bounded(
tlv,
TLV_ENCRYPTED_DATA,
17,
4096,
"Pair-Setup encrypted data",
)?;
let encryption_key = hkdf_sha512(
b"Pair-Setup-Encrypt-Salt",
session_key,
b"Pair-Setup-Encrypt-Info",
)?;
let mut plaintext = Zeroizing::new(open_labeled(&encryption_key, b"PS-Msg05", encrypted)?);
let sub_tlv = Tlv8::parse(&plaintext)?;
let controller_id_bytes =
required_bounded(&sub_tlv, TLV_IDENTIFIER, 1, 64, "controller identifier")?;
let controller_id = std::str::from_utf8(controller_id_bytes)
.map_err(|_| HapError::Protocol("controller identifier is not UTF-8".into()))?
.to_owned();
let controller_key: [u8; 32] =
required_bounded(&sub_tlv, TLV_PUBLIC_KEY, 32, 32, "controller LTPK")?
.try_into()
.expect("length checked");
let signature_bytes: [u8; 64] =
required_bounded(&sub_tlv, TLV_SIGNATURE, 64, 64, "controller signature")?
.try_into()
.expect("length checked");
let verifying_key = VerifyingKey::from_bytes(&controller_key)
.map_err(|_| HapError::Protocol("controller LTPK is invalid".into()))?;
let controller_x = hkdf_sha512(
b"Pair-Setup-Controller-Sign-Salt",
session_key,
b"Pair-Setup-Controller-Sign-Info",
)?;
let mut controller_info =
Zeroizing::new(Vec::with_capacity(32 + controller_id_bytes.len() + 32));
controller_info.extend_from_slice(&controller_x);
controller_info.extend_from_slice(controller_id_bytes);
controller_info.extend_from_slice(&controller_key);
verifying_key
.verify_strict(&controller_info, &Signature::from_bytes(&signature_bytes))
.map_err(|_| HapError::Protocol("controller signature rejected".into()))?;
let signing_key = self.store.signing_key()?;
let accessory_id = self.store.accessory_id()?;
let accessory_public = signing_key.verifying_key().to_bytes();
let accessory_x = hkdf_sha512(
b"Pair-Setup-Accessory-Sign-Salt",
session_key,
b"Pair-Setup-Accessory-Sign-Info",
)?;
let mut accessory_info = Zeroizing::new(Vec::with_capacity(32 + accessory_id.len() + 32));
accessory_info.extend_from_slice(&accessory_x);
accessory_info.extend_from_slice(accessory_id.as_bytes());
accessory_info.extend_from_slice(&accessory_public);
let accessory_signature = signing_key.sign(&accessory_info).to_bytes();
let mut response_plaintext = Zeroizing::new(encode_items([
(TLV_IDENTIFIER, accessory_id.as_bytes()),
(TLV_PUBLIC_KEY, accessory_public.as_slice()),
(TLV_SIGNATURE, accessory_signature.as_slice()),
]));
let response_encrypted = seal_labeled(&encryption_key, b"PS-Msg06", &response_plaintext)?;
// Commit only after every authentication and response construction
// step has succeeded, and before emitting success-shaped M6.
self.store.add_initial(ControllerPairing {
controller_id,
public_key: controller_key,
admin: true,
})?;
plaintext.zeroize();
response_plaintext.zeroize();
Ok(encode_items([
(TLV_STATE, [6].as_slice()),
(TLV_ENCRYPTED_DATA, response_encrypted.as_slice()),
]))
}
fn release_slot(&mut self) {
if self.owns_global_slot {
self.store.end_pair_setup();
self.owns_global_slot = false;
}
}
}
impl Drop for PairSetup {
fn drop(&mut self) {
self.release_slot();
}
}
fn response(body: Vec<u8>, paired: bool, terminal: bool) -> PairSetupResponse {
PairSetupResponse {
body,
paired,
terminal,
}
}
fn required_bounded<'a>(
tlv: &'a Tlv8,
kind: u8,
min: usize,
max: usize,
name: &str,
) -> Result<&'a [u8], HapError> {
let value = tlv
.get(kind)
.ok_or_else(|| HapError::Protocol(format!("missing {name}")))?;
if !(min..=max).contains(&value.len()) {
return Err(HapError::Protocol(format!(
"{name} must contain {min}..={max} bytes"
)));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use srp::ClientG3072;
fn setup() -> (tempfile::TempDir, Arc<PairingStore>) {
let directory = tempfile::tempdir().unwrap();
let store = PairingStore::create(
directory.path().join("pairings.json"),
crate::pairing::SetupCode::parse("518-26-003").unwrap(),
Some("AA:BB:CC:DD:EE:FF".into()),
)
.unwrap();
(directory, Arc::new(store))
}
#[test]
fn apple_hap_srp_3072_sha512_session_key_vector() {
let decode = |value: &str| {
value
.as_bytes()
.chunks_exact(2)
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
.collect::<Vec<_>>()
};
let salt = decode("BEB25379D1A8581EB5A727673A2441EE");
let a = decode("60975527035CF2AD1989806F0407210BC81EDC04E2762A56AFD529DDDA2D4393");
let b = decode("E487CB59D31AC550471E81F00F6928E01DDA08E974A004F49E61F5D105284D20");
let expected_key = decode(
"5CBC219DB052138EE1148C71CD4498963D682549CE91CA24F098468F06015BEB\
6AF245C2093F98C3651BCA83AB8CAB2B580BBF02184FEFDF26142F73DF95AC50",
);
let client = ClientG3072::<Sha512>::new();
let server = ServerG3072::<Sha512>::new_with_options(false);
let verifier = client.compute_verifier(b"alice", b"password123", &salt);
let a_public = client.compute_public_ephemeral(&a);
let b_public = server.compute_public_ephemeral(&b, &verifier);
let client_state = client
.process_reply(&a, b"alice", b"password123", &salt, &b_public)
.unwrap();
let server_state = server
.process_reply(b"alice", &salt, &b, &verifier, &a_public)
.unwrap();
assert_eq!(
server_state.verify_client(client_state.proof()).unwrap(),
expected_key
);
assert_eq!(
client_state.verify_server(server_state.proof()).unwrap(),
expected_key
);
}
#[test]
fn full_m1_through_m6_persists_only_authenticated_controller() {
let (_directory, store) = setup();
let mut server = PairSetup::new(store.clone());
let m1 = encode_items([(TLV_STATE, [1].as_slice()), (TLV_METHOD, [0].as_slice())]);
let m2 = Tlv8::parse(&server.handle(&m1).unwrap().body).unwrap();
let salt = m2.get(TLV_SALT).unwrap();
let server_public = m2.get(TLV_PUBLIC_KEY).unwrap();
let client = ClientG3072::<Sha512>::new();
let client_secret = [0x31; 48];
let client_state = client
.process_reply(
&client_secret,
SRP_USERNAME,
b"518-26-003",
salt,
server_public,
)
.unwrap();
let client_public = client.compute_public_ephemeral(&client_secret);
let m3 = encode_items([
(TLV_STATE, [3].as_slice()),
(TLV_PUBLIC_KEY, client_public.as_slice()),
(TLV_PROOF, client_state.proof()),
]);
let m4 = Tlv8::parse(&server.handle(&m3).unwrap().body).unwrap();
let session_key = client_state
.verify_server(m4.get(TLV_PROOF).unwrap())
.unwrap();
let controller_signing = SigningKey::from_bytes(&[0x22; 32]);
let controller_public = controller_signing.verifying_key().to_bytes();
let controller_id = b"deterministic-controller";
let controller_x = hkdf_sha512(
b"Pair-Setup-Controller-Sign-Salt",
session_key,
b"Pair-Setup-Controller-Sign-Info",
)
.unwrap();
let mut info = Vec::new();
info.extend_from_slice(&controller_x);
info.extend_from_slice(controller_id);
info.extend_from_slice(&controller_public);
let signature = controller_signing.sign(&info).to_bytes();
let sub_tlv = encode_items([
(TLV_IDENTIFIER, controller_id.as_slice()),
(TLV_PUBLIC_KEY, controller_public.as_slice()),
(TLV_SIGNATURE, signature.as_slice()),
]);
let encryption_key = hkdf_sha512(
b"Pair-Setup-Encrypt-Salt",
session_key,
b"Pair-Setup-Encrypt-Info",
)
.unwrap();
let encrypted = seal_labeled(&encryption_key, b"PS-Msg05", &sub_tlv).unwrap();
let m5 = encode_items([
(TLV_STATE, [5].as_slice()),
(TLV_ENCRYPTED_DATA, encrypted.as_slice()),
]);
let result = server.handle(&m5).unwrap();
assert!(result.paired);
let m6 = Tlv8::parse(&result.body).unwrap();
assert!(open_labeled(
&encryption_key,
b"PS-Msg06",
m6.get(TLV_ENCRYPTED_DATA).unwrap()
)
.is_ok());
assert_eq!(
store
.get("deterministic-controller")
.unwrap()
.unwrap()
.public_key,
controller_public
);
}
#[test]
fn malformed_replayed_and_wrong_proof_requests_fail_closed() {
let (_directory, store) = setup();
let mut server = PairSetup::new(store.clone());
let m1 = encode_items([(TLV_STATE, [1].as_slice()), (TLV_METHOD, [0].as_slice())]);
assert!(!server.handle(&m1).unwrap().paired);
assert!(server.handle(&m1).is_err());
let bad_m3 = encode_items([
(TLV_STATE, [3].as_slice()),
(TLV_PUBLIC_KEY, [1].as_slice()),
(TLV_PROOF, [0u8; 64].as_slice()),
]);
let response = Tlv8::parse(&server.handle(&bad_m3).unwrap().body).unwrap();
assert_eq!(
response.byte(crate::protocol::TLV_ERROR),
Some(TLV_ERROR_AUTHENTICATION)
);
assert!(!store.is_paired().unwrap());
}
}
+332
View File
@@ -0,0 +1,332 @@
//! Server-side HAP Pair-Verify M1-M4.
#![cfg_attr(not(feature = "hap-server"), allow(dead_code))]
use std::sync::Arc;
use ed25519_dalek::{Signature, Signer, VerifyingKey};
use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::{Zeroize, Zeroizing};
use crate::crypto::{hkdf_sha512, open_labeled, seal_labeled, SessionKeys};
use crate::error::HapError;
use crate::pairing::PairingStore;
use crate::protocol::{
encode_items, error_response, Tlv8, TLV_ENCRYPTED_DATA, TLV_ERROR_AUTHENTICATION,
TLV_IDENTIFIER, TLV_PUBLIC_KEY, TLV_SIGNATURE, TLV_STATE,
};
struct AwaitM3 {
controller_public: [u8; 32],
accessory_public: [u8; 32],
shared_secret: Zeroizing<[u8; 32]>,
session_key: Zeroizing<[u8; 32]>,
}
enum Phase {
Idle,
AwaitM3(AwaitM3),
}
pub(crate) struct AuthenticatedSession {
pub(crate) controller_id: String,
pub(crate) admin: bool,
pub(crate) keys: SessionKeys,
}
pub(crate) struct PairVerifyResponse {
pub(crate) body: Vec<u8>,
pub(crate) authenticated: Option<AuthenticatedSession>,
pub(crate) terminal: bool,
}
pub(crate) struct PairVerify {
store: Arc<PairingStore>,
phase: Phase,
}
impl PairVerify {
pub(crate) fn new(store: Arc<PairingStore>) -> Self {
Self {
store,
phase: Phase::Idle,
}
}
pub(crate) fn handle(&mut self, request: &[u8]) -> Result<PairVerifyResponse, HapError> {
let tlv = Tlv8::parse(request)?;
let state = tlv
.byte(TLV_STATE)
.ok_or_else(|| HapError::Protocol("Pair-Verify requires one-byte State".into()))?;
match state {
1 => self.m1(&tlv),
3 => self.m3(&tlv),
_ => Err(HapError::Protocol(
"Pair-Verify state is out of sequence".into(),
)),
}
}
fn m1(&mut self, tlv: &Tlv8) -> Result<PairVerifyResponse, HapError> {
if !matches!(self.phase, Phase::Idle) {
return Err(HapError::Protocol("Pair-Verify M1 was replayed".into()));
}
if !self.store.is_paired()? {
return Ok(PairVerifyResponse {
body: error_response(2, TLV_ERROR_AUTHENTICATION),
authenticated: None,
terminal: true,
});
}
let controller_public: [u8; 32] =
required_exact(tlv, TLV_PUBLIC_KEY, 32, "controller Curve25519 public key")?
.try_into()
.expect("length checked");
let mut secret_bytes = Zeroizing::new([0u8; 32]);
getrandom::getrandom(secret_bytes.as_mut())
.map_err(|error| HapError::Protocol(format!("generate X25519 secret: {error}")))?;
let secret = StaticSecret::from(*secret_bytes);
let accessory_public = PublicKey::from(&secret).to_bytes();
let shared = secret.diffie_hellman(&PublicKey::from(controller_public));
if !shared.was_contributory() {
return Ok(PairVerifyResponse {
body: error_response(2, TLV_ERROR_AUTHENTICATION),
authenticated: None,
terminal: true,
});
}
let shared_secret = Zeroizing::new(shared.to_bytes());
let accessory_id = self.store.accessory_id()?;
let signing_key = self.store.signing_key()?;
let mut accessory_info = Zeroizing::new(Vec::with_capacity(32 + accessory_id.len() + 32));
accessory_info.extend_from_slice(&accessory_public);
accessory_info.extend_from_slice(accessory_id.as_bytes());
accessory_info.extend_from_slice(&controller_public);
let signature = signing_key.sign(&accessory_info).to_bytes();
let plaintext = Zeroizing::new(encode_items([
(TLV_IDENTIFIER, accessory_id.as_bytes()),
(TLV_SIGNATURE, signature.as_slice()),
]));
let session_key = Zeroizing::new(hkdf_sha512(
b"Pair-Verify-Encrypt-Salt",
shared_secret.as_ref(),
b"Pair-Verify-Encrypt-Info",
)?);
let encrypted = seal_labeled(&session_key, b"PV-Msg02", &plaintext)?;
self.phase = Phase::AwaitM3(AwaitM3 {
controller_public,
accessory_public,
shared_secret,
session_key,
});
Ok(PairVerifyResponse {
body: encode_items([
(TLV_STATE, [2].as_slice()),
(TLV_PUBLIC_KEY, accessory_public.as_slice()),
(TLV_ENCRYPTED_DATA, encrypted.as_slice()),
]),
authenticated: None,
terminal: false,
})
}
fn m3(&mut self, tlv: &Tlv8) -> Result<PairVerifyResponse, HapError> {
let Phase::AwaitM3(state) = std::mem::replace(&mut self.phase, Phase::Idle) else {
return Err(HapError::Protocol(
"Pair-Verify M3 arrived without M1".into(),
));
};
match self.finish_m3(tlv, state) {
Ok(authenticated) => Ok(PairVerifyResponse {
body: encode_items([(TLV_STATE, [4].as_slice())]),
authenticated: Some(authenticated),
terminal: true,
}),
Err(_) => Ok(PairVerifyResponse {
body: error_response(4, TLV_ERROR_AUTHENTICATION),
authenticated: None,
terminal: true,
}),
}
}
fn finish_m3(&self, tlv: &Tlv8, state: AwaitM3) -> Result<AuthenticatedSession, HapError> {
let encrypted = tlv
.get(TLV_ENCRYPTED_DATA)
.filter(|value| (17..=4096).contains(&value.len()))
.ok_or_else(|| HapError::Protocol("invalid Pair-Verify encrypted data".into()))?;
let mut plaintext =
Zeroizing::new(open_labeled(&state.session_key, b"PV-Msg03", encrypted)?);
let sub_tlv = Tlv8::parse(&plaintext)?;
let controller_id_bytes = sub_tlv
.get(TLV_IDENTIFIER)
.filter(|value| !value.is_empty() && value.len() <= 64)
.ok_or_else(|| HapError::Protocol("invalid controller identifier".into()))?;
let controller_id = std::str::from_utf8(controller_id_bytes)
.map_err(|_| HapError::Protocol("controller identifier is not UTF-8".into()))?
.to_owned();
let signature_bytes: [u8; 64] =
required_exact(&sub_tlv, TLV_SIGNATURE, 64, "controller signature")?
.try_into()
.expect("length checked");
let pairing = self
.store
.get(&controller_id)?
.ok_or_else(|| HapError::Protocol("unknown controller pairing".into()))?;
let key = VerifyingKey::from_bytes(&pairing.public_key)
.map_err(|_| HapError::Protocol("persisted controller key is invalid".into()))?;
let mut controller_info =
Zeroizing::new(Vec::with_capacity(32 + controller_id_bytes.len() + 32));
controller_info.extend_from_slice(&state.controller_public);
controller_info.extend_from_slice(controller_id_bytes);
controller_info.extend_from_slice(&state.accessory_public);
key.verify_strict(&controller_info, &Signature::from_bytes(&signature_bytes))
.map_err(|_| HapError::Protocol("controller transcript signature rejected".into()))?;
let keys = SessionKeys::derive(&state.shared_secret)?;
plaintext.zeroize();
Ok(AuthenticatedSession {
controller_id,
admin: pairing.admin,
keys,
})
}
}
fn required_exact<'a>(
tlv: &'a Tlv8,
kind: u8,
length: usize,
name: &str,
) -> Result<&'a [u8], HapError> {
tlv.get(kind)
.filter(|value| value.len() == length)
.ok_or_else(|| HapError::Protocol(format!("{name} must contain {length} bytes")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::RecordLayer;
use crate::pairing::{ControllerPairing, SetupCode};
use ed25519_dalek::SigningKey;
fn setup() -> (tempfile::TempDir, Arc<PairingStore>, SigningKey) {
let directory = tempfile::tempdir().unwrap();
let store = PairingStore::create(
directory.path().join("pairings.json"),
SetupCode::parse("518-26-003").unwrap(),
Some("AA:BB:CC:DD:EE:FF".into()),
)
.unwrap();
let controller = SigningKey::from_bytes(&[0x44; 32]);
store
.add_initial(ControllerPairing {
controller_id: "controller-1".into(),
public_key: controller.verifying_key().to_bytes(),
admin: true,
})
.unwrap();
(directory, Arc::new(store), controller)
}
#[test]
fn full_m1_through_m4_authenticates_transcript_and_derives_record_keys() {
let (_directory, store, controller_signing) = setup();
let mut server = PairVerify::new(store.clone());
let controller_secret = StaticSecret::from([0x33; 32]);
let controller_public = PublicKey::from(&controller_secret).to_bytes();
let m1 = encode_items([
(TLV_STATE, [1].as_slice()),
(TLV_PUBLIC_KEY, controller_public.as_slice()),
]);
let m2 = Tlv8::parse(&server.handle(&m1).unwrap().body).unwrap();
let accessory_public: [u8; 32] = m2.get(TLV_PUBLIC_KEY).unwrap().try_into().unwrap();
let shared = controller_secret.diffie_hellman(&PublicKey::from(accessory_public));
let session_key = hkdf_sha512(
b"Pair-Verify-Encrypt-Salt",
shared.as_bytes(),
b"Pair-Verify-Encrypt-Info",
)
.unwrap();
let accessory_sub_tlv = Tlv8::parse(
&open_labeled(
&session_key,
b"PV-Msg02",
m2.get(TLV_ENCRYPTED_DATA).unwrap(),
)
.unwrap(),
)
.unwrap();
let accessory_id = accessory_sub_tlv.get(TLV_IDENTIFIER).unwrap();
let accessory_signature: [u8; 64] = accessory_sub_tlv
.get(TLV_SIGNATURE)
.unwrap()
.try_into()
.unwrap();
let mut accessory_info = Vec::new();
accessory_info.extend_from_slice(&accessory_public);
accessory_info.extend_from_slice(accessory_id);
accessory_info.extend_from_slice(&controller_public);
VerifyingKey::from_bytes(&store.accessory_public_key().unwrap())
.unwrap()
.verify_strict(
&accessory_info,
&Signature::from_bytes(&accessory_signature),
)
.unwrap();
let controller_id = b"controller-1";
let mut controller_info = Vec::new();
controller_info.extend_from_slice(&controller_public);
controller_info.extend_from_slice(controller_id);
controller_info.extend_from_slice(&accessory_public);
let signature = controller_signing.sign(&controller_info).to_bytes();
let sub_tlv = encode_items([
(TLV_IDENTIFIER, controller_id.as_slice()),
(TLV_SIGNATURE, signature.as_slice()),
]);
let encrypted = seal_labeled(&session_key, b"PV-Msg03", &sub_tlv).unwrap();
let m3 = encode_items([
(TLV_STATE, [3].as_slice()),
(TLV_ENCRYPTED_DATA, encrypted.as_slice()),
]);
let result = server.handle(&m3).unwrap();
assert_eq!(Tlv8::parse(&result.body).unwrap().byte(TLV_STATE), Some(4));
let authenticated = result.authenticated.unwrap();
assert_eq!(authenticated.controller_id, "controller-1");
let mut accessory_records = RecordLayer::accessory(authenticated.keys);
let encrypted_record = accessory_records.encrypt(b"response").unwrap();
assert!(!encrypted_record
.windows(8)
.any(|window| window == b"response"));
}
#[test]
fn all_zero_key_replay_and_tamper_fail_closed() {
let (_directory, store, _) = setup();
let mut server = PairVerify::new(store);
let zero_key = encode_items([
(TLV_STATE, [1].as_slice()),
(TLV_PUBLIC_KEY, [0u8; 32].as_slice()),
]);
let response = Tlv8::parse(&server.handle(&zero_key).unwrap().body).unwrap();
assert_eq!(
response.byte(crate::protocol::TLV_ERROR),
Some(TLV_ERROR_AUTHENTICATION)
);
let controller_secret = StaticSecret::from([9; 32]);
let controller_public = PublicKey::from(&controller_secret).to_bytes();
let m1 = encode_items([
(TLV_STATE, [1].as_slice()),
(TLV_PUBLIC_KEY, controller_public.as_slice()),
]);
let _ = server.handle(&m1).unwrap();
assert!(server.handle(&m1).is_err());
let tampered = encode_items([
(TLV_STATE, [3].as_slice()),
(TLV_ENCRYPTED_DATA, [0u8; 17].as_slice()),
]);
let response = server.handle(&tampered).unwrap();
assert!(response.authenticated.is_none());
}
}
+486 -134
View File
@@ -1,31 +1,35 @@
//! Durable controller pairing records.
//!
//! This module persists only long-term controller identities and Ed25519 public
//! keys. It does not implement HAP Pair-Setup or Pair-Verify. Those protocol
//! phases must populate this store only after their cryptographic transcript
//! has been authenticated.
//! Durable HAP accessory identity, setup verifier, and controller pairings.
#![cfg_attr(not(feature = "hap-server"), allow(dead_code))]
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
use std::sync::RwLock;
use ed25519_dalek::VerifyingKey;
use ed25519_dalek::{SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2_11::Sha512;
use srp::ClientG3072;
use tempfile::Builder;
use tokio::sync::watch;
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
use crate::error::HapError;
const STORE_VERSION: u32 = 1;
const STORE_VERSION: u32 = 2;
const MAX_STORE_BYTES: u64 = 1024 * 1024;
const MAX_CONTROLLER_ID_BYTES: usize = 64;
const MAX_CONTROLLERS: usize = 16;
const MAX_PAIR_SETUP_FAILURES: u16 = 100;
const SRP_USERNAME: &[u8] = b"Pair-Setup";
/// A controller authorized by a completed HAP pairing ceremony.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ControllerPairing {
/// HAP controller pairing identifier.
/// Case-sensitive HAP controller pairing identifier.
pub controller_id: String,
/// Controller Ed25519 long-term public key.
pub public_key: [u8; 32],
@@ -52,113 +56,434 @@ impl ControllerPairing {
}
}
/// A newly provisioned HAP setup code.
///
/// The code is returned only when a store is created. The persisted file holds
/// an SRP verifier instead of the raw code. Call [`SetupCode::expose`] only at
/// the operator-facing provisioning boundary.
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct SetupCode {
bytes: [u8; 10],
}
impl SetupCode {
pub fn parse(value: &str) -> Result<Self, HapError> {
let bytes: [u8; 10] = value.as_bytes().try_into().map_err(|_| {
HapError::InvalidPairingRecord("setup code must use the XXX-XX-XXX format".into())
})?;
if bytes[3] != b'-'
|| bytes[6] != b'-'
|| bytes
.iter()
.enumerate()
.any(|(index, byte)| index != 3 && index != 6 && !byte.is_ascii_digit())
{
return Err(HapError::InvalidPairingRecord(
"setup code must use the XXX-XX-XXX format".into(),
));
}
let digits: Vec<u8> = bytes.iter().copied().filter(u8::is_ascii_digit).collect();
if is_trivial_setup_code(&digits) {
return Err(HapError::InvalidPairingRecord(
"setup code is prohibited because it is trivial".into(),
));
}
Ok(Self { bytes })
}
/// Explicitly expose the code for a display, label, or provisioning UI.
pub fn expose(&self) -> &str {
std::str::from_utf8(&self.bytes).expect("validated setup code is ASCII")
}
pub(crate) fn as_bytes(&self) -> &[u8] {
&self.bytes
}
fn generate() -> Result<Self, HapError> {
const RANGE: u32 = 100_000_000;
const ACCEPT_BELOW: u32 = (u32::MAX / RANGE) * RANGE;
loop {
let mut random = [0u8; 4];
getrandom::getrandom(&mut random)
.map_err(|error| HapError::PairingStore(format!("generate setup code: {error}")))?;
let sample = u32::from_le_bytes(random);
if sample >= ACCEPT_BELOW {
continue;
}
let digits = format!("{:08}", sample % RANGE);
if is_trivial_setup_code(digits.as_bytes()) {
continue;
}
return Self::parse(&format!(
"{}-{}-{}",
&digits[..3],
&digits[3..5],
&digits[5..]
));
}
}
}
impl std::fmt::Debug for SetupCode {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("SetupCode([REDACTED])")
}
}
fn is_trivial_setup_code(digits: &[u8]) -> bool {
digits == b"12345678"
|| digits == b"87654321"
|| (digits.len() == 8 && digits.iter().all(|digit| *digit == digits[0]))
}
/// Result of opening an existing store or provisioning a new one.
pub struct PairingStoreProvisioning {
pub store: PairingStore,
/// Present only on first creation. It is never recoverable from the store.
pub setup_code: Option<SetupCode>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredAccessory {
device_id: String,
signing_seed: [u8; 32],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredSetup {
salt: [u8; 16],
verifier: Vec<u8>,
}
impl Drop for StoredAccessory {
fn drop(&mut self) {
self.signing_seed.zeroize();
}
}
impl Drop for StoredSetup {
fn drop(&mut self) {
self.salt.zeroize();
self.verifier.zeroize();
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PairingFile {
version: u32,
accessory: StoredAccessory,
setup: StoredSetup,
controllers: Vec<ControllerPairing>,
}
/// Thread-safe, atomically persisted controller pairing store.
#[derive(Debug, Clone)]
struct StoreState {
accessory: StoredAccessory,
setup: StoredSetup,
controllers: BTreeMap<String, ControllerPairing>,
}
/// Thread-safe, atomically persisted HAP security store.
#[derive(Debug)]
pub struct PairingStore {
path: PathBuf,
controllers: RwLock<BTreeMap<String, ControllerPairing>>,
state: RwLock<StoreState>,
pair_setup_active: AtomicBool,
pair_setup_failures: AtomicU16,
changes: watch::Sender<u64>,
}
impl PairingStore {
/// Open an existing store or create an empty in-memory store.
///
/// Existing files with group/other permission bits on Unix are rejected
/// instead of silently accepting exposed controller keys.
/// Open an existing v2 store.
pub fn open(path: impl Into<PathBuf>) -> Result<Self, HapError> {
let path = path.into();
let controllers = if path.exists() {
load_file(&path)?
} else {
BTreeMap::new()
};
Ok(Self {
path,
controllers: RwLock::new(controllers),
if !path.exists() {
return Err(HapError::PairingStore(format!(
"{} does not exist; use PairingStore::load_or_create for first provisioning",
path.display()
)));
}
Self::from_state(path.clone(), load_file(&path)?)
}
/// Open a store, or securely create identity/setup material and return the
/// one-time setup code.
pub fn load_or_create(path: impl Into<PathBuf>) -> Result<PairingStoreProvisioning, HapError> {
let path = path.into();
if path.exists() {
return Ok(PairingStoreProvisioning {
store: Self::open(path)?,
setup_code: None,
});
}
let setup_code = SetupCode::generate()?;
let state = new_state(&setup_code, None)?;
persist_file(&path, &state, false)?;
Ok(PairingStoreProvisioning {
store: Self::from_state(path, state)?,
setup_code: Some(setup_code),
})
}
/// Deterministic provisioning entry point for an externally generated
/// per-accessory setup code and optional device ID.
pub fn create(
path: impl Into<PathBuf>,
setup_code: SetupCode,
device_id: Option<String>,
) -> Result<Self, HapError> {
let path = path.into();
if path.exists() {
return Err(HapError::PairingStore(format!(
"{} already exists",
path.display()
)));
}
let state = new_state(&setup_code, device_id)?;
persist_file(&path, &state, false)?;
Self::from_state(path, state)
}
fn from_state(path: PathBuf, state: StoreState) -> Result<Self, HapError> {
validate_state(&state)?;
let (changes, _) = watch::channel(0);
Ok(Self {
path,
state: RwLock::new(state),
pair_setup_active: AtomicBool::new(false),
pair_setup_failures: AtomicU16::new(0),
changes,
})
}
/// Path used for durable records.
pub fn path(&self) -> &Path {
&self.path
}
/// Return a deterministic snapshot ordered by controller identifier.
pub fn accessory_id(&self) -> Result<String, HapError> {
Ok(self.read_state()?.accessory.device_id.clone())
}
pub fn accessory_public_key(&self) -> Result<[u8; 32], HapError> {
Ok(self.signing_key()?.verifying_key().to_bytes())
}
pub fn list(&self) -> Result<Vec<ControllerPairing>, HapError> {
Ok(self
.controllers
.read()
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?
.values()
.cloned()
.collect())
Ok(self.read_state()?.controllers.values().cloned().collect())
}
/// Look up one controller.
pub fn get(&self, controller_id: &str) -> Result<Option<ControllerPairing>, HapError> {
Ok(self
.controllers
.read()
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?
.get(controller_id)
.cloned())
Ok(self.read_state()?.controllers.get(controller_id).cloned())
}
/// Whether at least one controller has completed pairing.
pub fn is_paired(&self) -> Result<bool, HapError> {
Ok(!self
.controllers
.read()
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?
.is_empty())
Ok(!self.read_state()?.controllers.is_empty())
}
/// Add a controller and durably commit it before exposing it in memory.
pub fn add(&self, pairing: ControllerPairing) -> Result<(), HapError> {
/// Add the first administrator after a fully authenticated Pair-Setup M5.
pub(crate) fn add_initial(&self, pairing: ControllerPairing) -> Result<(), HapError> {
pairing.validate()?;
let mut guard = self
.controllers
.write()
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?;
if guard.contains_key(&pairing.controller_id) {
return Err(HapError::PairingAlreadyExists(pairing.controller_id));
if !pairing.admin {
return Err(HapError::InvalidPairingRecord(
"the first controller must be an administrator".into(),
));
}
let mut next = guard.clone();
next.insert(pairing.controller_id.clone(), pairing);
persist_file(&self.path, &next)?;
*guard = next;
self.update(|state| {
if !state.controllers.is_empty() {
return Err(HapError::PairingAlreadyExists(
pairing.controller_id.clone(),
));
}
state
.controllers
.insert(pairing.controller_id.clone(), pairing);
Ok(())
})?;
self.pair_setup_failures.store(0, Ordering::Release);
Ok(())
}
/// Remove a controller, refusing to orphan remaining non-admin pairings.
pub fn remove(&self, controller_id: &str) -> Result<(), HapError> {
/// Add or update a pairing according to HAP Add Pairing semantics.
pub(crate) fn upsert(&self, pairing: ControllerPairing) -> Result<(), HapError> {
pairing.validate()?;
self.update(|state| {
if let Some(existing) = state.controllers.get(&pairing.controller_id) {
if existing.public_key != pairing.public_key {
return Err(HapError::InvalidPairingRecord(
"existing pairing public key cannot be replaced".into(),
));
}
} else if state.controllers.len() >= MAX_CONTROLLERS {
return Err(HapError::PairingCapacity);
}
state
.controllers
.insert(pairing.controller_id.clone(), pairing);
Ok(())
})
}
/// Remove a pairing idempotently. Removing the final administrator clears
/// every pairing as required by HAP.
pub(crate) fn remove_hap(&self, controller_id: &str) -> Result<bool, HapError> {
let mut removed = false;
self.update(|state| {
removed = state.controllers.remove(controller_id).is_some();
if removed
&& !state.controllers.is_empty()
&& !state.controllers.values().any(|pairing| pairing.admin)
{
state.controllers.clear();
}
Ok(())
})?;
Ok(removed)
}
pub(crate) fn signing_key(&self) -> Result<SigningKey, HapError> {
Ok(SigningKey::from_bytes(
&self.read_state()?.accessory.signing_seed,
))
}
pub(crate) fn setup_record(&self) -> Result<([u8; 16], Vec<u8>), HapError> {
let state = self.read_state()?;
Ok((state.setup.salt, state.setup.verifier.clone()))
}
pub(crate) fn try_begin_pair_setup(&self) -> bool {
self.pair_setup_active
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
pub(crate) fn end_pair_setup(&self) {
self.pair_setup_active.store(false, Ordering::Release);
}
pub(crate) fn pair_setup_locked_out(&self) -> bool {
self.pair_setup_failures.load(Ordering::Acquire) >= MAX_PAIR_SETUP_FAILURES
}
pub(crate) fn record_pair_setup_failure(&self) {
let _ = self.pair_setup_failures.fetch_update(
Ordering::AcqRel,
Ordering::Acquire,
|attempts| Some(attempts.saturating_add(1)),
);
}
pub(crate) fn subscribe_changes(&self) -> watch::Receiver<u64> {
self.changes.subscribe()
}
fn read_state(&self) -> Result<std::sync::RwLockReadGuard<'_, StoreState>, HapError> {
self.state
.read()
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))
}
fn update(
&self,
mutate: impl FnOnce(&mut StoreState) -> Result<(), HapError>,
) -> Result<(), HapError> {
let mut guard = self
.controllers
.state
.write()
.map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))?;
if !guard.contains_key(controller_id) {
return Err(HapError::PairingNotFound(controller_id.to_owned()));
}
let mut next = guard.clone();
next.remove(controller_id);
if !next.is_empty() && !next.values().any(|pairing| pairing.admin) {
return Err(HapError::InvalidPairingRecord(
"cannot remove the last administrator while pairings remain".into(),
));
}
persist_file(&self.path, &next)?;
mutate(&mut next)?;
validate_state(&next)?;
persist_file(&self.path, &next, true)?;
*guard = next;
self.changes.send_modify(|revision| *revision += 1);
Ok(())
}
}
fn load_file(path: &Path) -> Result<BTreeMap<String, ControllerPairing>, HapError> {
fn new_state(setup_code: &SetupCode, device_id: Option<String>) -> Result<StoreState, HapError> {
let mut signing_seed = [0u8; 32];
let mut salt = [0u8; 16];
getrandom::getrandom(&mut signing_seed)
.and_then(|_| getrandom::getrandom(&mut salt))
.map_err(|error| HapError::PairingStore(format!("generate accessory identity: {error}")))?;
let device_id = match device_id {
Some(device_id) => device_id,
None => generate_device_id()?,
};
let verifier =
ClientG3072::<Sha512>::new().compute_verifier(SRP_USERNAME, setup_code.as_bytes(), &salt);
let state = StoreState {
accessory: StoredAccessory {
device_id,
signing_seed,
},
setup: StoredSetup { salt, verifier },
controllers: BTreeMap::new(),
};
validate_state(&state)?;
Ok(state)
}
fn generate_device_id() -> Result<String, HapError> {
let mut bytes = [0u8; 6];
getrandom::getrandom(&mut bytes)
.map_err(|error| HapError::PairingStore(format!("generate device ID: {error}")))?;
Ok(bytes
.iter()
.map(|byte| format!("{byte:02X}"))
.collect::<Vec<_>>()
.join(":"))
}
fn validate_device_id(device_id: &str) -> Result<(), HapError> {
let parts: Vec<&str> = device_id.split(':').collect();
if parts.len() != 6
|| parts
.iter()
.any(|part| part.len() != 2 || !part.bytes().all(|byte| byte.is_ascii_hexdigit()))
{
return Err(HapError::InvalidPairingRecord(
"accessory device ID must be six colon-separated hexadecimal octets".into(),
));
}
Ok(())
}
fn validate_state(state: &StoreState) -> Result<(), HapError> {
validate_device_id(&state.accessory.device_id)?;
SigningKey::from_bytes(&state.accessory.signing_seed);
if state.setup.verifier.len() != 384 {
return Err(HapError::InvalidPairingRecord(
"SRP verifier must contain exactly 384 bytes".into(),
));
}
if state.controllers.len() > MAX_CONTROLLERS {
return Err(HapError::InvalidPairingRecord(
"too many persisted controller pairings".into(),
));
}
for (id, pairing) in &state.controllers {
pairing.validate()?;
if id != &pairing.controller_id {
return Err(HapError::InvalidPairingRecord(
"controller map key does not match identifier".into(),
));
}
}
if !state.controllers.is_empty() && !state.controllers.values().any(|pairing| pairing.admin) {
return Err(HapError::InvalidPairingRecord(
"persisted pairings have no administrator".into(),
));
}
Ok(())
}
fn load_file(path: &Path) -> Result<StoreState, HapError> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| HapError::PairingStore(format!("metadata {}: {error}", path.display())))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
@@ -174,25 +499,23 @@ fn load_file(path: &Path) -> Result<BTreeMap<String, ControllerPairing>, HapErro
)));
}
validate_permissions(path, &metadata)?;
let mut bytes = Vec::with_capacity(metadata.len() as usize);
let mut bytes = Zeroizing::new(Vec::with_capacity(metadata.len() as usize));
File::open(path)
.and_then(|file| file.take(MAX_STORE_BYTES + 1).read_to_end(&mut bytes))
.and_then(|file| file.take(MAX_STORE_BYTES + 1).read_to_end(bytes.as_mut()))
.map_err(|error| HapError::PairingStore(format!("read {}: {error}", path.display())))?;
if bytes.len() as u64 > MAX_STORE_BYTES {
return Err(HapError::PairingStore(
"pairing store exceeds size limit".into(),
));
}
let file: PairingFile = serde_json::from_slice(&bytes)
let file: PairingFile = serde_json::from_slice(bytes.as_slice())
.map_err(|error| HapError::PairingStore(format!("parse {}: {error}", path.display())))?;
if file.version != STORE_VERSION {
return Err(HapError::PairingStore(format!(
"unsupported pairing store version {}",
"unsupported pairing store version {}; v1 controller-only stores cannot be used because they lack accessory identity and setup material",
file.version
)));
}
let mut controllers = BTreeMap::new();
for pairing in file.controllers {
pairing.validate()?;
@@ -203,31 +526,32 @@ fn load_file(path: &Path) -> Result<BTreeMap<String, ControllerPairing>, HapErro
)));
}
}
if !controllers.is_empty() && !controllers.values().any(|pairing| pairing.admin) {
return Err(HapError::InvalidPairingRecord(
"persisted pairings have no administrator".into(),
));
}
Ok(controllers)
let state = StoreState {
accessory: file.accessory,
setup: file.setup,
controllers,
};
validate_state(&state)?;
Ok(state)
}
fn persist_file(
path: &Path,
controllers: &BTreeMap<String, ControllerPairing>,
) -> Result<(), HapError> {
fn persist_file(path: &Path, state: &StoreState, overwrite: bool) -> Result<(), HapError> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or(Path::new("."));
create_private_dir(parent)?;
let payload = serde_json::to_vec_pretty(&PairingFile {
version: STORE_VERSION,
controllers: controllers.values().cloned().collect(),
})
.map_err(|error| HapError::PairingStore(format!("serialize pairings: {error}")))?;
let payload = Zeroizing::new(
serde_json::to_vec_pretty(&PairingFile {
version: STORE_VERSION,
accessory: state.accessory.clone(),
setup: state.setup.clone(),
controllers: state.controllers.values().cloned().collect(),
})
.map_err(|error| HapError::PairingStore(format!("serialize pairings: {error}")))?,
);
let mut temp = Builder::new()
.prefix(".homecore-hap-pairings-")
.prefix(".homecore-hap-security-")
.tempfile_in(parent)
.map_err(|error| HapError::PairingStore(format!("create temporary store: {error}")))?;
set_private_file_permissions(temp.as_file())?;
@@ -235,18 +559,19 @@ fn persist_file(
.and_then(|_| temp.flush())
.and_then(|_| temp.as_file().sync_all())
.map_err(|error| HapError::PairingStore(format!("write temporary store: {error}")))?;
temp.persist(path).map_err(|error| {
HapError::PairingStore(format!("replace {}: {}", path.display(), error.error))
})?;
#[cfg(unix)]
{
File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| {
HapError::PairingStore(format!("sync {}: {error}", parent.display()))
})?;
if overwrite {
temp.persist(path).map_err(|error| {
HapError::PairingStore(format!("replace {}: {}", path.display(), error.error))
})?;
} else {
temp.persist_noclobber(path).map_err(|error| {
HapError::PairingStore(format!("create {}: {}", path.display(), error.error))
})?;
}
#[cfg(unix)]
File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| HapError::PairingStore(format!("sync {}: {error}", parent.display())))?;
Ok(())
}
@@ -258,13 +583,11 @@ fn create_private_dir(path: &Path) -> Result<(), HapError> {
})?;
}
#[cfg(unix)]
{
if created {
use std::os::unix::fs::PermissionsExt;
if created {
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
HapError::PairingStore(format!("chmod {}: {error}", path.display()))
})?;
}
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
HapError::PairingStore(format!("chmod {}: {error}", path.display()))
})?;
}
Ok(())
}
@@ -299,28 +622,49 @@ fn validate_permissions(path: &Path, metadata: &fs::Metadata) -> Result<(), HapE
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
fn store(directory: &tempfile::TempDir) -> PairingStore {
PairingStore::create(
directory.path().join("pairings.json"),
SetupCode::parse("518-26-003").unwrap(),
Some("AA:BB:CC:DD:EE:FF".into()),
)
.unwrap()
}
fn pairing(id: &str, byte: u8, admin: bool) -> ControllerPairing {
let public_key = SigningKey::from_bytes(&[byte; 32])
.verifying_key()
.to_bytes();
ControllerPairing {
controller_id: id.into(),
public_key,
public_key: SigningKey::from_bytes(&[byte; 32])
.verifying_key()
.to_bytes(),
admin,
}
}
#[test]
fn restart_loads_atomically_persisted_pairings() {
fn first_provisioning_returns_code_but_restart_does_not() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("pairings.json");
let store = PairingStore::open(&path).unwrap();
store.add(pairing("controller-1", 7, true)).unwrap();
drop(store);
let provisioned = PairingStore::load_or_create(&path).unwrap();
let id = provisioned.store.accessory_id().unwrap();
assert!(provisioned.setup_code.is_some());
drop(provisioned);
let reopened = PairingStore::load_or_create(path).unwrap();
assert!(reopened.setup_code.is_none());
assert_eq!(reopened.store.accessory_id().unwrap(), id);
}
let reopened = PairingStore::open(&path).unwrap();
#[test]
fn identity_and_pairings_survive_atomic_restart() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("pairings.json");
let store = store(&directory);
let public_key = store.accessory_public_key().unwrap();
store.add_initial(pairing("controller-1", 7, true)).unwrap();
drop(store);
let reopened = PairingStore::open(path).unwrap();
assert_eq!(reopened.accessory_public_key().unwrap(), public_key);
assert_eq!(
reopened.list().unwrap(),
vec![pairing("controller-1", 7, true)]
@@ -328,10 +672,28 @@ mod tests {
}
#[test]
fn malformed_and_duplicate_records_fail_closed() {
fn prohibited_setup_codes_are_rejected_and_debug_is_redacted() {
assert!(SetupCode::parse("111-11-111").is_err());
assert!(SetupCode::parse("123-45-678").is_err());
let code = SetupCode::parse("518-26-003").unwrap();
assert_eq!(format!("{code:?}"), "SetupCode([REDACTED])");
}
#[test]
fn removing_last_admin_clears_all_pairings() {
let directory = tempfile::tempdir().unwrap();
let store = store(&directory);
store.add_initial(pairing("admin", 1, true)).unwrap();
store.upsert(pairing("member", 2, false)).unwrap();
assert!(store.remove_hap("admin").unwrap());
assert!(store.list().unwrap().is_empty());
}
#[test]
fn malformed_or_legacy_records_fail_closed() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("pairings.json");
fs::write(&path, br#"{"version":1,"controllers":[{"controller_id":"","public_key":[0,1],"admin":true}]}"#).unwrap();
fs::write(&path, br#"{"version":1,"controllers":[]}"#).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -340,23 +702,13 @@ mod tests {
assert!(PairingStore::open(path).is_err());
}
#[test]
fn cannot_remove_last_admin_while_members_remain() {
let directory = tempfile::tempdir().unwrap();
let store = PairingStore::open(directory.path().join("pairings.json")).unwrap();
store.add(pairing("admin", 1, true)).unwrap();
store.add(pairing("member", 2, false)).unwrap();
assert!(store.remove("admin").is_err());
assert_eq!(store.list().unwrap().len(), 2);
}
#[cfg(unix)]
#[test]
fn permissive_existing_file_is_rejected() {
use std::os::unix::fs::PermissionsExt;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("pairings.json");
fs::write(&path, br#"{"version":1,"controllers":[]}"#).unwrap();
let _ = store(&directory);
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
assert!(matches!(
PairingStore::open(path),
+40 -28
View File
@@ -1,4 +1,4 @@
//! Bounded HAP TLV8 primitives and fail-closed pairing responses.
//! Bounded HAP TLV8 primitives.
use std::collections::BTreeMap;
@@ -6,12 +6,21 @@ use crate::error::HapError;
pub const TLV_METHOD: u8 = 0x00;
pub const TLV_IDENTIFIER: u8 = 0x01;
pub const TLV_SALT: u8 = 0x02;
pub const TLV_PUBLIC_KEY: u8 = 0x03;
pub const TLV_PROOF: u8 = 0x04;
pub const TLV_ENCRYPTED_DATA: u8 = 0x05;
pub const TLV_STATE: u8 = 0x06;
pub const TLV_ERROR: u8 = 0x07;
pub const TLV_SIGNATURE: u8 = 0x0a;
pub const TLV_PERMISSIONS: u8 = 0x0b;
pub const TLV_FLAGS: u8 = 0x13;
pub const TLV_SEPARATOR: u8 = 0xff;
pub const TLV_ERROR_UNKNOWN: u8 = 0x01;
pub const TLV_ERROR_AUTHENTICATION: u8 = 0x02;
pub const TLV_ERROR_MAX_PEERS: u8 = 0x04;
pub const TLV_ERROR_MAX_TRIES: u8 = 0x05;
pub const TLV_ERROR_UNAVAILABLE: u8 = 0x06;
pub const TLV_ERROR_BUSY: u8 = 0x07;
@@ -68,34 +77,37 @@ impl Tlv8 {
}
pub fn encode(&self) -> Vec<u8> {
let mut encoded = Vec::new();
for (&kind, value) in &self.values {
if value.is_empty() {
encoded.extend_from_slice(&[kind, 0]);
continue;
}
for chunk in value.chunks(u8::MAX as usize) {
encoded.push(kind);
encoded.push(chunk.len() as u8);
encoded.extend_from_slice(chunk);
}
}
encoded
encode_items(
self.values
.iter()
.map(|(&kind, value)| (kind, value.as_slice())),
)
}
}
/// Standards-shaped response used while cryptographic pairing phases are
/// unavailable. It is a real TLV8 error, never a success-shaped placeholder.
pub fn pairing_unavailable_response(request: &[u8]) -> Result<Vec<u8>, HapError> {
let request = Tlv8::parse(request)?;
let request_state = request
.byte(TLV_STATE)
.ok_or_else(|| HapError::Protocol("pairing request lacks one-byte State".into()))?;
let response_state = request_state.saturating_add(1);
let mut response = Tlv8::default();
response.insert(TLV_STATE, vec![response_state]);
response.insert(TLV_ERROR, vec![TLV_ERROR_UNAVAILABLE]);
Ok(response.encode())
/// Encode ordered TLV8 items, including repeated items separated by a
/// zero-length `Separator` for `/pairings` list responses.
pub fn encode_items<'a>(items: impl IntoIterator<Item = (u8, &'a [u8])>) -> Vec<u8> {
let mut encoded = Vec::new();
for (kind, value) in items {
if value.is_empty() {
encoded.extend_from_slice(&[kind, 0]);
continue;
}
for chunk in value.chunks(u8::MAX as usize) {
encoded.push(kind);
encoded.push(chunk.len() as u8);
encoded.extend_from_slice(chunk);
}
}
encoded
}
pub fn error_response(state: u8, error: u8) -> Vec<u8> {
encode_items([
(TLV_STATE, [state].as_slice()),
(TLV_ERROR, [error].as_slice()),
])
}
#[cfg(test)]
@@ -120,8 +132,8 @@ mod tests {
}
#[test]
fn unavailable_pairing_is_an_explicit_error_not_success() {
let response = pairing_unavailable_response(&[TLV_STATE, 1, 1]).unwrap();
fn error_response_is_not_success_shaped() {
let response = error_response(2, TLV_ERROR_UNAVAILABLE);
let decoded = Tlv8::parse(&response).unwrap();
assert_eq!(decoded.byte(TLV_STATE), Some(2));
assert_eq!(decoded.byte(TLV_ERROR), Some(TLV_ERROR_UNAVAILABLE));
File diff suppressed because it is too large Load Diff
+39 -80
View File
@@ -1,15 +1,8 @@
//! Per-connection HAP authentication state.
//!
//! The transition to [`SessionState::Authenticated`] requires an Ed25519
//! signature from a persisted controller. The caller is responsible for
//! supplying the exact Pair-Verify transcript once X25519/HKDF/ChaCha20-
//! Poly1305 transport is implemented; this crate never substitutes a bearer
//! token or plaintext shortcut.
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
#![cfg_attr(not(feature = "hap-server"), allow(dead_code))]
use crate::crypto::SessionKeys;
use crate::error::HapError;
use crate::pairing::PairingStore;
/// Authentication phase of one TCP connection.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -32,17 +25,15 @@ impl SessionState {
}
}
/// Whether accessory, characteristic, event, and pairing-management
/// endpoints may be processed.
pub fn is_authenticated(&self) -> bool {
matches!(self, Self::Authenticated { .. })
}
}
/// Fail-closed state machine for one HAP connection.
#[derive(Debug, Clone)]
pub struct Session {
state: SessionState,
pending_keys: Option<SessionKeys>,
}
impl Default for Session {
@@ -55,6 +46,7 @@ impl Session {
pub fn new() -> Self {
Self {
state: SessionState::Connected,
pending_keys: None,
}
}
@@ -62,12 +54,7 @@ impl Session {
&self.state
}
pub fn begin_pair_setup(&mut self, already_paired: bool) -> Result<(), HapError> {
if already_paired {
return Err(HapError::Protocol(
"Pair-Setup is unavailable after a controller is paired".into(),
));
}
pub fn begin_pair_setup(&mut self) -> Result<(), HapError> {
self.transition(SessionState::PairSetup)
}
@@ -75,17 +62,11 @@ impl Session {
self.transition(SessionState::PairVerify)
}
/// Authenticate a completed Pair-Verify transcript with the controller's
/// persisted Ed25519 long-term public key.
///
/// This method is intentionally not called by the current network server:
/// the encrypted Pair-Verify transcript is not implemented yet.
pub fn authenticate_pair_verify(
pub(crate) fn authenticate(
&mut self,
controller_id: &str,
signed_transcript: &[u8],
signature: &[u8; 64],
pairings: &PairingStore,
controller_id: String,
admin: bool,
keys: SessionKeys,
) -> Result<(), HapError> {
if !matches!(self.state, SessionState::PairVerify) {
return Err(HapError::InvalidSessionTransition {
@@ -93,25 +74,23 @@ impl Session {
to: "authenticated",
});
}
let pairing = pairings
.get(controller_id)?
.ok_or_else(|| HapError::PairingNotFound(controller_id.to_owned()))?;
let key = VerifyingKey::from_bytes(&pairing.public_key)
.map_err(|_| HapError::InvalidPairingRecord("invalid Ed25519 public key".into()))?;
let signature = Signature::from_bytes(signature);
key.verify(signed_transcript, &signature)
.map_err(|_| HapError::Protocol("Pair-Verify controller signature rejected".into()))?;
self.state = SessionState::Authenticated {
controller_id: pairing.controller_id,
admin: pairing.admin,
controller_id,
admin,
};
self.pending_keys = Some(keys);
Ok(())
}
pub(crate) fn take_session_keys(&mut self) -> Option<SessionKeys> {
self.pending_keys.take()
}
pub fn reset_pairing(&mut self) -> Result<(), HapError> {
match self.state {
SessionState::PairSetup | SessionState::PairVerify => {
self.state = SessionState::Connected;
self.pending_keys = None;
Ok(())
}
_ => Err(HapError::InvalidSessionTransition {
@@ -122,9 +101,17 @@ impl Session {
}
pub fn close(&mut self) {
self.pending_keys = None;
self.state = SessionState::Closing;
}
pub(crate) fn controller_id(&self) -> Option<&str> {
match &self.state {
SessionState::Authenticated { controller_id, .. } => Some(controller_id),
_ => None,
}
}
#[cfg(all(test, feature = "hap-server"))]
pub(crate) fn authenticated_for_test(admin: bool) -> Self {
Self {
@@ -132,6 +119,7 @@ impl Session {
controller_id: "test-controller".into(),
admin,
},
pending_keys: None,
}
}
@@ -151,60 +139,31 @@ impl Session {
#[cfg(test)]
mod tests {
use super::*;
use crate::pairing::ControllerPairing;
use ed25519_dalek::{Signer, SigningKey};
#[test]
fn authenticated_transition_requires_persisted_valid_signature() {
let directory = tempfile::tempdir().unwrap();
let store = PairingStore::open(directory.path().join("pairings.json")).unwrap();
let signing_key = SigningKey::from_bytes(&[42; 32]);
store
.add(ControllerPairing {
controller_id: "controller".into(),
public_key: signing_key.verifying_key().to_bytes(),
admin: true,
})
.unwrap();
let transcript = b"pair-verify transcript supplied by protocol implementation";
let signature = signing_key.sign(transcript).to_bytes();
fn authentication_requires_pair_verify_and_yields_keys_once() {
let mut session = Session::new();
let keys = SessionKeys::derive(&[7; 32]).unwrap();
assert!(session
.authenticate("controller".into(), true, keys)
.is_err());
session.begin_pair_verify().unwrap();
session
.authenticate_pair_verify("controller", transcript, &signature, &store)
.authenticate(
"controller".into(),
true,
SessionKeys::derive(&[7; 32]).unwrap(),
)
.unwrap();
assert!(session.state().is_authenticated());
}
#[test]
fn bad_signature_and_skipped_verify_fail_closed() {
let directory = tempfile::tempdir().unwrap();
let store = PairingStore::open(directory.path().join("pairings.json")).unwrap();
let signing_key = SigningKey::from_bytes(&[9; 32]);
store
.add(ControllerPairing {
controller_id: "controller".into(),
public_key: signing_key.verifying_key().to_bytes(),
admin: true,
})
.unwrap();
let mut session = Session::new();
assert!(session
.authenticate_pair_verify("controller", b"x", &[0; 64], &store)
.is_err());
session.begin_pair_verify().unwrap();
assert!(session
.authenticate_pair_verify("controller", b"x", &[0; 64], &store)
.is_err());
assert!(!session.state().is_authenticated());
assert!(session.take_session_keys().is_some());
assert!(session.take_session_keys().is_none());
}
#[test]
fn pairing_phases_cannot_overlap() {
let mut session = Session::new();
session.begin_pair_setup(false).unwrap();
session.begin_pair_setup().unwrap();
assert!(session.begin_pair_verify().is_err());
session.reset_pairing().unwrap();
session.begin_pair_verify().unwrap();