feat(homecore-hap): add fail-closed network foundation

This commit is contained in:
ruv
2026-07-27 14:15:05 -04:00
parent 0a8e72e762
commit c2abe53e92
11 changed files with 2064 additions and 172 deletions
Generated
+4
View File
@@ -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",
+11 -5
View File
@@ -10,7 +10,7 @@ version = "0.1.0-alpha.0"
edition = "2021"
license = "MIT"
authors = ["rUv <ruv@ruv.net>", "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"] }
+90 -99
View File
@@ -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<dyn std::error::Error>> {
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::<IpAddr>()?,
)?);
## 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)
+96 -23
View File
@@ -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<EntityId, ExposedAccessory>,
}
/// 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<RwLock<BridgeInner>>,
advertiser: Arc<dyn MdnsAdvertiser>,
events: broadcast::Sender<CharacteristicEvent>,
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<dyn MdnsAdvertiser>,
) -> 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<CharacteristicEvent> {
self.events.subscribe()
}
/// Snapshot all currently registered accessories.
pub fn running_accessories(&self) -> Vec<ExposedAccessory> {
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();
+28
View File
@@ -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),
}
+23 -8
View File
@@ -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};
+194 -37
View File
@@ -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<String>,
port: u16,
device_id: impl Into<String>,
) -> 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<std::collections::HashMap<String, String>>,
}
#[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<String>, address: std::net::IpAddr) -> Result<Self, HapError> {
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<mdns_sd::ServiceInfo, HapError> {
record.validate()?;
let properties: std::collections::HashMap<String, String> =
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());
}
}
+366
View File
@@ -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<ControllerPairing>,
}
/// Thread-safe, atomically persisted controller pairing store.
#[derive(Debug)]
pub struct PairingStore {
path: PathBuf,
controllers: RwLock<BTreeMap<String, ControllerPairing>>,
}
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<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),
})
}
/// 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<Vec<ControllerPairing>, 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<Option<ControllerPairing>, 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<bool, HapError> {
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<BTreeMap<String, ControllerPairing>, 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<String, ControllerPairing>,
) -> 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 { .. })
));
}
}
+129
View File
@@ -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<u8, Vec<u8>>,
}
impl Tlv8 {
pub fn parse(input: &[u8]) -> Result<Self, HapError> {
if input.len() > MAX_TLV_BYTES {
return Err(HapError::Protocol("TLV8 body exceeds 4096 bytes".into()));
}
let mut values: BTreeMap<u8, Vec<u8>> = 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<u8> {
let value = self.get(kind)?;
(value.len() == 1).then_some(value[0])
}
pub fn insert(&mut self, kind: u8, value: impl Into<Vec<u8>>) {
self.values.insert(kind, value.into());
}
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
}
}
/// 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())
}
#[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));
}
}
+911
View File
@@ -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<oneshot::Sender<()>>,
task: Option<JoinHandle<Result<(), HapError>>>,
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<PairingStore>,
advertiser: Arc<dyn MdnsAdvertiser>,
) -> Result<HapServerHandle, HapError> {
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<PairingStore>,
advertiser: Arc<dyn MdnsAdvertiser>,
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<PairingStore>,
) -> 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<u8>,
connection_close: bool,
}
#[derive(Default)]
struct ConnectionBuffer {
bytes: Vec<u8>,
}
async fn read_request(
stream: &mut TcpStream,
buffer: &mut ConnectionBuffer,
config: &HapServerConfig,
) -> Result<Option<Request>, 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::<usize>()
.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<usize> {
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<u8>,
}
impl Response {
fn plain(status: u16, body: Vec<u8>) -> 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::<Value>(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<Vec<u8>> {
let (aid, _) = indexed_accessories(bridge)
.into_iter()
.find(|(_, accessory)| accessory.entity_id == event.entity_id)?;
let values: Vec<Value> = 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<u8>) -> 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<u8> {
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)));
}
}
+212
View File
@@ -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();
}
}