feat(hap): implement authenticated pairing and transport

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