mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
feat(hap): implement authenticated pairing and transport
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user