mirror of
https://github.com/ruvnet/RuView
synced 2026-08-07 20:01:43 +00:00
feat(homecore-hap): add fail-closed network foundation
This commit is contained in:
Generated
+4
@@ -3600,9 +3600,13 @@ name = "homecore-hap"
|
|||||||
version = "0.1.0-alpha.0"
|
version = "0.1.0-alpha.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"ed25519-dalek",
|
||||||
"homecore",
|
"homecore",
|
||||||
|
"httparse",
|
||||||
|
"mdns-sd",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ version = "0.1.0-alpha.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
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"
|
repository = "https://github.com/ruvnet/wifi-densepose"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -19,18 +19,24 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
# P2: gates the actual hap = "0.1" crate integration + real mDNS via mdns-sd
|
# Enables the bounded TCP/HTTP listener and real `_hap._tcp` mDNS advertiser.
|
||||||
hap-server = []
|
# 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]
|
[dependencies]
|
||||||
homecore = { path = "../homecore" }
|
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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
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]
|
[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"] }
|
||||||
|
|||||||
@@ -1,121 +1,112 @@
|
|||||||
# homecore-hap
|
# 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.
|
||||||
|
|
||||||
[](https://crates.io/crates/homecore-hap)
|
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
|
||||||
[](https://github.com/ruvnet/RuView)
|
plaintext header, bearer-token, or test credential bypass.
|
||||||
[](../../docs/adr/ADR-125-homecore-apple-home-homekit-bridge.md)
|
|
||||||
|
|
||||||
**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.)
|
The following must land together before this crate may claim Apple Home
|
||||||
- **HapCharacteristic enum** — HAP characteristic types (`On`, `Brightness`, `Temperature`, `TargetLockState`, etc.)
|
interoperability:
|
||||||
- **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 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
|
## Server integration
|
||||||
- **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)
|
|
||||||
|
|
||||||
## Capabilities
|
```rust,no_run
|
||||||
|
use std::{net::IpAddr, sync::Arc};
|
||||||
|
use homecore_hap::{
|
||||||
|
start_server, HapBridge, HapServerConfig, HapServiceRecord,
|
||||||
|
MdnsSdAdvertiser, PairingStore,
|
||||||
|
};
|
||||||
|
|
||||||
| Capability | Type | Method | Notes |
|
# async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|------------|------|--------|-------|
|
let record = HapServiceRecord::bridge(
|
||||||
| Define accessory type | Trait | `HapAccessoryType::Light` etc. (11 variants) | Enum; no instantiation yet (P1) |
|
"HOMECORE Bridge",
|
||||||
| Define characteristic | Trait | `HapCharacteristic::On`, `Brightness`, etc. | Enum; values encoded as HAP TLV |
|
51826,
|
||||||
| Map entity to accessory | Mapping | `EntityToAccessoryMapper::map_light()` | Takes `EntityId` + `State`; returns `HapAccessory` |
|
"AA:BB:CC:DD:EE:FF",
|
||||||
| 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 |
|
let bridge = HapBridge::new(record);
|
||||||
| Advertise bridge (P2) | mDNS | `mdns_sd::ServiceInstanceBuilder` | Real mDNS via `--features hap-server` |
|
let pairings = Arc::new(PairingStore::open(
|
||||||
| Bridge state query | Bridge | `HapBridge::list_accessories()` | Returns exposed accessories + their characteristics |
|
"/var/lib/homecore-hap/pairings.json",
|
||||||
| 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 |
|
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 |
|
// Feed HOMECORE StateChanged events through bridge.update_accessory(...).
|
||||||
|--------|----------------|--------------|
|
// On process shutdown:
|
||||||
| Framework | HA's `hap-python` (pure Python) | Rust 1.89+ with HAP trait abstraction |
|
server.shutdown().await?;
|
||||||
| Server type | Python asyncio HAP-1.1 server | TLS server trait (P2); stub in P1 |
|
# Ok(())
|
||||||
| 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Real HAP server (P2, via `--features hap-server`):
|
Build and test:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build -p homecore-hap --features hap-server
|
cargo test -p homecore-hap --no-default-features
|
||||||
# The server will advertise over mDNS and accept HomeKit pairing requests
|
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.
|
||||||
|
|
||||||
```
|
## Decisions
|
||||||
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)
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
- [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-125: HOMECORE Apple Home / HomeKit Bridge](../../docs/adr/ADR-125-homecore-apple-home-homekit-bridge.md)
|
- [ADR-161 — server-layer security and explicit HAP deferral](../../docs/adr/ADR-161-homecore-server-layer-security.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)
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
//! `HapBridge` — owns the set of HOMECORE entities exposed as HAP accessories.
|
//! `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
|
//! The bridge owns mappings and their event stream. The feature-gated network
|
||||||
//! crates (and P2's `hap-server` feature) can register accessories and query
|
//! lifecycle is started separately with `start_server`.
|
||||||
//! their current mapping. The actual mDNS + HAP pairing is gated to P2.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use homecore::entity::EntityId;
|
use homecore::entity::EntityId;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use crate::accessory::HapAccessoryType;
|
use crate::accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||||
use crate::error::HapError;
|
use crate::error::HapError;
|
||||||
use crate::mapping::{AccessoryMapping, EntityToAccessoryMapper};
|
use crate::mapping::{AccessoryMapping, EntityToAccessoryMapper};
|
||||||
use crate::mdns::{HapServiceRecord, MdnsAdvertiser, NullAdvertiser};
|
use crate::mdns::{HapServiceRecord, MdnsAdvertiser, NullAdvertiser};
|
||||||
@@ -22,37 +22,49 @@ pub struct ExposedAccessory {
|
|||||||
pub mapping: AccessoryMapping,
|
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 {
|
struct BridgeInner {
|
||||||
accessories: HashMap<EntityId, ExposedAccessory>,
|
accessories: HashMap<EntityId, ExposedAccessory>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The P1 HAP bridge.
|
/// HOMECORE-to-HAP accessory bridge state.
|
||||||
///
|
///
|
||||||
/// Call [`HapBridge::add_accessory`] to register entities and
|
/// Call [`HapBridge::add_accessory`] to register entities and
|
||||||
/// [`HapBridge::running_accessories`] to read back what is currently
|
/// [`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)]
|
#[derive(Clone)]
|
||||||
pub struct HapBridge {
|
pub struct HapBridge {
|
||||||
inner: Arc<RwLock<BridgeInner>>,
|
inner: Arc<RwLock<BridgeInner>>,
|
||||||
advertiser: Arc<dyn MdnsAdvertiser>,
|
advertiser: Arc<dyn MdnsAdvertiser>,
|
||||||
|
events: broadcast::Sender<CharacteristicEvent>,
|
||||||
pub service_record: HapServiceRecord,
|
pub service_record: HapServiceRecord,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HapBridge {
|
impl HapBridge {
|
||||||
/// Create a bridge with the given service record and a `NullAdvertiser`
|
/// Create a bridge with the given service record and a `NullAdvertiser`.
|
||||||
/// (P1 default — real mDNS lands in P2).
|
|
||||||
pub fn new(service_record: HapServiceRecord) -> Self {
|
pub fn new(service_record: HapServiceRecord) -> Self {
|
||||||
Self::with_advertiser(service_record, Arc::new(NullAdvertiser))
|
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(
|
pub fn with_advertiser(
|
||||||
service_record: HapServiceRecord,
|
service_record: HapServiceRecord,
|
||||||
advertiser: Arc<dyn MdnsAdvertiser>,
|
advertiser: Arc<dyn MdnsAdvertiser>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let (events, _) = broadcast::channel(128);
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(RwLock::new(BridgeInner { accessories: HashMap::new() })),
|
inner: Arc::new(RwLock::new(BridgeInner {
|
||||||
|
accessories: HashMap::new(),
|
||||||
|
})),
|
||||||
advertiser,
|
advertiser,
|
||||||
|
events,
|
||||||
service_record,
|
service_record,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,9 +109,46 @@ impl HapBridge {
|
|||||||
Ok(())
|
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.
|
/// Snapshot all currently registered accessories.
|
||||||
pub fn running_accessories(&self) -> Vec<ExposedAccessory> {
|
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.
|
/// Number of registered accessories.
|
||||||
@@ -111,21 +160,25 @@ impl HapBridge {
|
|||||||
self.len() == 0
|
self.len() == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// P2 stub — will start the HAP-1.1 server + mDNS advertisement.
|
/// Start advertisement only.
|
||||||
/// In P1 this only fires the null advertiser.
|
///
|
||||||
|
/// 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> {
|
pub async fn start(&self) -> Result<(), HapError> {
|
||||||
self.advertiser.advertise(&self.service_record).await?;
|
self.advertiser.advertise(&self.service_record).await?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
instance = %self.service_record.instance_name,
|
instance = %self.service_record.instance_name,
|
||||||
port = self.service_record.port,
|
port = self.service_record.port,
|
||||||
"HapBridge started (P1 — no real HAP server; mDNS stub only)"
|
"HAP advertisement started without a TCP server"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Graceful shutdown — retracts mDNS advertisement.
|
/// Graceful shutdown — retracts mDNS advertisement.
|
||||||
pub async fn stop(&self) -> Result<(), HapError> {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,18 +190,22 @@ mod tests {
|
|||||||
use homecore::event::Context;
|
use homecore::event::Context;
|
||||||
|
|
||||||
fn make_bridge() -> HapBridge {
|
fn make_bridge() -> HapBridge {
|
||||||
HapBridge::new(HapServiceRecord {
|
HapBridge::new(HapServiceRecord::bridge(
|
||||||
instance_name: "RuView Sense".into(),
|
"RuView Sense",
|
||||||
port: 51826,
|
51826,
|
||||||
setup_code: "111-22-333".into(),
|
"AA:BB:CC:DD:EE:FF",
|
||||||
device_id: "AA:BB:CC:DD:EE:FF".into(),
|
))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn light_state(name: &str, on: bool, brightness: u8) -> (EntityId, State) {
|
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 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)
|
(eid, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +244,22 @@ mod tests {
|
|||||||
assert!(matches!(err, HapError::EntityNotFound(_)));
|
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]
|
#[tokio::test]
|
||||||
async fn start_stop_with_null_advertiser() {
|
async fn start_stop_with_null_advertiser() {
|
||||||
let bridge = make_bridge();
|
let bridge = make_bridge();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Unified error type for `homecore-hap`.
|
//! Unified error type for `homecore-hap`.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Errors produced by the HAP bridge and its sub-components.
|
/// Errors produced by the HAP bridge and its sub-components.
|
||||||
@@ -19,4 +20,31 @@ pub enum HapError {
|
|||||||
|
|
||||||
#[error("bridge not running")]
|
#[error("bridge not running")]
|
||||||
NotRunning,
|
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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
//! `homecore-hap` — Apple Home HomeKit Accessory Protocol bridge (ADR-125).
|
//! `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
|
//! The crate provides persisted controller records, a fail-closed session
|
||||||
//! states onto HAP accessory / characteristic values. The actual HAP-1.1 TLS
|
//! state machine, bounded TLV8 parsing, characteristic event flow, and (with
|
||||||
//! server and real mDNS advertisement are gated behind the `hap-server`
|
//! `hap-server`) a bounded TCP/HTTP listener plus real mDNS. It does **not**
|
||||||
//! feature (P2). P1 ships `NullAdvertiser` (no-op) so the bridge compiles and
|
//! yet implement SRP Pair-Setup, X25519/HKDF/ChaCha20-Poly1305 Pair-Verify, or
|
||||||
//! all tests pass with `--no-default-features`.
|
//! encrypted HAP framing, so Apple Home pairing is deliberately unavailable.
|
||||||
//!
|
//!
|
||||||
//! # Module layout
|
//! # Module layout
|
||||||
//!
|
//!
|
||||||
@@ -16,7 +16,11 @@
|
|||||||
//! | [`mapping`] | `EntityToAccessoryMapper` — HOMECORE entity → HAP |
|
//! | [`mapping`] | `EntityToAccessoryMapper` — HOMECORE entity → HAP |
|
||||||
//! | [`bridge`] | `HapBridge` — owns exposed accessories |
|
//! | [`bridge`] | `HapBridge` — owns exposed accessories |
|
||||||
//! | [`mdns`] | `MdnsAdvertiser` trait + `NullAdvertiser` stub |
|
//! | [`mdns`] | `MdnsAdvertiser` trait + `NullAdvertiser` stub |
|
||||||
|
//! | [`pairing`] | Atomic controller pairing persistence |
|
||||||
|
//! | [`protocol`] | Bounded TLV8 protocol primitives |
|
||||||
//! | [`ruview`] | `RuViewToHapMapper` — sensing primitives → HAP |
|
//! | [`ruview`] | `RuViewToHapMapper` — sensing primitives → HAP |
|
||||||
|
//! | [`session`] | Authenticated request-gating state machine |
|
||||||
|
//! | `server` | Feature-gated bounded TCP/HTTP lifecycle |
|
||||||
//! | [`error`] | Unified `HapError` type |
|
//! | [`error`] | Unified `HapError` type |
|
||||||
|
|
||||||
pub mod accessory;
|
pub mod accessory;
|
||||||
@@ -24,11 +28,22 @@ pub mod bridge;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod mapping;
|
pub mod mapping;
|
||||||
pub mod mdns;
|
pub mod mdns;
|
||||||
|
pub mod pairing;
|
||||||
|
pub mod protocol;
|
||||||
pub mod ruview;
|
pub mod ruview;
|
||||||
|
#[cfg(feature = "hap-server")]
|
||||||
|
pub mod server;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
pub use accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
pub use accessory::{HapAccessoryType, HapCharacteristic, HapCharacteristicValue};
|
||||||
pub use bridge::{ExposedAccessory, HapBridge};
|
pub use bridge::{CharacteristicEvent, ExposedAccessory, HapBridge};
|
||||||
pub use error::HapError;
|
pub use error::HapError;
|
||||||
pub use mapping::EntityToAccessoryMapper;
|
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;
|
pub use ruview::RuViewToHapMapper;
|
||||||
|
#[cfg(feature = "hap-server")]
|
||||||
|
pub use server::{start_server, HapServerConfig, HapServerHandle};
|
||||||
|
pub use session::{Session, SessionState};
|
||||||
|
|||||||
@@ -1,61 +1,206 @@
|
|||||||
//! mDNS advertisement trait and P1 no-op stub.
|
//! HAP `_hap._tcp` advertisement.
|
||||||
//!
|
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::error::HapError;
|
use crate::error::HapError;
|
||||||
|
|
||||||
/// Service record advertised over mDNS for HAP discovery.
|
/// HAP service record advertised over mDNS.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct HapServiceRecord {
|
pub struct HapServiceRecord {
|
||||||
/// Service instance name shown in Apple Home ("RuView Sense").
|
/// Service instance shown in discovery UI.
|
||||||
pub instance_name: String,
|
pub instance_name: String,
|
||||||
/// TCP port the HAP server listens on (default 51826).
|
/// Bound HAP TCP port.
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// HAP pairing setup code (8 digits, formatted as XXX-XX-XXX).
|
/// Stable colon-separated accessory device identifier.
|
||||||
pub setup_code: String,
|
|
||||||
/// Unique device ID (colon-separated MAC-like hex, required by HAP §5.4).
|
|
||||||
pub device_id: String,
|
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`).
|
impl HapServiceRecord {
|
||||||
///
|
pub fn bridge(
|
||||||
/// Implementors register the `_hap._tcp` service so HomePod / Apple TV can
|
instance_name: impl Into<String>,
|
||||||
/// discover the bridge and initiate pairing. P1 provides only `NullAdvertiser`.
|
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]
|
#[async_trait]
|
||||||
pub trait MdnsAdvertiser: Send + Sync {
|
pub trait MdnsAdvertiser: Send + Sync {
|
||||||
/// Begin advertising the service. Idempotent.
|
|
||||||
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError>;
|
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError>;
|
||||||
|
|
||||||
/// Stop advertising. Called on bridge shutdown.
|
|
||||||
async fn retract(&self, instance_name: &str) -> Result<(), HapError>;
|
async fn retract(&self, instance_name: &str) -> Result<(), HapError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// No-op advertiser for P1 / test environments.
|
/// Deterministic no-network advertiser for tests and disabled deployments.
|
||||||
///
|
|
||||||
/// All calls succeed without touching the network.
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct NullAdvertiser;
|
pub struct NullAdvertiser;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl MdnsAdvertiser for NullAdvertiser {
|
impl MdnsAdvertiser for NullAdvertiser {
|
||||||
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> {
|
async fn advertise(&self, record: &HapServiceRecord) -> Result<(), HapError> {
|
||||||
|
record.validate()?;
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
instance = %record.instance_name,
|
instance = %record.instance_name,
|
||||||
port = record.port,
|
port = record.port,
|
||||||
"NullAdvertiser: skipping mDNS advertisement (P1 stub)"
|
"HAP mDNS advertisement disabled"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn retract(&self, instance_name: &str) -> Result<(), HapError> {
|
async fn retract(&self, instance_name: &str) -> Result<(), HapError> {
|
||||||
tracing::debug!(
|
tracing::debug!(instance = %instance_name, "HAP mDNS retraction disabled");
|
||||||
instance = %instance_name,
|
Ok(())
|
||||||
"NullAdvertiser: skipping mDNS retract (P1 stub)"
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
|
/// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,16 +209,28 @@ impl MdnsAdvertiser for NullAdvertiser {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[tokio::test]
|
||||||
async fn null_advertiser_is_noop() {
|
async fn null_advertiser_validates_without_network_io() {
|
||||||
let adv = NullAdvertiser;
|
let record = HapServiceRecord::bridge("RuView Sense", 51826, "AA:BB:CC:DD:EE:FF");
|
||||||
let rec = HapServiceRecord {
|
NullAdvertiser.advertise(&record).await.unwrap();
|
||||||
instance_name: "RuView Sense".into(),
|
NullAdvertiser.retract(&record.instance_name).await.unwrap();
|
||||||
port: 51826,
|
}
|
||||||
setup_code: "111-22-333".into(),
|
|
||||||
device_id: "AA:BB:CC:DD:EE:FF".into(),
|
#[tokio::test]
|
||||||
};
|
async fn malformed_record_is_rejected() {
|
||||||
adv.advertise(&rec).await.unwrap();
|
let record = HapServiceRecord::bridge("RuView Sense", 0, "not-an-id");
|
||||||
adv.retract(&rec.instance_name).await.unwrap();
|
assert!(NullAdvertiser.advertise(&record).await.is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user