diff --git a/docs/adr/ADR-161-homecore-server-layer-security.md b/docs/adr/ADR-161-homecore-server-layer-security.md index 6fea0862..5787e073 100644 --- a/docs/adr/ADR-161-homecore-server-layer-security.md +++ b/docs/adr/ADR-161-homecore-server-layer-security.md @@ -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. diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 5b44a110..9ff7bed5 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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" diff --git a/v2/crates/homecore-hap/Cargo.toml b/v2/crates/homecore-hap/Cargo.toml index 537d196d..84388037 100644 --- a/v2/crates/homecore-hap/Cargo.toml +++ b/v2/crates/homecore-hap/Cargo.toml @@ -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 } diff --git a/v2/crates/homecore-hap/README.md b/v2/crates/homecore-hap/README.md index 5eb54c8d..031ac6a5 100644 --- a/v2/crates/homecore-hap/README.md +++ b/v2/crates/homecore-hap/README.md @@ -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> { +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::()?, @@ -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) diff --git a/v2/crates/homecore-hap/src/crypto.rs b/v2/crates/homecore-hap/src/crypto.rs new file mode 100644 index 00000000..666ccba6 --- /dev/null +++ b/v2/crates/homecore-hap/src/crypto.rs @@ -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::::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, HapError> { + seal(key, &label_nonce(label), plaintext, &[]) +} + +pub(crate) fn open_labeled( + key: &[u8; 32], + label: &[u8; 8], + ciphertext_and_tag: &[u8], +) -> Result, HapError> { + open(key, &label_nonce(label), ciphertext_and_tag, &[]) +} + +fn seal( + key: &[u8; 32], + nonce: &[u8; 12], + plaintext: &[u8], + aad: &[u8], +) -> Result, 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, 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 { + 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, 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, 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()); + } +} diff --git a/v2/crates/homecore-hap/src/error.rs b/v2/crates/homecore-hap/src/error.rs index 223cd737..b67563c2 100644 --- a/v2/crates/homecore-hap/src/error.rs +++ b/v2/crates/homecore-hap/src/error.rs @@ -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 }, diff --git a/v2/crates/homecore-hap/src/lib.rs b/v2/crates/homecore-hap/src/lib.rs index 7b1f8359..f02058bf 100644 --- a/v2/crates/homecore-hap/src/lib.rs +++ b/v2/crates/homecore-hap/src/lib.rs @@ -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}; diff --git a/v2/crates/homecore-hap/src/pair_setup.rs b/v2/crates/homecore-hap/src/pair_setup.rs new file mode 100644 index 00000000..9be92593 --- /dev/null +++ b/v2/crates/homecore-hap/src/pair_setup.rs @@ -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, + server_secret: Zeroizing>, + }, + AwaitM5 { + session_key: Zeroizing>, + }, +} + +pub(crate) struct PairSetup { + store: Arc, + phase: Phase, + owns_global_slot: bool, +} + +pub(crate) struct PairSetupResponse { + pub(crate) body: Vec, + pub(crate) paired: bool, + pub(crate) terminal: bool, +} + +impl PairSetup { + pub(crate) fn new(store: Arc) -> Self { + Self { + store, + phase: Phase::Idle, + owns_global_slot: false, + } + } + + pub(crate) fn handle(&mut self, request: &[u8]) -> Result { + 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 { + 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::::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 { + 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::::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 { + 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, 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, 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) { + 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::>() + }; + let salt = decode("BEB25379D1A8581EB5A727673A2441EE"); + let a = decode("60975527035CF2AD1989806F0407210BC81EDC04E2762A56AFD529DDDA2D4393"); + let b = decode("E487CB59D31AC550471E81F00F6928E01DDA08E974A004F49E61F5D105284D20"); + let expected_key = decode( + "5CBC219DB052138EE1148C71CD4498963D682549CE91CA24F098468F06015BEB\ + 6AF245C2093F98C3651BCA83AB8CAB2B580BBF02184FEFDF26142F73DF95AC50", + ); + let client = ClientG3072::::new(); + let server = ServerG3072::::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::::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()); + } +} diff --git a/v2/crates/homecore-hap/src/pair_verify.rs b/v2/crates/homecore-hap/src/pair_verify.rs new file mode 100644 index 00000000..e705cef6 --- /dev/null +++ b/v2/crates/homecore-hap/src/pair_verify.rs @@ -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, + pub(crate) authenticated: Option, + pub(crate) terminal: bool, +} + +pub(crate) struct PairVerify { + store: Arc, + phase: Phase, +} + +impl PairVerify { + pub(crate) fn new(store: Arc) -> Self { + Self { + store, + phase: Phase::Idle, + } + } + + pub(crate) fn handle(&mut self, request: &[u8]) -> Result { + 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 { + 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 { + 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 { + 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, 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()); + } +} diff --git a/v2/crates/homecore-hap/src/pairing.rs b/v2/crates/homecore-hap/src/pairing.rs index b2d81478..73e6a221 100644 --- a/v2/crates/homecore-hap/src/pairing.rs +++ b/v2/crates/homecore-hap/src/pairing.rs @@ -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 { + 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 = 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 { + 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, +} + +#[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, +} + +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, } -/// Thread-safe, atomically persisted controller pairing store. +#[derive(Debug, Clone)] +struct StoreState { + accessory: StoredAccessory, + setup: StoredSetup, + controllers: BTreeMap, +} + +/// Thread-safe, atomically persisted HAP security store. #[derive(Debug)] pub struct PairingStore { path: PathBuf, - controllers: RwLock>, + state: RwLock, + pair_setup_active: AtomicBool, + pair_setup_failures: AtomicU16, + changes: watch::Sender, } 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) -> Result { 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) -> Result { + 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, + setup_code: SetupCode, + device_id: Option, + ) -> Result { + 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 { + 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 { + 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, 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, 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 { - 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 { + 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 { + Ok(SigningKey::from_bytes( + &self.read_state()?.accessory.signing_seed, + )) + } + + pub(crate) fn setup_record(&self) -> Result<([u8; 16], Vec), 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 { + self.changes.subscribe() + } + + fn read_state(&self) -> Result, 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, HapError> { +fn new_state(setup_code: &SetupCode, device_id: Option) -> Result { + 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::::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 { + 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::>() + .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 { 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, 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, 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, -) -> 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), diff --git a/v2/crates/homecore-hap/src/protocol.rs b/v2/crates/homecore-hap/src/protocol.rs index 53cdbb8c..33e5e19f 100644 --- a/v2/crates/homecore-hap/src/protocol.rs +++ b/v2/crates/homecore-hap/src/protocol.rs @@ -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 { - 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, 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) -> Vec { + 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 { + 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)); diff --git a/v2/crates/homecore-hap/src/server.rs b/v2/crates/homecore-hap/src/server.rs index cf4ae018..0ccecc92 100644 --- a/v2/crates/homecore-hap/src/server.rs +++ b/v2/crates/homecore-hap/src/server.rs @@ -1,9 +1,4 @@ -//! Bounded async TCP/HTTP foundation for HAP. -//! -//! The listener parses plaintext pairing HTTP and has authenticated accessory -//! endpoint handlers ready for a future encrypted transport. Because Pair- -//! Setup, Pair-Verify, and HAP frame encryption are not complete, no network -//! request can currently transition a session to authenticated. +//! Bounded HAP IP server with plaintext pairing and encrypted control sessions. use std::collections::HashSet; use std::net::SocketAddr; @@ -14,16 +9,23 @@ use httparse::Status; use serde_json::{json, Value}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::{oneshot, Semaphore}; +use tokio::sync::{oneshot, Mutex, Semaphore}; use tokio::task::{JoinHandle, JoinSet}; use tokio::time::{timeout, Instant}; use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue}; use crate::bridge::{CharacteristicEvent, ExposedAccessory, HapBridge}; +use crate::crypto::{RecordLayer, RECORD_TAG_BYTES}; use crate::error::HapError; -use crate::mdns::MdnsAdvertiser; -use crate::pairing::PairingStore; -use crate::protocol::pairing_unavailable_response; +use crate::mdns::{HapServiceRecord, MdnsAdvertiser}; +use crate::pair_setup::PairSetup; +use crate::pair_verify::PairVerify; +use crate::pairing::{ControllerPairing, PairingStore}; +use crate::protocol::{ + encode_items, error_response as tlv_error_response, Tlv8, TLV_ERROR_AUTHENTICATION, + TLV_ERROR_MAX_PEERS, TLV_ERROR_UNKNOWN, TLV_IDENTIFIER, TLV_METHOD, TLV_PERMISSIONS, + TLV_PUBLIC_KEY, TLV_SEPARATOR, TLV_STATE, +}; use crate::session::Session; const HAP_JSON: &str = "application/hap+json"; @@ -133,8 +135,19 @@ pub async fn start_server( let mut record = bridge.service_record.clone(); record.port = local_addr.port(); + let persisted_id = pairings.accessory_id()?; + if !record.device_id.eq_ignore_ascii_case(&persisted_id) { + return Err(HapError::Server(format!( + "mDNS device ID {} does not match persisted accessory identity {persisted_id}", + record.device_id + ))); + } record.paired = pairings.is_paired()?; advertiser.advertise(&record).await?; + let discovery = Arc::new(DiscoveryState { + advertiser, + record: Mutex::new(record), + }); let (shutdown_tx, shutdown_rx) = oneshot::channel(); let task_config = config.clone(); @@ -143,8 +156,7 @@ pub async fn start_server( task_config, bridge, pairings, - advertiser, - record.instance_name, + discovery, shutdown_rx, )); Ok(HapServerHandle { @@ -164,8 +176,7 @@ async fn run_listener( config: HapServerConfig, bridge: HapBridge, pairings: Arc, - advertiser: Arc, - instance_name: String, + discovery: Arc, mut shutdown: oneshot::Receiver<()>, ) -> Result<(), HapError> { let permits = Arc::new(Semaphore::new(config.max_connections)); @@ -189,11 +200,12 @@ async fn run_listener( Ok((stream, peer)) => { let bridge = bridge.clone(); let pairings = pairings.clone(); + let discovery = discovery.clone(); let limits = config.clone(); connections.spawn(async move { let _permit = permit; if let Err(error) = - serve_connection(stream, peer, limits, bridge, pairings).await + serve_connection(stream, peer, limits, bridge, pairings, discovery).await { tracing::debug!(%peer, %error, "HAP connection closed"); } @@ -216,7 +228,32 @@ async fn run_listener( break; } } - advertiser.retract(&instance_name).await + discovery.retract().await +} + +struct DiscoveryState { + advertiser: Arc, + record: Mutex, +} + +impl DiscoveryState { + async fn set_paired(&self, paired: bool) -> Result<(), HapError> { + let mut record = self.record.lock().await; + if record.paired == paired { + return Ok(()); + } + self.advertiser.retract(&record.instance_name).await?; + let mut next = record.clone(); + next.paired = paired; + self.advertiser.advertise(&next).await?; + *record = next; + Ok(()) + } + + async fn retract(&self) -> Result<(), HapError> { + let record = self.record.lock().await; + self.advertiser.retract(&record.instance_name).await + } } async fn serve_connection( @@ -225,41 +262,62 @@ async fn serve_connection( config: HapServerConfig, bridge: HapBridge, pairings: Arc, + discovery: Arc, ) -> Result<(), HapError> { let mut buffer = ConnectionBuffer::default(); let mut session = Session::new(); + let mut pair_setup = PairSetup::new(pairings.clone()); + let mut pair_verify = PairVerify::new(pairings.clone()); + let mut record_layer = None; let mut subscriptions = HashSet::new(); let mut events = bridge.subscribe_events(); + let mut pairing_changes = pairings.subscribe_changes(); loop { tokio::select! { request = timeout( config.request_timeout, - read_request(&mut stream, &mut buffer, &config), + read_request(&mut stream, &mut record_layer, &mut buffer, &config), ) => { let request = match request { Ok(Ok(Some(request))) => request, Ok(Ok(None)) => break, + Ok(Err(RequestReadError::Authentication)) => break, Ok(Err(error)) => { let response = error_response(&error); - write_response(&mut stream, response).await?; + write_response(&mut stream, record_layer.as_mut(), response).await?; break; } Err(_) => { - write_response(&mut stream, Response::plain(408, b"request timeout".to_vec())).await?; + write_response( + &mut stream, + record_layer.as_mut(), + Response::plain(408, b"request timeout".to_vec()), + ).await?; break; } }; let close = request.connection_close; - let response = dispatch_request( + let dispatched = dispatch_request( request, &mut session, + (&mut pair_setup, &mut pair_verify), &bridge, &pairings, + &discovery, &mut subscriptions, - ); - write_response(&mut stream, response).await?; - if close { + ).await; + write_response( + &mut stream, + record_layer.as_mut(), + dispatched.response, + ).await?; + if record_layer.is_none() { + if let Some(keys) = session.take_session_keys() { + record_layer = Some(RecordLayer::accessory(keys)); + } + } + if close || dispatched.close_after_response { break; } } @@ -267,7 +325,10 @@ async fn serve_connection( match event { Ok(event) => { if let Some(payload) = event_payload(&bridge, &event, &subscriptions) { - write_event(&mut stream, payload).await?; + let Some(records) = record_layer.as_mut() else { + break; + }; + write_event(&mut stream, records, payload).await?; } } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { @@ -276,6 +337,17 @@ async fn serve_connection( Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } + changed = pairing_changes.changed(), if session.state().is_authenticated() => { + if changed.is_err() { + break; + } + let Some(controller_id) = session.controller_id() else { + break; + }; + if pairings.get(controller_id)?.is_none() { + break; + } + } } } session.close(); @@ -297,6 +369,7 @@ struct ConnectionBuffer { async fn read_request( stream: &mut TcpStream, + record_layer: &mut Option, buffer: &mut ConnectionBuffer, config: &HapServerConfig, ) -> Result, RequestReadError> { @@ -307,19 +380,15 @@ async fn read_request( if buffer.bytes.len() >= config.max_header_bytes { return Err(RequestReadError::HeadersTooLarge); } - let mut chunk = [0u8; 2048]; - let read = stream - .read(&mut chunk) - .await - .map_err(RequestReadError::Io)?; - if read == 0 { + let chunk = read_transport_chunk(stream, record_layer).await?; + if chunk.is_empty() { return if buffer.bytes.is_empty() { Ok(None) } else { Err(RequestReadError::Malformed("truncated HTTP headers")) }; } - buffer.bytes.extend_from_slice(&chunk[..read]); + buffer.bytes.extend_from_slice(&chunk); }; if header_end > config.max_header_bytes { return Err(RequestReadError::HeadersTooLarge); @@ -381,16 +450,11 @@ async fn read_request( .checked_add(content_length) .ok_or(RequestReadError::BodyTooLarge)?; while buffer.bytes.len() < request_end { - let remaining = request_end - buffer.bytes.len(); - let mut chunk = vec![0u8; remaining.min(8192)]; - let read = stream - .read(&mut chunk) - .await - .map_err(RequestReadError::Io)?; - if read == 0 { + let chunk = read_transport_chunk(stream, record_layer).await?; + if chunk.is_empty() { return Err(RequestReadError::Malformed("truncated HTTP body")); } - buffer.bytes.extend_from_slice(&chunk[..read]); + buffer.bytes.extend_from_slice(&chunk); } let body = buffer.bytes[header_end..request_end].to_vec(); buffer.bytes.drain(..request_end); @@ -402,6 +466,46 @@ async fn read_request( })) } +async fn read_transport_chunk( + stream: &mut TcpStream, + record_layer: &mut Option, +) -> Result, RequestReadError> { + let Some(records) = record_layer.as_mut() else { + let mut chunk = vec![0u8; 2048]; + let read = stream + .read(&mut chunk) + .await + .map_err(RequestReadError::Io)?; + chunk.truncate(read); + return Ok(chunk); + }; + + let mut length_bytes = [0u8; 2]; + let first = stream + .read(&mut length_bytes[..1]) + .await + .map_err(RequestReadError::Io)?; + if first == 0 { + return Ok(Vec::new()); + } + stream + .read_exact(&mut length_bytes[1..]) + .await + .map_err(|_| RequestReadError::Authentication)?; + let length = u16::from_le_bytes(length_bytes) as usize; + if length > crate::crypto::MAX_RECORD_PLAINTEXT { + return Err(RequestReadError::Authentication); + } + let mut encrypted = vec![0u8; length + RECORD_TAG_BYTES]; + stream + .read_exact(&mut encrypted) + .await + .map_err(|_| RequestReadError::Authentication)?; + records + .decrypt(length_bytes, &encrypted) + .map_err(|_| RequestReadError::Authentication) +} + fn find_header_end(bytes: &[u8]) -> Option { bytes.windows(4).position(|window| window == b"\r\n\r\n") } @@ -413,6 +517,7 @@ enum RequestReadError { MalformedOwned(String), HeadersTooLarge, BodyTooLarge, + Authentication, } impl std::fmt::Display for RequestReadError { @@ -423,6 +528,7 @@ impl std::fmt::Display for RequestReadError { Self::MalformedOwned(message) => formatter.write_str(message), Self::HeadersTooLarge => formatter.write_str("HTTP headers too large"), Self::BodyTooLarge => formatter.write_str("HTTP body too large"), + Self::Authentication => formatter.write_str("encrypted HAP record rejected"), } } } @@ -459,60 +565,245 @@ impl Response { } } -fn dispatch_request( +struct DispatchResult { + response: Response, + close_after_response: bool, +} + +impl DispatchResult { + fn keep(response: Response) -> Self { + Self { + response, + close_after_response: false, + } + } + + fn close(response: Response) -> Self { + Self { + response, + close_after_response: true, + } + } +} + +async fn dispatch_request( request: Request, session: &mut Session, + pair_protocols: (&mut PairSetup, &mut PairVerify), bridge: &HapBridge, - pairings: &PairingStore, + pairings: &Arc, + discovery: &DiscoveryState, subscriptions: &mut HashSet<(u64, u64)>, -) -> Response { +) -> DispatchResult { + let (pair_setup, pair_verify) = pair_protocols; match ( request.method.as_str(), request.target.split('?').next().unwrap_or(""), ) { ("POST", "/pair-setup") => { - let _ = session.begin_pair_setup(pairings.is_paired().unwrap_or(true)); - let response = pairing_unavailable_response(&request.body); - let _ = session.reset_pairing(); - match response { - Ok(body) => Response { - status: 200, - content_type: HAP_TLV, - body, - }, - Err(error) => Response::plain(400, error.to_string().into_bytes()), + if request_state(&request.body) == Some(1) && session.begin_pair_setup().is_err() { + return DispatchResult::close(Response::plain( + 400, + b"invalid Pair-Setup session transition".to_vec(), + )); + } + match pair_setup.handle(&request.body) { + Ok(result) => { + if result.paired { + if let Err(error) = discovery.set_paired(true).await { + tracing::warn!(%error, "paired state persisted but mDNS update failed"); + } + } + if result.terminal { + let _ = session.reset_pairing(); + } + DispatchResult::keep(Response { + status: 200, + content_type: HAP_TLV, + body: result.body, + }) + } + Err(error) => { + DispatchResult::close(Response::plain(400, error.to_string().into_bytes())) + } } } ("POST", "/pair-verify") => { - let _ = session.begin_pair_verify(); - let response = pairing_unavailable_response(&request.body); - let _ = session.reset_pairing(); - match response { - Ok(body) => Response { - status: 200, - content_type: HAP_TLV, - body, - }, - Err(error) => Response::plain(400, error.to_string().into_bytes()), + if request_state(&request.body) == Some(1) && session.begin_pair_verify().is_err() { + return DispatchResult::close(Response::plain( + 400, + b"invalid Pair-Verify session transition".to_vec(), + )); + } + match pair_verify.handle(&request.body) { + Ok(result) => { + if let Some(authenticated) = result.authenticated { + if let Err(error) = session.authenticate( + authenticated.controller_id, + authenticated.admin, + authenticated.keys, + ) { + return DispatchResult::close(Response::plain( + 400, + error.to_string().into_bytes(), + )); + } + } else if result.terminal { + let _ = session.reset_pairing(); + } + DispatchResult::keep(Response { + status: 200, + content_type: HAP_TLV, + body: result.body, + }) + } + Err(error) => { + DispatchResult::close(Response::plain(400, error.to_string().into_bytes())) + } } } - _ if !session.state().is_authenticated() => Response::json( + _ if !session.state().is_authenticated() => DispatchResult::keep(Response::json( 470, json!({"status": -70401, "message": "Connection Authorization Required"}), - ), - ("GET", "/accessories") => Response::json(200, accessories_json(bridge)), - ("GET", "/characteristics") => characteristics_response(&request.target, bridge), - ("PUT", "/characteristics") => { - characteristic_subscription_response(&request.body, subscriptions) + )), + ("GET", "/accessories") => { + DispatchResult::keep(Response::json(200, accessories_json(bridge))) } - ("POST", "/pairings") => Response::json( - 501, - json!({"status": -70406, "message": "Pairings management awaits encrypted transport"}), - ), - _ => Response::plain(404, b"not found".to_vec()), + ("GET", "/characteristics") => { + DispatchResult::keep(characteristics_response(&request.target, bridge)) + } + ("PUT", "/characteristics") => DispatchResult::keep(characteristic_subscription_response( + &request.body, + subscriptions, + )), + ("POST", "/pairings") => { + pairings_response(&request.body, session, pairings, discovery).await + } + _ => DispatchResult::keep(Response::plain(404, b"not found".to_vec())), } } +fn request_state(body: &[u8]) -> Option { + Tlv8::parse(body).ok()?.byte(TLV_STATE) +} + +async fn pairings_response( + body: &[u8], + session: &Session, + pairings: &PairingStore, + discovery: &DiscoveryState, +) -> DispatchResult { + let response = |body| Response { + status: 200, + content_type: HAP_TLV, + body, + }; + let Some(controller_id) = session.controller_id() else { + return DispatchResult::close(response(tlv_error_response(2, TLV_ERROR_AUTHENTICATION))); + }; + let authorized = pairings + .get(controller_id) + .ok() + .flatten() + .is_some_and(|pairing| pairing.admin); + if !authorized { + return DispatchResult::keep(response(tlv_error_response(2, TLV_ERROR_AUTHENTICATION))); + } + let Ok(tlv) = Tlv8::parse(body) else { + return DispatchResult::keep(response(tlv_error_response(2, TLV_ERROR_UNKNOWN))); + }; + if tlv.byte(TLV_STATE) != Some(1) { + return DispatchResult::keep(response(tlv_error_response(2, TLV_ERROR_UNKNOWN))); + } + + match tlv.byte(TLV_METHOD) { + Some(3) => { + let Some(identifier) = pairing_identifier(&tlv) else { + return DispatchResult::keep(response(tlv_error_response(2, TLV_ERROR_UNKNOWN))); + }; + let Some(public_key) = tlv + .get(TLV_PUBLIC_KEY) + .and_then(|value| <[u8; 32]>::try_from(value).ok()) + else { + return DispatchResult::keep(response(tlv_error_response(2, TLV_ERROR_UNKNOWN))); + }; + let Some(admin) = tlv + .byte(TLV_PERMISSIONS) + .and_then(|permission| match permission { + 0 => Some(false), + 1 => Some(true), + _ => None, + }) + else { + return DispatchResult::keep(response(tlv_error_response(2, TLV_ERROR_UNKNOWN))); + }; + let pairing = ControllerPairing { + controller_id: identifier, + public_key, + admin, + }; + match pairings.upsert(pairing) { + Ok(()) => { + DispatchResult::keep(response(encode_items([(TLV_STATE, [2].as_slice())]))) + } + Err(HapError::PairingCapacity) => { + DispatchResult::keep(response(tlv_error_response(2, TLV_ERROR_MAX_PEERS))) + } + Err(_) => DispatchResult::keep(response(tlv_error_response(2, TLV_ERROR_UNKNOWN))), + } + } + Some(4) => { + let Some(identifier) = pairing_identifier(&tlv) else { + return DispatchResult::keep(response(tlv_error_response(2, TLV_ERROR_UNKNOWN))); + }; + if pairings.remove_hap(&identifier).is_err() { + return DispatchResult::keep(response(tlv_error_response(2, TLV_ERROR_UNKNOWN))); + } + let paired = pairings.is_paired().unwrap_or(true); + if let Err(error) = discovery.set_paired(paired).await { + tracing::warn!(%error, "pairing removal persisted but mDNS update failed"); + } + let removed_current = pairings.get(controller_id).ok().flatten().is_none(); + let result = response(encode_items([(TLV_STATE, [2].as_slice())])); + if removed_current { + DispatchResult::close(result) + } else { + DispatchResult::keep(result) + } + } + Some(5) => { + let Ok(records) = pairings.list() else { + return DispatchResult::keep(response(tlv_error_response(2, TLV_ERROR_UNKNOWN))); + }; + let mut body = encode_items([(TLV_STATE, [2].as_slice())]); + for (index, pairing) in records.iter().enumerate() { + if index != 0 { + body.extend_from_slice(&[TLV_SEPARATOR, 0]); + } + body.extend_from_slice(&encode_items([ + (TLV_IDENTIFIER, pairing.controller_id.as_bytes()), + (TLV_PUBLIC_KEY, pairing.public_key.as_slice()), + (TLV_PERMISSIONS, [u8::from(pairing.admin)].as_slice()), + ])); + } + DispatchResult::keep(response(body)) + } + _ => DispatchResult::keep(response(tlv_error_response(2, TLV_ERROR_UNKNOWN))), + } +} + +fn pairing_identifier(tlv: &Tlv8) -> Option { + let value = tlv.get(TLV_IDENTIFIER)?; + if value.is_empty() || value.len() > 64 { + return None; + } + let identifier = std::str::from_utf8(value).ok()?; + if identifier.chars().any(char::is_control) { + return None; + } + Some(identifier.to_owned()) +} + fn accessories_json(bridge: &HapBridge) -> Value { let accessories = indexed_accessories(bridge); let mut output = vec![json!({ @@ -715,7 +1006,11 @@ fn characteristic_type(kind: HapCharacteristic) -> &'static str { } } -async fn write_response(stream: &mut TcpStream, response: Response) -> Result<(), HapError> { +async fn write_response( + stream: &mut TcpStream, + records: Option<&mut RecordLayer>, + response: Response, +) -> Result<(), HapError> { let reason = match response.status { 200 => "OK", 204 => "No Content", @@ -726,47 +1021,60 @@ async fn write_response(stream: &mut TcpStream, response: Response) -> Result<() 413 => "Payload Too Large", 431 => "Request Header Fields Too Large", 470 => "Connection Authorization Required", - 501 => "Not Implemented", _ => "Error", }; - let header = format!( + let mut message = format!( "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n\r\n", response.status, reason, response.content_type, response.body.len() - ); - stream - .write_all(header.as_bytes()) - .await - .map_err(|error| HapError::Server(format!("write response header: {error}")))?; - stream - .write_all(&response.body) - .await - .map_err(|error| HapError::Server(format!("write response body: {error}"))) + ) + .into_bytes(); + message.extend_from_slice(&response.body); + write_transport(stream, records, &message).await } -async fn write_event(stream: &mut TcpStream, body: Vec) -> Result<(), HapError> { - let header = format!( +async fn write_event( + stream: &mut TcpStream, + records: &mut RecordLayer, + body: Vec, +) -> Result<(), HapError> { + let mut message = format!( "EVENT/1.0 200 OK\r\nContent-Type: {HAP_JSON}\r\nContent-Length: {}\r\n\r\n", body.len() - ); + ) + .into_bytes(); + message.extend_from_slice(&body); + write_transport(stream, Some(records), &message).await +} + +async fn write_transport( + stream: &mut TcpStream, + records: Option<&mut RecordLayer>, + plaintext: &[u8], +) -> Result<(), HapError> { + let output = match records { + Some(records) => records.encrypt(plaintext)?, + None => plaintext.to_vec(), + }; stream - .write_all(header.as_bytes()) + .write_all(&output) .await - .map_err(|error| HapError::Server(format!("write event header: {error}")))?; - stream - .write_all(&body) - .await - .map_err(|error| HapError::Server(format!("write event body: {error}"))) + .map_err(|error| HapError::Server(format!("write HAP transport: {error}"))) } #[cfg(test)] mod tests { use super::*; + use crate::crypto::{hkdf_sha512, open_labeled, seal_labeled, SessionKeys}; use crate::mdns::{HapServiceRecord, NullAdvertiser}; + use crate::pairing::SetupCode; + use crate::protocol::{TLV_ENCRYPTED_DATA, TLV_SIGNATURE}; + use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use homecore::entity::{EntityId, State}; use homecore::event::Context; + use x25519_dalek::{PublicKey, StaticSecret}; fn bridge() -> HapBridge { let bridge = HapBridge::new(HapServiceRecord::bridge( @@ -787,8 +1095,14 @@ mod tests { async fn server() -> (HapServerHandle, tempfile::TempDir) { let directory = tempfile::tempdir().unwrap(); - let pairings = - Arc::new(PairingStore::open(directory.path().join("pairings.json")).unwrap()); + let pairings = Arc::new( + PairingStore::create( + directory.path().join("pairings.json"), + SetupCode::parse("518-26-003").unwrap(), + Some("AA:BB:CC:DD:EE:FF".into()), + ) + .unwrap(), + ); let config = HapServerConfig { bind_addr: "127.0.0.1:0".parse().unwrap(), request_timeout: Duration::from_secs(1), @@ -810,6 +1124,96 @@ mod tests { response } + async fn paired_server() -> (HapServerHandle, tempfile::TempDir, SigningKey) { + let directory = tempfile::tempdir().unwrap(); + let pairings = Arc::new( + 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(&[0x42; 32]); + pairings + .add_initial(ControllerPairing { + controller_id: "network-controller".into(), + public_key: controller.verifying_key().to_bytes(), + admin: true, + }) + .unwrap(); + let config = HapServerConfig { + bind_addr: "127.0.0.1:0".parse().unwrap(), + request_timeout: Duration::from_secs(1), + shutdown_timeout: Duration::from_secs(1), + ..HapServerConfig::default() + }; + let handle = start_server(config, bridge(), pairings, Arc::new(NullAdvertiser)) + .await + .unwrap(); + (handle, directory, controller) + } + + async fn post_tlv(stream: &mut TcpStream, path: &str, body: &[u8]) -> Vec { + let request = format!( + "POST {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: {HAP_TLV}\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + stream.write_all(request.as_bytes()).await.unwrap(); + stream.write_all(body).await.unwrap(); + read_plain_http(stream).await + } + + async fn read_plain_http(stream: &mut TcpStream) -> Vec { + let mut response = Vec::new(); + while find_header_end(&response).is_none() { + let mut byte = [0u8; 1]; + stream.read_exact(&mut byte).await.unwrap(); + response.push(byte[0]); + } + let header_end = find_header_end(&response).unwrap() + 4; + let header = std::str::from_utf8(&response[..header_end]).unwrap(); + let length = header + .lines() + .find_map(|line| { + line.strip_prefix("Content-Length: ") + .and_then(|value| value.parse::().ok()) + }) + .unwrap(); + response.resize(header_end + length, 0); + stream + .read_exact(&mut response[header_end..]) + .await + .unwrap(); + response + } + + async fn read_encrypted_http(stream: &mut TcpStream, records: &mut RecordLayer) -> Vec { + let mut plaintext = Vec::new(); + loop { + let mut length = [0u8; 2]; + stream.read_exact(&mut length).await.unwrap(); + let payload_length = u16::from_le_bytes(length) as usize; + let mut encrypted = vec![0u8; payload_length + RECORD_TAG_BYTES]; + stream.read_exact(&mut encrypted).await.unwrap(); + plaintext.extend_from_slice(&records.decrypt(length, &encrypted).unwrap()); + if let Some(header_position) = find_header_end(&plaintext) { + let header_end = header_position + 4; + let header = std::str::from_utf8(&plaintext[..header_end]).unwrap(); + let content_length = header + .lines() + .find_map(|line| { + line.strip_prefix("Content-Length: ") + .and_then(|value| value.parse::().ok()) + }) + .unwrap(); + if plaintext.len() >= header_end + content_length { + return plaintext; + } + } + } + } + #[tokio::test] async fn lifecycle_binds_and_shuts_down() { let (server, _directory) = server().await; @@ -840,15 +1244,108 @@ mod tests { } #[tokio::test] - async fn pairing_endpoint_returns_explicit_unavailable_tlv() { + async fn pair_setup_m1_returns_real_srp_challenge() { let (server, _directory) = server().await; let response = exchange( server.local_addr(), - b"POST /pair-setup HTTP/1.1\r\nHost: localhost\r\nContent-Length: 3\r\nConnection: close\r\n\r\n\x06\x01\x01", + b"POST /pair-setup HTTP/1.1\r\nHost: localhost\r\nContent-Length: 6\r\nConnection: close\r\n\r\n\x00\x01\x00\x06\x01\x01", ) .await; assert!(response.starts_with(b"HTTP/1.1 200")); - assert!(response.ends_with(b"\x06\x01\x02\x07\x01\x06")); + let body_start = find_header_end(&response).unwrap() + 4; + let tlv = Tlv8::parse(&response[body_start..]).unwrap(); + assert_eq!(tlv.byte(TLV_STATE), Some(2)); + assert_eq!(tlv.get(crate::protocol::TLV_SALT).unwrap().len(), 16); + assert_eq!(tlv.get(TLV_PUBLIC_KEY).unwrap().len(), 384); + server.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn pair_verify_enables_encrypted_access_and_replay_closes_connection() { + let (server, _directory, controller_signing) = paired_server().await; + let mut stream = TcpStream::connect(server.local_addr()).await.unwrap(); + let controller_secret = StaticSecret::from([0x24; 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_http = post_tlv(&mut stream, "/pair-verify", &m1).await; + let m2 = Tlv8::parse(&m2_http[find_header_end(&m2_http).unwrap() + 4..]).unwrap(); + assert_eq!(m2.byte(TLV_STATE), Some(2)); + 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 verify_key = hkdf_sha512( + b"Pair-Verify-Encrypt-Salt", + shared.as_bytes(), + b"Pair-Verify-Encrypt-Info", + ) + .unwrap(); + let accessory_data = Tlv8::parse( + &open_labeled( + &verify_key, + b"PV-Msg02", + m2.get(TLV_ENCRYPTED_DATA).unwrap(), + ) + .unwrap(), + ) + .unwrap(); + let accessory_id = accessory_data.get(TLV_IDENTIFIER).unwrap(); + let accessory_signature: [u8; 64] = accessory_data + .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); + let persisted = PairingStore::open(_directory.path().join("pairings.json")).unwrap(); + VerifyingKey::from_bytes(&persisted.accessory_public_key().unwrap()) + .unwrap() + .verify_strict( + &accessory_info, + &Signature::from_bytes(&accessory_signature), + ) + .unwrap(); + + let controller_id = b"network-controller"; + 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(&verify_key, b"PV-Msg03", &sub_tlv).unwrap(); + let m3 = encode_items([ + (TLV_STATE, [3].as_slice()), + (TLV_ENCRYPTED_DATA, encrypted.as_slice()), + ]); + let m4_http = post_tlv(&mut stream, "/pair-verify", &m3).await; + let m4 = Tlv8::parse(&m4_http[find_header_end(&m4_http).unwrap() + 4..]).unwrap(); + assert_eq!(m4.byte(TLV_STATE), Some(4)); + + let keys = SessionKeys::derive(shared.as_bytes()) + .unwrap() + .controller_view(); + let mut records = RecordLayer::controller(keys); + let request = b"GET /accessories HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n"; + let encrypted_request = records.encrypt(request).unwrap(); + stream.write_all(&encrypted_request).await.unwrap(); + let response = read_encrypted_http(&mut stream, &mut records).await; + assert!(response.starts_with(b"HTTP/1.1 200")); + assert!(response.windows(11).any(|window| window == b"accessories")); + + stream.write_all(&encrypted_request).await.unwrap(); + let mut byte = [0u8; 1]; + let read = timeout(Duration::from_secs(2), stream.read(&mut byte)) + .await + .unwrap() + .unwrap(); + assert_eq!(read, 0); server.shutdown().await.unwrap(); } @@ -870,12 +1367,32 @@ mod tests { server.shutdown().await.unwrap(); } - #[test] - fn authenticated_internal_dispatch_exposes_accessories_and_events() { + #[tokio::test] + async fn authenticated_internal_dispatch_exposes_accessories_and_events() { let bridge = bridge(); let directory = tempfile::tempdir().unwrap(); - let pairings = PairingStore::open(directory.path().join("pairings.json")).unwrap(); + let pairings = Arc::new( + PairingStore::create( + directory.path().join("pairings.json"), + SetupCode::parse("518-26-003").unwrap(), + Some("AA:BB:CC:DD:EE:FF".into()), + ) + .unwrap(), + ); + pairings + .add_initial(ControllerPairing { + controller_id: "test-controller".into(), + public_key: SigningKey::from_bytes(&[7; 32]).verifying_key().to_bytes(), + admin: true, + }) + .unwrap(); let mut session = Session::authenticated_for_test(true); + let mut pair_setup = PairSetup::new(pairings.clone()); + let mut pair_verify = PairVerify::new(pairings.clone()); + let discovery = DiscoveryState { + advertiser: Arc::new(NullAdvertiser), + record: Mutex::new(bridge.service_record.clone()), + }; let mut subscriptions = HashSet::new(); let response = dispatch_request( Request { @@ -885,12 +1402,15 @@ mod tests { connection_close: false, }, &mut session, + (&mut pair_setup, &mut pair_verify), &bridge, &pairings, + &discovery, &mut subscriptions, - ); - assert_eq!(response.status, 200); - let body: Value = serde_json::from_slice(&response.body).unwrap(); + ) + .await; + assert_eq!(response.response.status, 200); + let body: Value = serde_json::from_slice(&response.response.body).unwrap(); assert_eq!(body["accessories"].as_array().unwrap().len(), 2); let response = dispatch_request( @@ -901,11 +1421,95 @@ mod tests { connection_close: false, }, &mut session, + (&mut pair_setup, &mut pair_verify), &bridge, &pairings, + &discovery, &mut subscriptions, - ); - assert_eq!(response.status, 204); + ) + .await; + assert_eq!(response.response.status, 204); assert!(subscriptions.contains(&(2, 8))); } + + #[tokio::test] + async fn pairing_management_rechecks_admin_and_enforces_last_admin_invariant() { + let bridge = bridge(); + let directory = tempfile::tempdir().unwrap(); + let pairings = Arc::new( + PairingStore::create( + directory.path().join("pairings.json"), + SetupCode::parse("518-26-003").unwrap(), + Some("AA:BB:CC:DD:EE:FF".into()), + ) + .unwrap(), + ); + let admin_key = SigningKey::from_bytes(&[8; 32]); + pairings + .add_initial(ControllerPairing { + controller_id: "test-controller".into(), + public_key: admin_key.verifying_key().to_bytes(), + admin: true, + }) + .unwrap(); + let mut session = Session::authenticated_for_test(true); + let mut pair_setup = PairSetup::new(pairings.clone()); + let mut pair_verify = PairVerify::new(pairings.clone()); + let discovery = DiscoveryState { + advertiser: Arc::new(NullAdvertiser), + record: Mutex::new(bridge.service_record.clone()), + }; + let mut subscriptions = HashSet::new(); + let member_key = SigningKey::from_bytes(&[9; 32]).verifying_key().to_bytes(); + let add = encode_items([ + (TLV_STATE, [1].as_slice()), + (TLV_METHOD, [3].as_slice()), + (TLV_IDENTIFIER, b"member".as_slice()), + (TLV_PUBLIC_KEY, member_key.as_slice()), + (TLV_PERMISSIONS, [0].as_slice()), + ]); + let result = dispatch_request( + Request { + method: "POST".into(), + target: "/pairings".into(), + body: add, + connection_close: false, + }, + &mut session, + (&mut pair_setup, &mut pair_verify), + &bridge, + &pairings, + &discovery, + &mut subscriptions, + ) + .await; + assert_eq!( + Tlv8::parse(&result.response.body).unwrap().byte(TLV_STATE), + Some(2) + ); + assert!(pairings.get("member").unwrap().is_some()); + + let remove = encode_items([ + (TLV_STATE, [1].as_slice()), + (TLV_METHOD, [4].as_slice()), + (TLV_IDENTIFIER, b"test-controller".as_slice()), + ]); + let result = dispatch_request( + Request { + method: "POST".into(), + target: "/pairings".into(), + body: remove, + connection_close: false, + }, + &mut session, + (&mut pair_setup, &mut pair_verify), + &bridge, + &pairings, + &discovery, + &mut subscriptions, + ) + .await; + assert!(result.close_after_response); + assert!(pairings.list().unwrap().is_empty()); + } } diff --git a/v2/crates/homecore-hap/src/session.rs b/v2/crates/homecore-hap/src/session.rs index 4433d842..d8dfd8cf 100644 --- a/v2/crates/homecore-hap/src/session.rs +++ b/v2/crates/homecore-hap/src/session.rs @@ -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, } 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 { + 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();