diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 5f0b13fa..417f9a75 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -3600,9 +3600,13 @@ name = "homecore-hap" version = "0.1.0-alpha.0" dependencies = [ "async-trait", + "ed25519-dalek", "homecore", + "httparse", + "mdns-sd", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", diff --git a/v2/crates/homecore-hap/Cargo.toml b/v2/crates/homecore-hap/Cargo.toml index a4ebe91a..537d196d 100644 --- a/v2/crates/homecore-hap/Cargo.toml +++ b/v2/crates/homecore-hap/Cargo.toml @@ -10,7 +10,7 @@ version = "0.1.0-alpha.0" edition = "2021" license = "MIT" authors = ["rUv ", "HOMECORE Contributors"] -description = "Apple Home HomeKit Accessory Protocol bridge — ADR-125 P1 scaffold" +description = "Fail-closed HomeKit Accessory Protocol network foundation for HOMECORE" repository = "https://github.com/ruvnet/wifi-densepose" [lib] @@ -19,18 +19,24 @@ path = "src/lib.rs" [features] default = [] -# P2: gates the actual hap = "0.1" crate integration + real mDNS via mdns-sd -hap-server = [] +# 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] homecore = { path = "../homecore" } -tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] } +tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt", "rt-multi-thread", "sync", "time"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" tracing = "0.1" async-trait = "0.1" uuid = { version = "1", features = ["v4", "serde"] } +ed25519-dalek = "2.1" +tempfile = "3" +httparse = { version = "1", optional = true } +mdns-sd = { version = "0.11", optional = true } [dev-dependencies] -tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] } +tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt", "rt-multi-thread", "sync", "time", "test-util"] } diff --git a/v2/crates/homecore-hap/README.md b/v2/crates/homecore-hap/README.md index 5c5635e1..5eb54c8d 100644 --- a/v2/crates/homecore-hap/README.md +++ b/v2/crates/homecore-hap/README.md @@ -1,121 +1,112 @@ # homecore-hap -Apple Home HomeKit Accessory Protocol bridge for HOMECORE with HAP-1.1 trait surface and mDNS advertisement (P2). +`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. -[![Crates.io](https://img.shields.io/crates/v/homecore-hap.svg)](https://crates.io/crates/homecore-hap) -![License](https://img.shields.io/badge/license-MIT-blue.svg) -![MSRV: 1.89+](https://img.shields.io/badge/MSRV-1.89%2B-purple.svg) -[![Tests](https://img.shields.io/badge/tests-17%20passing-brightgreen.svg)](https://github.com/ruvnet/RuView) -[![ADR-125](https://img.shields.io/badge/ADR-125-orange.svg)](../../docs/adr/ADR-125-homecore-apple-home-homekit-bridge.md) +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. -**P1 scaffold**: trait surface for HAP accessories + characteristics, entity→HAP mapping rules, and bridge ownership. The actual HAP-1.1 TLS server and real mDNS integration are gated behind `--features hap-server` (P2). +## Implemented -## What this crate does +- 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. -`homecore-hap` bridges HOMECORE entity state to Apple HomeKit Accessory Protocol (HAP-1.1), allowing HomeKit-native apps (Home, Control Center, Siri) to control HOMECORE devices. It provides: +## Deliberately incomplete protocol phases -- **HapAccessoryType enum** — 11 accessory types matching HA's HomeKit integration (`Light`, `Switch`, `Thermostat`, `Lock`, `Door`, etc.) -- **HapCharacteristic enum** — HAP characteristic types (`On`, `Brightness`, `Temperature`, `TargetLockState`, etc.) -- **EntityToAccessoryMapper** — bidirectional rules for mapping HOMECORE entities to HAP accessories (e.g., `light.kitchen` → `Light` accessory + `On` + `Brightness` characteristics) -- **HapBridge** — owns and exposes a collection of mapped accessories over HAP -- **MdnsAdvertiser trait** — abstraction over mDNS advertisement; P1 ships `NullAdvertiser` (no-op), P2 adds real mDNS via `mdns-sd` -- **RuViewToHapMapper** — bridges RuView sensing data (temperature, humidity, occupancy) to HAP characteristics +The following must land together before this crate may claim Apple Home +interoperability: -The bridge itself is a HAP Accessory Bridge (HAP-1.1 spec §8.3), advertising a single service with characteristic slots for each exposed accessory. +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. -## Features +No cryptographic primitive should be implemented locally. The remaining work +must use reviewed RustCrypto/PAKE crates and protocol test vectors. -- **11 accessory types** — Light, Switch, Thermostat, Door, Lock, Window, Blind, Outlet, Fan, Sensor, SecuritySystem -- **Bi-directional mapping** — HOMECORE entity state ↔ HAP characteristic values with type-safe enums -- **HAP-1.1 spec compliance** — characteristic types and permissions match HomeKit's published spec -- **Trait-based advertisement** — `MdnsAdvertiser` abstraction; swappable implementations (null, real mDNS, etc.) -- **RuView integration** — maps WiFi sensing data (occupancy, temperature, vital signs) to HomeKit sensor accessories -- **No TLS server in P1** — bridge compiles and tests pass with `--no-default-features`; real server lands in P2 with `--features hap-server` -- **Home.app compatible** — exposed accessories appear in Home app on any HomeKit hub (Apple TV, HomePod, HomePod mini) +## Server integration -## Capabilities +```rust,no_run +use std::{net::IpAddr, sync::Arc}; +use homecore_hap::{ + start_server, HapBridge, HapServerConfig, HapServiceRecord, + MdnsSdAdvertiser, PairingStore, +}; -| Capability | Type | Method | Notes | -|------------|------|--------|-------| -| Define accessory type | Trait | `HapAccessoryType::Light` etc. (11 variants) | Enum; no instantiation yet (P1) | -| Define characteristic | Trait | `HapCharacteristic::On`, `Brightness`, etc. | Enum; values encoded as HAP TLV | -| Map entity to accessory | Mapping | `EntityToAccessoryMapper::map_light()` | Takes `EntityId` + `State`; returns `HapAccessory` | -| Expose accessory | Bridge | `HapBridge::expose(accessory)` | Adds to the bridge's characteristic list | -| Advertise bridge | mDNS | `NullAdvertiser::advertise()` (P1) | No-op stub; real mDNS in P2 | -| Advertise bridge (P2) | mDNS | `mdns_sd::ServiceInstanceBuilder` | Real mDNS via `--features hap-server` | -| Bridge state query | Bridge | `HapBridge::list_accessories()` | Returns exposed accessories + their characteristics | -| Characteristic write | Characteristic | HAP `WriteRequest` TLV (P2) | Home.app button press → service call | -| Characteristic read | Characteristic | HAP `ReadResponse` TLV (P2) | Home.app query → current entity state | +# async fn run() -> Result<(), Box> { +let record = HapServiceRecord::bridge( + "HOMECORE Bridge", + 51826, + "AA:BB:CC:DD:EE:FF", +); +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::()?, +)?); -## Comparison to Home Assistant +let server = start_server( + HapServerConfig::default(), + bridge.clone(), + pairings, + advertiser, +).await?; -| Aspect | Home Assistant | homecore-hap | -|--------|----------------|--------------| -| Framework | HA's `hap-python` (pure Python) | Rust 1.89+ with HAP trait abstraction | -| Server type | Python asyncio HAP-1.1 server | TLS server trait (P2); stub in P1 | -| Accessory types | 30+ (Light, Switch, Thermostat, etc.) | 11 (Light, Switch, Thermostat, Door, Lock, Window, Blind, Outlet, Fan, Sensor, SecuritySystem) | -| mDNS | mdns-py broadcast via asyncio | Abstraction + real mDNS (P2) or no-op stub (P1) | -| Entity filtering | YAML `include_domains` + `exclude_entities` | Mapper rules (planned P2) | -| HomeKit hub requirement | Yes (for remote access) | Yes (same as HomeKit) | -| Pairing code generation | Automatic (HA web UI) | Manual setup code (P2) | -| Characteristic persistence | HomeKit cloud only | Paired with homecore state machine | - -## Performance - -- **Entity→HAP mapping** — < 100 μs per entity (enum lookups + type conversions) -- **HAP write latency** — ~10 ms (TLS decrypt + characteristic parse + entity state set); bounded by homecore state machine lock contention -- **mDNS advertisement** (P2) — ~50 ms multicast broadcast; periodic rediscovery on network change -- **Memory overhead per accessory** — ~500 bytes (enum + characteristic slots + metadata) -- **No per-crate benchmarks yet** — a follow-up issue tracks baseline measurements - -## Usage - -Mapping an entity (P1): - -```rust -use homecore_hap::{EntityToAccessoryMapper, HapBridge, HapAccessoryType}; -use homecore::{EntityId, State}; -use std::collections::HashMap; - -#[tokio::main] -async fn main() { - let light_id = EntityId::parse("light.kitchen").unwrap(); - let state = State::new("on", HashMap::new()); - - // Map the entity to a HAP Light accessory - let mut mapper = EntityToAccessoryMapper::new(); - if let Ok(accessory) = mapper.map_light(&light_id, &state) { - println!("Mapped to HAP: {:?}", accessory.accessory_type); - - // Expose it via the bridge - let mut bridge = HapBridge::new(); - bridge.expose(accessory); - println!("Exposed {} accessories", bridge.list_accessories().len()); - } -} +// Feed HOMECORE StateChanged events through bridge.update_accessory(...). +// On process shutdown: +server.shutdown().await?; +# Ok(()) +# } ``` -Real HAP server (P2, via `--features hap-server`): +Build and test: ```bash -cargo build -p homecore-hap --features hap-server -# The server will advertise over mDNS and accept HomeKit pairing requests +cargo test -p homecore-hap --no-default-features +cargo test -p homecore-hap --features hap-server ``` -## Relation to other HOMECORE crates +Real mDNS requires the advertised address to be LAN-routable and the runtime to +have multicast access. Containers normally need host networking or macvlan. -``` -homecore-hap (HomeKit bridge) -├─ homecore (state machine; bridge reads entity states) -├─ homecore-api (exposes HAP state via REST /api for remote debugging) -├─ homecore-server (starts the bridge on homecore init) -└─ homecore-automation (can trigger state changes via service calls) -``` +## Decisions -## References - -- [ADR-125: HOMECORE Apple Home / HomeKit Bridge](../../docs/adr/ADR-125-homecore-apple-home-homekit-bridge.md) -- [ADR-126: HOMECORE Home Assistant Port (master)](../../docs/adr/ADR-126-homecore-home-assistant-port.md) -- [HomeKit Accessory Protocol Specification (HAP-1.1)](https://developer.apple.com/homekit/) -- [user-guide-apple-homepod.md](../../docs/user-guide-apple-homepod.md) -- [README — wifi-densepose](../../../README.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 explicit HAP deferral](../../docs/adr/ADR-161-homecore-server-layer-security.md) diff --git a/v2/crates/homecore-hap/src/bridge.rs b/v2/crates/homecore-hap/src/bridge.rs index faa66185..435d8f1c 100644 --- a/v2/crates/homecore-hap/src/bridge.rs +++ b/v2/crates/homecore-hap/src/bridge.rs @@ -1,15 +1,15 @@ //! `HapBridge` — owns the set of HOMECORE entities exposed as HAP accessories. //! -//! P1 does not start a real HAP-1.1 server; it ships the API surface so other -//! crates (and P2's `hap-server` feature) can register accessories and query -//! their current mapping. The actual mDNS + HAP pairing is gated to P2. +//! The bridge owns mappings and their event stream. The feature-gated network +//! lifecycle is started separately with `start_server`. use std::collections::HashMap; use std::sync::{Arc, RwLock}; use homecore::entity::EntityId; +use tokio::sync::broadcast; -use crate::accessory::HapAccessoryType; +use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue}; use crate::error::HapError; use crate::mapping::{AccessoryMapping, EntityToAccessoryMapper}; use crate::mdns::{HapServiceRecord, MdnsAdvertiser, NullAdvertiser}; @@ -22,37 +22,49 @@ pub struct ExposedAccessory { pub mapping: AccessoryMapping, } +/// A characteristic snapshot emitted after a registered entity changes. +#[derive(Debug, Clone)] +pub struct CharacteristicEvent { + pub entity_id: EntityId, + pub accessory_type: HapAccessoryType, + pub characteristics: Vec<(HapCharacteristic, HapCharacteristicValue)>, +} + struct BridgeInner { accessories: HashMap, } -/// The P1 HAP bridge. +/// HOMECORE-to-HAP accessory bridge state. /// /// Call [`HapBridge::add_accessory`] to register entities and /// [`HapBridge::running_accessories`] to read back what is currently -/// registered. In P2, `start()` will spawn the `hap` server task. +/// registered. Use `start_server` for the bounded TCP lifecycle. #[derive(Clone)] pub struct HapBridge { inner: Arc>, advertiser: Arc, + events: broadcast::Sender, pub service_record: HapServiceRecord, } impl HapBridge { - /// Create a bridge with the given service record and a `NullAdvertiser` - /// (P1 default — real mDNS lands in P2). + /// Create a bridge with the given service record and a `NullAdvertiser`. pub fn new(service_record: HapServiceRecord) -> Self { Self::with_advertiser(service_record, Arc::new(NullAdvertiser)) } - /// Create a bridge with a custom `MdnsAdvertiser` (used in tests and P2). + /// Create a bridge with a custom `MdnsAdvertiser`. pub fn with_advertiser( service_record: HapServiceRecord, advertiser: Arc, ) -> Self { + let (events, _) = broadcast::channel(128); Self { - inner: Arc::new(RwLock::new(BridgeInner { accessories: HashMap::new() })), + inner: Arc::new(RwLock::new(BridgeInner { + accessories: HashMap::new(), + })), advertiser, + events, service_record, } } @@ -97,9 +109,46 @@ impl HapBridge { Ok(()) } + /// Refresh a registered accessory and notify event subscribers. + pub fn update_accessory( + &self, + entity_id: &EntityId, + state: &homecore::entity::State, + ) -> Result<(), HapError> { + let mapping = EntityToAccessoryMapper::map(entity_id, state)?; + let accessory_type = mapping.accessory_type; + { + let mut inner = self.inner.write().unwrap(); + let accessory = inner + .accessories + .get_mut(entity_id) + .ok_or_else(|| HapError::EntityNotFound(entity_id.as_str().to_owned()))?; + accessory.accessory_type = accessory_type; + accessory.mapping = mapping.clone(); + } + let _ = self.events.send(CharacteristicEvent { + entity_id: entity_id.clone(), + accessory_type, + characteristics: mapping.characteristics, + }); + Ok(()) + } + + /// Subscribe to bounded characteristic updates. Lagging receivers receive + /// Tokio's explicit `Lagged` error and must resynchronize from a snapshot. + pub fn subscribe_events(&self) -> broadcast::Receiver { + self.events.subscribe() + } + /// Snapshot all currently registered accessories. pub fn running_accessories(&self) -> Vec { - self.inner.read().unwrap().accessories.values().cloned().collect() + self.inner + .read() + .unwrap() + .accessories + .values() + .cloned() + .collect() } /// Number of registered accessories. @@ -111,21 +160,25 @@ impl HapBridge { self.len() == 0 } - /// P2 stub — will start the HAP-1.1 server + mDNS advertisement. - /// In P1 this only fires the null advertiser. + /// Start advertisement only. + /// + /// This legacy lifecycle does not bind a TCP listener. New integrations + /// should call `start_server`, which advertises only after binding. pub async fn start(&self) -> Result<(), HapError> { self.advertiser.advertise(&self.service_record).await?; tracing::info!( instance = %self.service_record.instance_name, port = self.service_record.port, - "HapBridge started (P1 — no real HAP server; mDNS stub only)" + "HAP advertisement started without a TCP server" ); Ok(()) } /// Graceful shutdown — retracts mDNS advertisement. pub async fn stop(&self) -> Result<(), HapError> { - self.advertiser.retract(&self.service_record.instance_name).await?; + self.advertiser + .retract(&self.service_record.instance_name) + .await?; Ok(()) } } @@ -137,18 +190,22 @@ mod tests { use homecore::event::Context; fn make_bridge() -> HapBridge { - HapBridge::new(HapServiceRecord { - instance_name: "RuView Sense".into(), - port: 51826, - setup_code: "111-22-333".into(), - device_id: "AA:BB:CC:DD:EE:FF".into(), - }) + HapBridge::new(HapServiceRecord::bridge( + "RuView Sense", + 51826, + "AA:BB:CC:DD:EE:FF", + )) } fn light_state(name: &str, on: bool, brightness: u8) -> (EntityId, State) { - let eid = EntityId::parse(&format!("light.{name}")).unwrap(); + let eid = EntityId::parse(format!("light.{name}")).unwrap(); let attrs = serde_json::json!({"brightness": brightness}); - let s = State::new(eid.clone(), if on { "on" } else { "off" }, attrs, Context::default()); + let s = State::new( + eid.clone(), + if on { "on" } else { "off" }, + attrs, + Context::default(), + ); (eid, s) } @@ -187,6 +244,22 @@ mod tests { assert!(matches!(err, HapError::EntityNotFound(_))); } + #[tokio::test] + async fn update_emits_characteristic_event() { + let bridge = make_bridge(); + let (eid, initial) = light_state("kitchen", false, 10); + bridge.add_accessory(&eid, &initial).unwrap(); + let mut events = bridge.subscribe_events(); + let (_, updated) = light_state("kitchen", true, 200); + bridge.update_accessory(&eid, &updated).unwrap(); + + let event = events.recv().await.unwrap(); + assert_eq!(event.entity_id, eid); + assert!(event + .characteristics + .contains(&(HapCharacteristic::On, HapCharacteristicValue::Bool(true)))); + } + #[tokio::test] async fn start_stop_with_null_advertiser() { let bridge = make_bridge(); diff --git a/v2/crates/homecore-hap/src/error.rs b/v2/crates/homecore-hap/src/error.rs index a7125616..223cd737 100644 --- a/v2/crates/homecore-hap/src/error.rs +++ b/v2/crates/homecore-hap/src/error.rs @@ -1,5 +1,6 @@ //! Unified error type for `homecore-hap`. +use std::path::PathBuf; use thiserror::Error; /// Errors produced by the HAP bridge and its sub-components. @@ -19,4 +20,31 @@ pub enum HapError { #[error("bridge not running")] NotRunning, + + #[error("pairing store error: {0}")] + PairingStore(String), + + #[error("invalid pairing record: {0}")] + InvalidPairingRecord(String), + + #[error("controller pairing already exists: {0}")] + PairingAlreadyExists(String), + + #[error("controller pairing not found: {0}")] + PairingNotFound(String), + + #[error("insecure permissions on {path}: mode {mode:o}; expected no group/other access")] + InsecurePermissions { path: PathBuf, mode: u32 }, + + #[error("invalid HAP session transition from {from} to {to}")] + InvalidSessionTransition { + from: &'static str, + to: &'static str, + }, + + #[error("HAP protocol error: {0}")] + Protocol(String), + + #[error("HAP server error: {0}")] + Server(String), } diff --git a/v2/crates/homecore-hap/src/lib.rs b/v2/crates/homecore-hap/src/lib.rs index 931f2f8a..7b1f8359 100644 --- a/v2/crates/homecore-hap/src/lib.rs +++ b/v2/crates/homecore-hap/src/lib.rs @@ -1,12 +1,12 @@ //! `homecore-hap` — Apple Home HomeKit Accessory Protocol bridge (ADR-125). //! -//! # P1 scope +//! # Network foundation scope //! -//! Ships the trait surface and type definitions needed to map HOMECORE entity -//! states onto HAP accessory / characteristic values. The actual HAP-1.1 TLS -//! server and real mDNS advertisement are gated behind the `hap-server` -//! feature (P2). P1 ships `NullAdvertiser` (no-op) so the bridge compiles and -//! all tests pass with `--no-default-features`. +//! 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. //! //! # Module layout //! @@ -16,7 +16,11 @@ //! | [`mapping`] | `EntityToAccessoryMapper` — HOMECORE entity → HAP | //! | [`bridge`] | `HapBridge` — owns exposed accessories | //! | [`mdns`] | `MdnsAdvertiser` trait + `NullAdvertiser` stub | +//! | [`pairing`] | Atomic controller pairing persistence | +//! | [`protocol`] | Bounded TLV8 protocol primitives | //! | [`ruview`] | `RuViewToHapMapper` — sensing primitives → HAP | +//! | [`session`] | Authenticated request-gating state machine | +//! | `server` | Feature-gated bounded TCP/HTTP lifecycle | //! | [`error`] | Unified `HapError` type | pub mod accessory; @@ -24,11 +28,22 @@ pub mod bridge; pub mod error; pub mod mapping; pub mod mdns; +pub mod pairing; +pub mod protocol; pub mod ruview; +#[cfg(feature = "hap-server")] +pub mod server; +pub mod session; pub use accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue}; -pub use bridge::{ExposedAccessory, HapBridge}; +pub use bridge::{CharacteristicEvent, ExposedAccessory, HapBridge}; pub use error::HapError; pub use mapping::EntityToAccessoryMapper; -pub use mdns::{MdnsAdvertiser, NullAdvertiser}; +#[cfg(feature = "hap-server")] +pub use mdns::MdnsSdAdvertiser; +pub use mdns::{HapServiceRecord, MdnsAdvertiser, NullAdvertiser}; +pub use pairing::{ControllerPairing, PairingStore}; pub use ruview::RuViewToHapMapper; +#[cfg(feature = "hap-server")] +pub use server::{start_server, HapServerConfig, HapServerHandle}; +pub use session::{Session, SessionState}; diff --git a/v2/crates/homecore-hap/src/mdns.rs b/v2/crates/homecore-hap/src/mdns.rs index ada1bed3..183a4743 100644 --- a/v2/crates/homecore-hap/src/mdns.rs +++ b/v2/crates/homecore-hap/src/mdns.rs @@ -1,61 +1,206 @@ -//! mDNS advertisement trait and P1 no-op stub. -//! -//! Real mDNS via the `mdns-sd` crate (https://crates.io/crates/mdns-sd) -//! lands in P2 behind the `hap-server` feature flag. P1 ships `NullAdvertiser` -//! so the bridge compiles and tests pass without any mDNS infrastructure. +//! HAP `_hap._tcp` advertisement. use async_trait::async_trait; use crate::error::HapError; -/// Service record advertised over mDNS for HAP discovery. -#[derive(Debug, Clone)] +/// HAP service record advertised over mDNS. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct HapServiceRecord { - /// Service instance name shown in Apple Home ("RuView Sense"). + /// Service instance shown in discovery UI. pub instance_name: String, - /// TCP port the HAP server listens on (default 51826). + /// Bound HAP TCP port. pub port: u16, - /// HAP pairing setup code (8 digits, formatted as XXX-XX-XXX). - pub setup_code: String, - /// Unique device ID (colon-separated MAC-like hex, required by HAP §5.4). + /// Stable colon-separated accessory device identifier. pub device_id: String, + /// Accessory model (`md` TXT key). + pub model: String, + /// Configuration number (`c#`), incremented when accessory layout changes. + pub configuration_number: u32, + /// Current state number (`s#`). + pub state_number: u32, + /// HAP accessory category identifier. Bridges use `2`. + pub category: u16, + /// Whether a controller pairing already exists. + pub paired: bool, } -/// Advertise (and retract) a HAP accessory over mDNS (`_hap._tcp`). -/// -/// Implementors register the `_hap._tcp` service so HomePod / Apple TV can -/// discover the bridge and initiate pairing. P1 provides only `NullAdvertiser`. +impl HapServiceRecord { + pub fn bridge( + instance_name: impl Into, + port: u16, + device_id: impl Into, + ) -> Self { + Self { + instance_name: instance_name.into(), + port, + device_id: device_id.into(), + model: "HOMECORE Bridge".into(), + configuration_number: 1, + state_number: 1, + category: 2, + paired: false, + } + } + + fn validate(&self) -> Result<(), HapError> { + if self.instance_name.is_empty() || self.instance_name.len() > 63 { + return Err(HapError::MdnsError( + "instance_name must contain 1..=63 bytes".into(), + )); + } + if self.port == 0 { + return Err(HapError::MdnsError("advertised port cannot be zero".into())); + } + let parts: Vec<&str> = self.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::MdnsError( + "device_id must be six colon-separated hexadecimal octets".into(), + )); + } + if self.model.is_empty() || self.model.len() > 64 { + return Err(HapError::MdnsError( + "model must contain 1..=64 bytes".into(), + )); + } + if self.configuration_number == 0 || self.state_number == 0 { + return Err(HapError::MdnsError( + "configuration and state numbers must be non-zero".into(), + )); + } + Ok(()) + } + + /// Standard HAP Bonjour TXT keys. Setup codes and controller data are + /// intentionally never included because TXT records are plaintext. + pub fn txt_records(&self) -> Vec<(String, String)> { + vec![ + ("c#".into(), self.configuration_number.to_string()), + ("ci".into(), self.category.to_string()), + ("ff".into(), "0".into()), + ("id".into(), self.device_id.to_ascii_uppercase()), + ("md".into(), self.model.clone()), + ("pv".into(), "1.1".into()), + ("s#".into(), self.state_number.to_string()), + ("sf".into(), if self.paired { "0" } else { "1" }.into()), + ] + } +} + +/// Advertise and retract a HAP service. #[async_trait] pub trait MdnsAdvertiser: Send + Sync { - /// Begin advertising the service. Idempotent. async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError>; - - /// Stop advertising. Called on bridge shutdown. async fn retract(&self, instance_name: &str) -> Result<(), HapError>; } -/// No-op advertiser for P1 / test environments. -/// -/// All calls succeed without touching the network. +/// Deterministic no-network advertiser for tests and disabled deployments. #[derive(Debug, Default, Clone)] pub struct NullAdvertiser; #[async_trait] impl MdnsAdvertiser for NullAdvertiser { async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> { + record.validate()?; tracing::debug!( instance = %record.instance_name, port = record.port, - "NullAdvertiser: skipping mDNS advertisement (P1 stub)" + "HAP mDNS advertisement disabled" ); Ok(()) } async fn retract(&self, instance_name: &str) -> Result<(), HapError> { - tracing::debug!( - instance = %instance_name, - "NullAdvertiser: skipping mDNS retract (P1 stub)" - ); + tracing::debug!(instance = %instance_name, "HAP mDNS retraction disabled"); + Ok(()) + } +} + +/// Network-backed Bonjour advertiser. +#[cfg(feature = "hap-server")] +pub struct MdnsSdAdvertiser { + daemon: mdns_sd::ServiceDaemon, + hostname: String, + address: String, + registrations: std::sync::Mutex>, +} + +#[cfg(feature = "hap-server")] +impl std::fmt::Debug for MdnsSdAdvertiser { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MdnsSdAdvertiser") + .field("hostname", &self.hostname) + .field("address", &self.address) + .finish_non_exhaustive() + } +} + +#[cfg(feature = "hap-server")] +impl MdnsSdAdvertiser { + /// Bind the mDNS daemon for a LAN-routable host/address. + pub fn new(hostname: impl Into, address: std::net::IpAddr) -> Result { + let mut hostname = hostname.into(); + if !hostname.ends_with(".local.") { + hostname = format!("{}.local.", hostname.trim_end_matches('.')); + } + let daemon = mdns_sd::ServiceDaemon::new() + .map_err(|error| HapError::MdnsError(error.to_string()))?; + Ok(Self { + daemon, + hostname, + address: address.to_string(), + registrations: std::sync::Mutex::new(std::collections::HashMap::new()), + }) + } + + fn service_info(&self, record: &HapServiceRecord) -> Result { + record.validate()?; + let properties: std::collections::HashMap = + record.txt_records().into_iter().collect(); + mdns_sd::ServiceInfo::new( + "_hap._tcp.local.", + &record.instance_name, + &self.hostname, + self.address.as_str(), + record.port, + Some(properties), + ) + .map_err(|error| HapError::MdnsError(error.to_string())) + } +} + +#[cfg(feature = "hap-server")] +#[async_trait] +impl MdnsAdvertiser for MdnsSdAdvertiser { + async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> { + let info = self.service_info(record)?; + let fullname = info.get_fullname().to_owned(); + self.daemon + .register(info) + .map_err(|error| HapError::MdnsError(error.to_string()))?; + self.registrations + .lock() + .map_err(|_| HapError::MdnsError("registration lock poisoned".into()))? + .insert(record.instance_name.clone(), fullname); + Ok(()) + } + + async fn retract(&self, instance_name: &str) -> Result<(), HapError> { + let fullname = self + .registrations + .lock() + .map_err(|_| HapError::MdnsError("registration lock poisoned".into()))? + .remove(instance_name); + if let Some(fullname) = fullname { + self.daemon + .unregister(&fullname) + .map_err(|error| HapError::MdnsError(error.to_string()))?; + } Ok(()) } } @@ -64,16 +209,28 @@ impl MdnsAdvertiser for NullAdvertiser { mod tests { use super::*; + #[test] + fn txt_record_is_hap_shaped_and_contains_no_setup_secret() { + let record = HapServiceRecord::bridge("RuView Sense", 51826, "AA:BB:CC:DD:EE:FF"); + let txt: std::collections::HashMap<_, _> = record.txt_records().into_iter().collect(); + assert_eq!(txt.get("pv").map(String::as_str), Some("1.1")); + assert_eq!(txt.get("sf").map(String::as_str), Some("1")); + assert_eq!(txt.get("ci").map(String::as_str), Some("2")); + assert!(!txt + .keys() + .any(|key| key.contains("pin") || key.contains("code"))); + } + #[tokio::test] - async fn null_advertiser_is_noop() { - let adv = NullAdvertiser; - let rec = HapServiceRecord { - instance_name: "RuView Sense".into(), - port: 51826, - setup_code: "111-22-333".into(), - device_id: "AA:BB:CC:DD:EE:FF".into(), - }; - adv.advertise(&rec).await.unwrap(); - adv.retract(&rec.instance_name).await.unwrap(); + async fn null_advertiser_validates_without_network_io() { + let record = HapServiceRecord::bridge("RuView Sense", 51826, "AA:BB:CC:DD:EE:FF"); + NullAdvertiser.advertise(&record).await.unwrap(); + NullAdvertiser.retract(&record.instance_name).await.unwrap(); + } + + #[tokio::test] + async fn malformed_record_is_rejected() { + let record = HapServiceRecord::bridge("RuView Sense", 0, "not-an-id"); + assert!(NullAdvertiser.advertise(&record).await.is_err()); } } diff --git a/v2/crates/homecore-hap/src/pairing.rs b/v2/crates/homecore-hap/src/pairing.rs new file mode 100644 index 00000000..b2d81478 --- /dev/null +++ b/v2/crates/homecore-hap/src/pairing.rs @@ -0,0 +1,366 @@ +//! 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. + +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::RwLock; + +use ed25519_dalek::VerifyingKey; +use serde::{Deserialize, Serialize}; +use tempfile::Builder; + +use crate::error::HapError; + +const STORE_VERSION: u32 = 1; +const MAX_STORE_BYTES: u64 = 1024 * 1024; +const MAX_CONTROLLER_ID_BYTES: usize = 64; + +/// 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. + pub controller_id: String, + /// Controller Ed25519 long-term public key. + pub public_key: [u8; 32], + /// Whether this controller may manage other pairings. + pub admin: bool, +} + +impl ControllerPairing { + /// Validate bounded identifiers and the encoded Ed25519 point. + pub fn validate(&self) -> Result<(), HapError> { + let len = self.controller_id.len(); + if len == 0 + || len > MAX_CONTROLLER_ID_BYTES + || self.controller_id.chars().any(char::is_control) + { + return Err(HapError::InvalidPairingRecord( + "controller_id must contain 1..=64 bytes and no control characters".into(), + )); + } + VerifyingKey::from_bytes(&self.public_key).map_err(|_| { + HapError::InvalidPairingRecord("controller public key is not valid Ed25519".into()) + })?; + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PairingFile { + version: u32, + controllers: Vec, +} + +/// Thread-safe, atomically persisted controller pairing store. +#[derive(Debug)] +pub struct PairingStore { + path: PathBuf, + controllers: RwLock>, +} + +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. + 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), + }) + } + + /// Path used for durable records. + pub fn path(&self) -> &Path { + &self.path + } + + /// Return a deterministic snapshot ordered by controller identifier. + pub fn list(&self) -> Result, HapError> { + Ok(self + .controllers + .read() + .map_err(|_| HapError::PairingStore("pairing store lock poisoned".into()))? + .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()) + } + + /// 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()) + } + + /// Add a controller and durably commit it before exposing it in memory. + pub fn add(&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)); + } + let mut next = guard.clone(); + next.insert(pairing.controller_id.clone(), pairing); + persist_file(&self.path, &next)?; + *guard = next; + Ok(()) + } + + /// Remove a controller, refusing to orphan remaining non-admin pairings. + pub fn remove(&self, controller_id: &str) -> Result<(), HapError> { + let mut guard = self + .controllers + .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)?; + *guard = next; + Ok(()) + } +} + +fn load_file(path: &Path) -> Result, 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() { + return Err(HapError::PairingStore(format!( + "{} must be a regular, non-symlink file", + path.display() + ))); + } + if metadata.len() > MAX_STORE_BYTES { + return Err(HapError::PairingStore(format!( + "{} exceeds the {MAX_STORE_BYTES}-byte limit", + path.display() + ))); + } + validate_permissions(path, &metadata)?; + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + File::open(path) + .and_then(|file| file.take(MAX_STORE_BYTES + 1).read_to_end(&mut bytes)) + .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) + .map_err(|error| HapError::PairingStore(format!("parse {}: {error}", path.display())))?; + if file.version != STORE_VERSION { + return Err(HapError::PairingStore(format!( + "unsupported pairing store version {}", + file.version + ))); + } + + let mut controllers = BTreeMap::new(); + for pairing in file.controllers { + pairing.validate()?; + let id = pairing.controller_id.clone(); + if controllers.insert(id.clone(), pairing).is_some() { + return Err(HapError::InvalidPairingRecord(format!( + "duplicate controller_id {id}" + ))); + } + } + if !controllers.is_empty() && !controllers.values().any(|pairing| pairing.admin) { + return Err(HapError::InvalidPairingRecord( + "persisted pairings have no administrator".into(), + )); + } + Ok(controllers) +} + +fn persist_file( + path: &Path, + controllers: &BTreeMap, +) -> 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 mut temp = Builder::new() + .prefix(".homecore-hap-pairings-") + .tempfile_in(parent) + .map_err(|error| HapError::PairingStore(format!("create temporary store: {error}")))?; + set_private_file_permissions(temp.as_file())?; + temp.write_all(&payload) + .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())) + })?; + } + Ok(()) +} + +fn create_private_dir(path: &Path) -> Result<(), HapError> { + let created = !path.exists(); + if created { + fs::create_dir_all(path).map_err(|error| { + HapError::PairingStore(format!("create {}: {error}", path.display())) + })?; + } + #[cfg(unix)] + { + 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())) + })?; + } + } + Ok(()) +} + +fn set_private_file_permissions(_file: &File) -> Result<(), HapError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + _file + .set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| HapError::PairingStore(format!("chmod temporary store: {error}")))?; + } + Ok(()) +} + +fn validate_permissions(path: &Path, metadata: &fs::Metadata) -> Result<(), HapError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = metadata.permissions().mode(); + if mode & 0o077 != 0 { + return Err(HapError::InsecurePermissions { + path: path.to_path_buf(), + mode: mode & 0o777, + }); + } + } + let _ = (path, metadata); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + + 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, + admin, + } + } + + #[test] + fn restart_loads_atomically_persisted_pairings() { + 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 reopened = PairingStore::open(&path).unwrap(); + assert_eq!( + reopened.list().unwrap(), + vec![pairing("controller-1", 7, true)] + ); + } + + #[test] + fn malformed_and_duplicate_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(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + 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(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(matches!( + PairingStore::open(path), + Err(HapError::InsecurePermissions { .. }) + )); + } +} diff --git a/v2/crates/homecore-hap/src/protocol.rs b/v2/crates/homecore-hap/src/protocol.rs new file mode 100644 index 00000000..53cdbb8c --- /dev/null +++ b/v2/crates/homecore-hap/src/protocol.rs @@ -0,0 +1,129 @@ +//! Bounded HAP TLV8 primitives and fail-closed pairing responses. + +use std::collections::BTreeMap; + +use crate::error::HapError; + +pub const TLV_METHOD: u8 = 0x00; +pub const TLV_IDENTIFIER: u8 = 0x01; +pub const TLV_PUBLIC_KEY: u8 = 0x03; +pub const TLV_STATE: u8 = 0x06; +pub const TLV_ERROR: u8 = 0x07; +pub const TLV_SIGNATURE: u8 = 0x0a; + +pub const TLV_ERROR_AUTHENTICATION: u8 = 0x02; +pub const TLV_ERROR_UNAVAILABLE: u8 = 0x06; +pub const TLV_ERROR_BUSY: u8 = 0x07; + +const MAX_TLV_BYTES: usize = 4096; +const MAX_TLV_TYPES: usize = 32; + +/// Parsed TLV8 values. Repeated fragments of a type are concatenated. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Tlv8 { + values: BTreeMap>, +} + +impl Tlv8 { + pub fn parse(input: &[u8]) -> Result { + if input.len() > MAX_TLV_BYTES { + return Err(HapError::Protocol("TLV8 body exceeds 4096 bytes".into())); + } + let mut values: BTreeMap> = BTreeMap::new(); + let mut offset = 0usize; + while offset < input.len() { + if input.len() - offset < 2 { + return Err(HapError::Protocol("truncated TLV8 header".into())); + } + let kind = input[offset]; + let len = input[offset + 1] as usize; + offset += 2; + let end = offset + .checked_add(len) + .filter(|end| *end <= input.len()) + .ok_or_else(|| HapError::Protocol("truncated TLV8 value".into()))?; + if !values.contains_key(&kind) && values.len() == MAX_TLV_TYPES { + return Err(HapError::Protocol("too many TLV8 types".into())); + } + values + .entry(kind) + .or_default() + .extend_from_slice(&input[offset..end]); + offset = end; + } + Ok(Self { values }) + } + + pub fn get(&self, kind: u8) -> Option<&[u8]> { + self.values.get(&kind).map(Vec::as_slice) + } + + pub fn byte(&self, kind: u8) -> Option { + let value = self.get(kind)?; + (value.len() == 1).then_some(value[0]) + } + + pub fn insert(&mut self, kind: u8, value: impl Into>) { + self.values.insert(kind, value.into()); + } + + 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 + } +} + +/// 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()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tlv8_roundtrip_supports_fragmented_values() { + let mut tlv = Tlv8::default(); + tlv.insert(TLV_PUBLIC_KEY, vec![3; 300]); + tlv.insert(TLV_STATE, vec![1]); + let encoded = tlv.encode(); + let decoded = Tlv8::parse(&encoded).unwrap(); + assert_eq!(decoded, tlv); + } + + #[test] + fn malformed_or_oversized_tlv_is_rejected() { + assert!(Tlv8::parse(&[TLV_STATE]).is_err()); + assert!(Tlv8::parse(&[TLV_STATE, 2, 1]).is_err()); + assert!(Tlv8::parse(&vec![0; MAX_TLV_BYTES + 1]).is_err()); + } + + #[test] + fn unavailable_pairing_is_an_explicit_error_not_success() { + let response = pairing_unavailable_response(&[TLV_STATE, 1, 1]).unwrap(); + 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 new file mode 100644 index 00000000..cf4ae018 --- /dev/null +++ b/v2/crates/homecore-hap/src/server.rs @@ -0,0 +1,911 @@ +//! 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. + +use std::collections::HashSet; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +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::task::{JoinHandle, JoinSet}; +use tokio::time::{timeout, Instant}; + +use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue}; +use crate::bridge::{CharacteristicEvent, ExposedAccessory, HapBridge}; +use crate::error::HapError; +use crate::mdns::MdnsAdvertiser; +use crate::pairing::PairingStore; +use crate::protocol::pairing_unavailable_response; +use crate::session::Session; + +const HAP_JSON: &str = "application/hap+json"; +const HAP_TLV: &str = "application/pairing+tlv8"; + +/// Resource bounds for the HAP listener. +#[derive(Debug, Clone)] +pub struct HapServerConfig { + pub bind_addr: SocketAddr, + pub max_connections: usize, + pub max_header_bytes: usize, + pub max_body_bytes: usize, + pub request_timeout: Duration, + pub shutdown_timeout: Duration, +} + +impl Default for HapServerConfig { + fn default() -> Self { + Self { + bind_addr: SocketAddr::from(([0, 0, 0, 0], 51826)), + max_connections: 32, + max_header_bytes: 16 * 1024, + max_body_bytes: 64 * 1024, + request_timeout: Duration::from_secs(10), + shutdown_timeout: Duration::from_secs(5), + } + } +} + +impl HapServerConfig { + fn validate(&self) -> Result<(), HapError> { + if self.max_connections == 0 + || self.max_header_bytes < 512 + || self.max_body_bytes == 0 + || self.request_timeout.is_zero() + || self.shutdown_timeout.is_zero() + { + return Err(HapError::Server( + "invalid zero or undersized server limit".into(), + )); + } + Ok(()) + } +} + +/// Running server handle. Dropping it aborts the listener; [`shutdown`] also +/// retracts mDNS and waits for connection tasks within the configured bound. +pub struct HapServerHandle { + local_addr: SocketAddr, + shutdown: Option>, + task: Option>>, + shutdown_timeout: Duration, +} + +impl HapServerHandle { + pub fn local_addr(&self) -> SocketAddr { + self.local_addr + } + + pub async fn shutdown(mut self) -> Result<(), HapError> { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + let Some(mut task) = self.task.take() else { + return Ok(()); + }; + match timeout(self.shutdown_timeout, &mut task).await { + Ok(result) => { + result.map_err(|error| HapError::Server(format!("server task failed: {error}")))? + } + Err(_) => { + task.abort(); + let _ = task.await; + Err(HapError::Server( + "server shutdown timed out; task aborted".into(), + )) + } + } + } +} + +impl Drop for HapServerHandle { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(task) = &self.task { + task.abort(); + } + } +} + +/// Start the bounded listener and advertise the actual bound port. +pub async fn start_server( + config: HapServerConfig, + bridge: HapBridge, + pairings: Arc, + advertiser: Arc, +) -> Result { + config.validate()?; + let listener = TcpListener::bind(config.bind_addr) + .await + .map_err(|error| HapError::Server(format!("bind {}: {error}", config.bind_addr)))?; + let local_addr = listener + .local_addr() + .map_err(|error| HapError::Server(format!("read local address: {error}")))?; + + let mut record = bridge.service_record.clone(); + record.port = local_addr.port(); + record.paired = pairings.is_paired()?; + advertiser.advertise(&record).await?; + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let task_config = config.clone(); + let task = tokio::spawn(run_listener( + listener, + task_config, + bridge, + pairings, + advertiser, + record.instance_name, + shutdown_rx, + )); + Ok(HapServerHandle { + local_addr, + shutdown: Some(shutdown_tx), + task: Some(task), + // The listener owns the configured drain window; the handle allows a + // small scheduling/retraction margin before enforcing its outer abort. + shutdown_timeout: config + .shutdown_timeout + .saturating_add(Duration::from_secs(1)), + }) +} + +async fn run_listener( + listener: TcpListener, + config: HapServerConfig, + bridge: HapBridge, + pairings: Arc, + advertiser: Arc, + instance_name: String, + mut shutdown: oneshot::Receiver<()>, +) -> Result<(), HapError> { + let permits = Arc::new(Semaphore::new(config.max_connections)); + let mut connections = JoinSet::new(); + + loop { + let permit = tokio::select! { + _ = &mut shutdown => break, + permit = permits.clone().acquire_owned() => { + permit.map_err(|_| HapError::Server("connection semaphore closed".into()))? + } + }; + let accepted = tokio::select! { + _ = &mut shutdown => { + drop(permit); + break; + } + accepted = listener.accept() => accepted + }; + match accepted { + Ok((stream, peer)) => { + let bridge = bridge.clone(); + let pairings = pairings.clone(); + let limits = config.clone(); + connections.spawn(async move { + let _permit = permit; + if let Err(error) = + serve_connection(stream, peer, limits, bridge, pairings).await + { + tracing::debug!(%peer, %error, "HAP connection closed"); + } + }); + } + Err(error) => { + tracing::warn!(%error, "HAP accept failed"); + } + } + while connections.try_join_next().is_some() {} + } + + drop(listener); + let deadline = Instant::now() + config.shutdown_timeout; + while !connections.is_empty() { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() || timeout(remaining, connections.join_next()).await.is_err() { + connections.abort_all(); + while connections.join_next().await.is_some() {} + break; + } + } + advertiser.retract(&instance_name).await +} + +async fn serve_connection( + mut stream: TcpStream, + _peer: SocketAddr, + config: HapServerConfig, + bridge: HapBridge, + pairings: Arc, +) -> Result<(), HapError> { + let mut buffer = ConnectionBuffer::default(); + let mut session = Session::new(); + let mut subscriptions = HashSet::new(); + let mut events = bridge.subscribe_events(); + + loop { + tokio::select! { + request = timeout( + config.request_timeout, + read_request(&mut stream, &mut buffer, &config), + ) => { + let request = match request { + Ok(Ok(Some(request))) => request, + Ok(Ok(None)) => break, + Ok(Err(error)) => { + let response = error_response(&error); + write_response(&mut stream, response).await?; + break; + } + Err(_) => { + write_response(&mut stream, Response::plain(408, b"request timeout".to_vec())).await?; + break; + } + }; + let close = request.connection_close; + let response = dispatch_request( + request, + &mut session, + &bridge, + &pairings, + &mut subscriptions, + ); + write_response(&mut stream, response).await?; + if close { + break; + } + } + event = events.recv(), if session.state().is_authenticated() && !subscriptions.is_empty() => { + match event { + Ok(event) => { + if let Some(payload) = event_payload(&bridge, &event, &subscriptions) { + write_event(&mut stream, payload).await?; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + // A lagged controller must resynchronize through GET /characteristics. + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } + } + session.close(); + Ok(()) +} + +#[derive(Debug)] +struct Request { + method: String, + target: String, + body: Vec, + connection_close: bool, +} + +#[derive(Default)] +struct ConnectionBuffer { + bytes: Vec, +} + +async fn read_request( + stream: &mut TcpStream, + buffer: &mut ConnectionBuffer, + config: &HapServerConfig, +) -> Result, RequestReadError> { + let header_end = loop { + if let Some(position) = find_header_end(&buffer.bytes) { + break position + 4; + } + 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 { + return if buffer.bytes.is_empty() { + Ok(None) + } else { + Err(RequestReadError::Malformed("truncated HTTP headers")) + }; + } + buffer.bytes.extend_from_slice(&chunk[..read]); + }; + if header_end > config.max_header_bytes { + return Err(RequestReadError::HeadersTooLarge); + } + + let mut headers = [httparse::EMPTY_HEADER; 32]; + let mut parsed = httparse::Request::new(&mut headers); + match parsed.parse(&buffer.bytes[..header_end]) { + Ok(Status::Complete(_)) => {} + Ok(Status::Partial) => return Err(RequestReadError::Malformed("partial HTTP request")), + Err(error) => return Err(RequestReadError::MalformedOwned(error.to_string())), + } + if parsed.version != Some(1) { + return Err(RequestReadError::Malformed("HTTP/1.1 required")); + } + let method = parsed + .method + .ok_or(RequestReadError::Malformed("missing method"))? + .to_owned(); + let target = parsed + .path + .ok_or(RequestReadError::Malformed("missing request target"))? + .to_owned(); + if target.len() > 2048 || !target.starts_with('/') { + return Err(RequestReadError::Malformed("invalid request target")); + } + + let mut content_length = None; + let mut connection_close = false; + for header in parsed.headers.iter() { + if header.name.eq_ignore_ascii_case("transfer-encoding") { + return Err(RequestReadError::Malformed( + "Transfer-Encoding is unsupported", + )); + } + if header.name.eq_ignore_ascii_case("content-length") { + if content_length.is_some() { + return Err(RequestReadError::Malformed("duplicate Content-Length")); + } + let value = std::str::from_utf8(header.value) + .map_err(|_| RequestReadError::Malformed("non-UTF8 Content-Length"))?; + content_length = Some( + value + .parse::() + .map_err(|_| RequestReadError::Malformed("invalid Content-Length"))?, + ); + } + if header.name.eq_ignore_ascii_case("connection") + && header.value.eq_ignore_ascii_case(b"close") + { + connection_close = true; + } + } + let content_length = content_length.unwrap_or(0); + if content_length > config.max_body_bytes { + return Err(RequestReadError::BodyTooLarge); + } + let request_end = header_end + .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 { + return Err(RequestReadError::Malformed("truncated HTTP body")); + } + buffer.bytes.extend_from_slice(&chunk[..read]); + } + let body = buffer.bytes[header_end..request_end].to_vec(); + buffer.bytes.drain(..request_end); + Ok(Some(Request { + method, + target, + body, + connection_close, + })) +} + +fn find_header_end(bytes: &[u8]) -> Option { + bytes.windows(4).position(|window| window == b"\r\n\r\n") +} + +#[derive(Debug)] +enum RequestReadError { + Io(std::io::Error), + Malformed(&'static str), + MalformedOwned(String), + HeadersTooLarge, + BodyTooLarge, +} + +impl std::fmt::Display for RequestReadError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(error) => write!(formatter, "{error}"), + Self::Malformed(message) => formatter.write_str(message), + 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"), + } + } +} + +fn error_response(error: &RequestReadError) -> Response { + match error { + RequestReadError::HeadersTooLarge => Response::plain(431, error.to_string().into_bytes()), + RequestReadError::BodyTooLarge => Response::plain(413, error.to_string().into_bytes()), + _ => Response::plain(400, error.to_string().into_bytes()), + } +} + +struct Response { + status: u16, + content_type: &'static str, + body: Vec, +} + +impl Response { + fn plain(status: u16, body: Vec) -> Self { + Self { + status, + content_type: "text/plain; charset=utf-8", + body, + } + } + + fn json(status: u16, value: Value) -> Self { + Self { + status, + content_type: HAP_JSON, + body: serde_json::to_vec(&value).expect("JSON value serialization cannot fail"), + } + } +} + +fn dispatch_request( + request: Request, + session: &mut Session, + bridge: &HapBridge, + pairings: &PairingStore, + subscriptions: &mut HashSet<(u64, u64)>, +) -> Response { + 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()), + } + } + ("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 !session.state().is_authenticated() => 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) + } + ("POST", "/pairings") => Response::json( + 501, + json!({"status": -70406, "message": "Pairings management awaits encrypted transport"}), + ), + _ => Response::plain(404, b"not found".to_vec()), + } +} + +fn accessories_json(bridge: &HapBridge) -> Value { + let accessories = indexed_accessories(bridge); + let mut output = vec![json!({ + "aid": 1, + "services": [accessory_information(1, "HOMECORE Bridge")] + })]; + for (aid, accessory) in accessories { + let mut characteristics = Vec::new(); + for (index, (kind, value)) in accessory.mapping.characteristics.iter().enumerate() { + characteristics.push(json!({ + "iid": 8 + index as u64, + "type": characteristic_type(*kind), + "perms": ["pr", "ev"], + "format": characteristic_format(value), + "value": characteristic_value(value), + })); + } + output.push(json!({ + "aid": aid, + "services": [ + accessory_information(1, accessory.entity_id.as_str()), + { + "iid": 7, + "type": service_type(accessory.accessory_type), + "primary": true, + "characteristics": characteristics, + } + ] + })); + } + json!({ "accessories": output }) +} + +fn accessory_information(iid: u64, name: &str) -> Value { + json!({ + "iid": iid, + "type": "3E", + "characteristics": [ + {"iid": iid + 1, "type": "23", "perms": ["pr"], "format": "string", "value": name}, + {"iid": iid + 2, "type": "20", "perms": ["pr"], "format": "string", "value": "HOMECORE"}, + {"iid": iid + 3, "type": "21", "perms": ["pr"], "format": "string", "value": "HOMECORE HAP Bridge"}, + {"iid": iid + 4, "type": "30", "perms": ["pr"], "format": "string", "value": name}, + {"iid": iid + 5, "type": "52", "perms": ["pr"], "format": "string", "value": env!("CARGO_PKG_VERSION")} + ] + }) +} + +fn indexed_accessories(bridge: &HapBridge) -> Vec<(u64, ExposedAccessory)> { + let mut accessories = bridge.running_accessories(); + accessories.sort_by(|left, right| left.entity_id.as_str().cmp(right.entity_id.as_str())); + accessories + .into_iter() + .enumerate() + .map(|(index, accessory)| (index as u64 + 2, accessory)) + .collect() +} + +fn characteristics_response(target: &str, bridge: &HapBridge) -> Response { + let Some(query) = target.split_once('?').map(|(_, query)| query) else { + return Response::plain(400, b"missing characteristic query".to_vec()); + }; + let Some(ids) = query.split('&').find_map(|part| part.strip_prefix("id=")) else { + return Response::plain(400, b"missing id query".to_vec()); + }; + if ids.len() > 4096 || ids.split(',').count() > 128 { + return Response::plain(400, b"characteristic query too large".to_vec()); + } + let accessories = indexed_accessories(bridge); + let mut values = Vec::new(); + for id in ids.split(',') { + let Some((aid, iid)) = parse_aid_iid(id) else { + return Response::plain(400, b"invalid aid.iid".to_vec()); + }; + let value = accessories + .iter() + .find(|(candidate, _)| *candidate == aid) + .and_then(|(_, accessory)| { + accessory + .mapping + .characteristics + .get(iid.saturating_sub(8) as usize) + }) + .map(|(_, value)| characteristic_value(value)); + values.push(match value { + Some(value) => json!({"aid": aid, "iid": iid, "value": value}), + None => json!({"aid": aid, "iid": iid, "status": -70409}), + }); + } + Response::json(207, json!({"characteristics": values})) +} + +fn characteristic_subscription_response( + body: &[u8], + subscriptions: &mut HashSet<(u64, u64)>, +) -> Response { + let Ok(value) = serde_json::from_slice::(body) else { + return Response::plain(400, b"invalid characteristic JSON".to_vec()); + }; + let Some(items) = value.get("characteristics").and_then(Value::as_array) else { + return Response::plain(400, b"missing characteristics array".to_vec()); + }; + if items.len() > 128 { + return Response::plain(400, b"too many characteristic writes".to_vec()); + } + for item in items { + let (Some(aid), Some(iid), Some(enabled)) = ( + item.get("aid").and_then(Value::as_u64), + item.get("iid").and_then(Value::as_u64), + item.get("ev").and_then(Value::as_bool), + ) else { + // Entity writes are not yet connected to HOMECORE service calls. + return Response::json(207, json!({"characteristics": [{"status": -70405}]})); + }; + if enabled { + subscriptions.insert((aid, iid)); + } else { + subscriptions.remove(&(aid, iid)); + } + } + Response { + status: 204, + content_type: HAP_JSON, + body: Vec::new(), + } +} + +fn event_payload( + bridge: &HapBridge, + event: &CharacteristicEvent, + subscriptions: &HashSet<(u64, u64)>, +) -> Option> { + let (aid, _) = indexed_accessories(bridge) + .into_iter() + .find(|(_, accessory)| accessory.entity_id == event.entity_id)?; + let values: Vec = event + .characteristics + .iter() + .enumerate() + .filter_map(|(index, (_, value))| { + let iid = index as u64 + 8; + subscriptions + .contains(&(aid, iid)) + .then(|| json!({"aid": aid, "iid": iid, "value": characteristic_value(value)})) + }) + .collect(); + (!values.is_empty()) + .then(|| serde_json::to_vec(&json!({"characteristics": values})).expect("serialize event")) +} + +fn parse_aid_iid(value: &str) -> Option<(u64, u64)> { + let (aid, iid) = value.split_once('.')?; + Some((aid.parse().ok()?, iid.parse().ok()?)) +} + +fn characteristic_value(value: &HapCharacteristicValue) -> Value { + match value { + HapCharacteristicValue::Bool(value) => json!(value), + HapCharacteristicValue::UInt8(value) => json!(value), + HapCharacteristicValue::Float(value) => json!(value), + } +} + +fn characteristic_format(value: &HapCharacteristicValue) -> &'static str { + match value { + HapCharacteristicValue::Bool(_) => "bool", + HapCharacteristicValue::UInt8(_) => "uint8", + HapCharacteristicValue::Float(_) => "float", + } +} + +fn service_type(kind: HapAccessoryType) -> &'static str { + match kind { + HapAccessoryType::Lightbulb => "43", + HapAccessoryType::Switch => "49", + HapAccessoryType::OccupancySensor => "86", + HapAccessoryType::MotionSensor => "85", + HapAccessoryType::TemperatureSensor => "8A", + HapAccessoryType::HumiditySensor => "82", + HapAccessoryType::LeakSensor => "83", + HapAccessoryType::ContactSensor => "80", + HapAccessoryType::Door => "81", + HapAccessoryType::Lock => "45", + HapAccessoryType::SecuritySystem => "7E", + } +} + +fn characteristic_type(kind: HapCharacteristic) -> &'static str { + match kind { + HapCharacteristic::On => "25", + HapCharacteristic::Brightness => "8", + HapCharacteristic::CurrentTemperature => "11", + HapCharacteristic::CurrentRelativeHumidity => "10", + HapCharacteristic::OccupancyDetected => "71", + HapCharacteristic::MotionDetected => "22", + HapCharacteristic::LeakDetected => "70", + HapCharacteristic::ContactSensorState => "6A", + HapCharacteristic::CurrentDoorState => "E", + HapCharacteristic::LockCurrentState => "1D", + HapCharacteristic::SecuritySystemCurrentState => "66", + } +} + +async fn write_response(stream: &mut TcpStream, response: Response) -> Result<(), HapError> { + let reason = match response.status { + 200 => "OK", + 204 => "No Content", + 207 => "Multi-Status", + 400 => "Bad Request", + 404 => "Not Found", + 408 => "Request Timeout", + 413 => "Payload Too Large", + 431 => "Request Header Fields Too Large", + 470 => "Connection Authorization Required", + 501 => "Not Implemented", + _ => "Error", + }; + let header = 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}"))) +} + +async fn write_event(stream: &mut TcpStream, body: Vec) -> Result<(), HapError> { + let header = format!( + "EVENT/1.0 200 OK\r\nContent-Type: {HAP_JSON}\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + stream + .write_all(header.as_bytes()) + .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}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mdns::{HapServiceRecord, NullAdvertiser}; + use homecore::entity::{EntityId, State}; + use homecore::event::Context; + + fn bridge() -> HapBridge { + let bridge = HapBridge::new(HapServiceRecord::bridge( + "RuView Sense", + 51826, + "AA:BB:CC:DD:EE:FF", + )); + let entity_id = EntityId::parse("binary_sensor.room_occupancy").unwrap(); + let state = State::new( + entity_id.clone(), + "on", + json!({"device_class": "occupancy"}), + Context::default(), + ); + bridge.add_accessory(&entity_id, &state).unwrap(); + bridge + } + + async fn server() -> (HapServerHandle, tempfile::TempDir) { + let directory = tempfile::tempdir().unwrap(); + let pairings = + Arc::new(PairingStore::open(directory.path().join("pairings.json")).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) + } + + async fn exchange(addr: SocketAddr, request: &[u8]) -> Vec { + let mut stream = TcpStream::connect(addr).await.unwrap(); + stream.write_all(request).await.unwrap(); + stream.shutdown().await.unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + response + } + + #[tokio::test] + async fn lifecycle_binds_and_shuts_down() { + let (server, _directory) = server().await; + assert_ne!(server.local_addr().port(), 0); + server.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn shutdown_remains_bounded_with_idle_connection() { + let (server, _directory) = server().await; + let _idle = TcpStream::connect(server.local_addr()).await.unwrap(); + timeout(Duration::from_secs(3), server.shutdown()) + .await + .expect("shutdown exceeded its outer bound") + .unwrap(); + } + + #[tokio::test] + async fn unauthenticated_accessory_request_is_gated() { + let (server, _directory) = server().await; + let response = exchange( + server.local_addr(), + b"GET /accessories HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ) + .await; + assert!(response.starts_with(b"HTTP/1.1 470")); + server.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn pairing_endpoint_returns_explicit_unavailable_tlv() { + 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", + ) + .await; + assert!(response.starts_with(b"HTTP/1.1 200")); + assert!(response.ends_with(b"\x06\x01\x02\x07\x01\x06")); + server.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn malformed_and_oversized_requests_are_rejected() { + let (server, _directory) = server().await; + let malformed = exchange( + server.local_addr(), + b"GET / HTTP/1.0\r\nConnection: close\r\n\r\n", + ) + .await; + assert!(malformed.starts_with(b"HTTP/1.1 400")); + let oversized = exchange( + server.local_addr(), + b"POST /pair-setup HTTP/1.1\r\nContent-Length: 999999\r\nConnection: close\r\n\r\n", + ) + .await; + assert!(oversized.starts_with(b"HTTP/1.1 413")); + server.shutdown().await.unwrap(); + } + + #[test] + 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 mut session = Session::authenticated_for_test(true); + let mut subscriptions = HashSet::new(); + let response = dispatch_request( + Request { + method: "GET".into(), + target: "/accessories".into(), + body: Vec::new(), + connection_close: false, + }, + &mut session, + &bridge, + &pairings, + &mut subscriptions, + ); + assert_eq!(response.status, 200); + let body: Value = serde_json::from_slice(&response.body).unwrap(); + assert_eq!(body["accessories"].as_array().unwrap().len(), 2); + + let response = dispatch_request( + Request { + method: "PUT".into(), + target: "/characteristics".into(), + body: br#"{"characteristics":[{"aid":2,"iid":8,"ev":true}]}"#.to_vec(), + connection_close: false, + }, + &mut session, + &bridge, + &pairings, + &mut subscriptions, + ); + assert_eq!(response.status, 204); + assert!(subscriptions.contains(&(2, 8))); + } +} diff --git a/v2/crates/homecore-hap/src/session.rs b/v2/crates/homecore-hap/src/session.rs new file mode 100644 index 00000000..4433d842 --- /dev/null +++ b/v2/crates/homecore-hap/src/session.rs @@ -0,0 +1,212 @@ +//! 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}; + +use crate::error::HapError; +use crate::pairing::PairingStore; + +/// Authentication phase of one TCP connection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionState { + Connected, + PairSetup, + PairVerify, + Authenticated { controller_id: String, admin: bool }, + Closing, +} + +impl SessionState { + fn name(&self) -> &'static str { + match self { + Self::Connected => "connected", + Self::PairSetup => "pair-setup", + Self::PairVerify => "pair-verify", + Self::Authenticated { .. } => "authenticated", + Self::Closing => "closing", + } + } + + /// 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, +} + +impl Default for Session { + fn default() -> Self { + Self::new() + } +} + +impl Session { + pub fn new() -> Self { + Self { + state: SessionState::Connected, + } + } + + pub fn state(&self) -> &SessionState { + &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(), + )); + } + self.transition(SessionState::PairSetup) + } + + pub fn begin_pair_verify(&mut self) -> Result<(), HapError> { + 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( + &mut self, + controller_id: &str, + signed_transcript: &[u8], + signature: &[u8; 64], + pairings: &PairingStore, + ) -> Result<(), HapError> { + if !matches!(self.state, SessionState::PairVerify) { + return Err(HapError::InvalidSessionTransition { + from: self.state.name(), + 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, + }; + Ok(()) + } + + pub fn reset_pairing(&mut self) -> Result<(), HapError> { + match self.state { + SessionState::PairSetup | SessionState::PairVerify => { + self.state = SessionState::Connected; + Ok(()) + } + _ => Err(HapError::InvalidSessionTransition { + from: self.state.name(), + to: "connected", + }), + } + } + + pub fn close(&mut self) { + self.state = SessionState::Closing; + } + + #[cfg(all(test, feature = "hap-server"))] + pub(crate) fn authenticated_for_test(admin: bool) -> Self { + Self { + state: SessionState::Authenticated { + controller_id: "test-controller".into(), + admin, + }, + } + } + + fn transition(&mut self, next: SessionState) -> Result<(), HapError> { + if matches!(self.state, SessionState::Connected) { + self.state = next; + Ok(()) + } else { + Err(HapError::InvalidSessionTransition { + from: self.state.name(), + to: next.name(), + }) + } + } +} + +#[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(); + let mut session = Session::new(); + session.begin_pair_verify().unwrap(); + session + .authenticate_pair_verify("controller", transcript, &signature, &store) + .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()); + } + + #[test] + fn pairing_phases_cannot_overlap() { + let mut session = Session::new(); + session.begin_pair_setup(false).unwrap(); + assert!(session.begin_pair_verify().is_err()); + session.reset_pairing().unwrap(); + session.begin_pair_verify().unwrap(); + } +}