diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index c3e4955..2e519d4 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs @@ -10,8 +10,9 @@ use ziggurat_zigbee::nwk::frame::{ }; use ziggurat_zigbee::zdp::{ - DeviceAnnce, MgmtLqiReq, MgmtLqiRsp, MgmtRtgReq, MgmtRtgRsp, NeighborDescriptor, ParentAnnce, - ParentAnnceRsp, RoutingDescriptor, ZDP_PROFILE_ID, ZdpAffinity, ZdpClusterId, ZdpCommand, + DeviceAnnce, MgmtLqiReq, MgmtLqiRsp, MgmtRtgReq, MgmtRtgRsp, NeighborDescriptor, NodeDescReq, + NodeDescRsp, NodeDescriptor, ParentAnnce, ParentAnnceRsp, RoutingDescriptor, + STACK_COMPLIANCE_REVISION, ZDP_PROFILE_ID, ZdpAffinity, ZdpClusterId, ZdpCommand, ZdpDeviceType, ZdpPermitJoining, ZdpRouteStatus, ZdpRxOnWhenIdle, ZdpStatus, }; @@ -25,6 +26,10 @@ use crate::frame_token::TrafficClass; /// EUI64s per Parent_annce frame, keeping the ASDU within the NWK payload budget. const PARENT_ANNCE_CHILDREN_PER_FRAME: usize = 8; +fn should_process_parent_annce(source: Nwk, local: Nwk) -> bool { + source != local +} + /// Neighbor records per Mgmt_Lqi_rsp; the spec caps the count field at 2 /// (Table 2-101) and clients paginate with the start index. const MGMT_LQI_DESCRIPTORS_PER_FRAME: usize = 2; @@ -41,13 +46,49 @@ impl ZigbeeStack { } match ZdpClusterId::try_from(aps_frame.cluster_id) { + Ok(ZdpClusterId::NodeDescReq) => self.handle_node_desc_req(nwk_frame, aps_frame), Ok(ZdpClusterId::DeviceAnnce) => self.handle_device_annce(nwk_frame, aps_frame), Ok(ZdpClusterId::ParentAnnce) => self.handle_parent_annce(nwk_frame, aps_frame), Ok(ZdpClusterId::ParentAnnceRsp) => self.handle_parent_annce_rsp(nwk_frame, aps_frame), Ok(ZdpClusterId::MgmtLqiReq) => self.handle_mgmt_lqi_req(nwk_frame, aps_frame), Ok(ZdpClusterId::MgmtRtgReq) => self.handle_mgmt_rtg_req(nwk_frame, aps_frame), // Management responses from other devices are the client's business - Ok(ZdpClusterId::MgmtLqiRsp | ZdpClusterId::MgmtRtgRsp) | Err(_) => {} + Ok(ZdpClusterId::NodeDescRsp | ZdpClusterId::MgmtLqiRsp | ZdpClusterId::MgmtRtgRsp) + | Err(_) => {} + } + } + + /// Zigbee spec 2.4.4.1.2: answer a Node_Desc_req addressed to this node. + /// Zigbee 3.0 joiners use the advertised stack revision to decide whether + /// the Trust Center supports the Request-Key update procedure. + fn handle_node_desc_req(&self, nwk_frame: &NwkFrame, aps_frame: &ApsDataFrame) { + if nwk_frame.nwk_header.destination != self.state.network_address { + return; + } + + let source = nwk_frame.nwk_header.source; + let (tsn, request) = match NodeDescReq::deserialize(&aps_frame.asdu) { + Ok(parsed) => parsed, + Err(err) => { + tracing::warn!("Malformed node descriptor request from {source:?}: {err}"); + return; + } + }; + if request.nwk_addr_of_interest != self.state.network_address { + return; + } + + let response = NodeDescRsp { + status: ZdpStatus::Success, + nwk_addr_of_interest: self.state.network_address, + node_descriptor: NodeDescriptor::coordinator(), + }; + + tracing::info!( + "Answering node descriptor request from {source:?} with stack compliance revision {STACK_COMPLIANCE_REVISION}" + ); + if let Err(err) = self.send_zdp_command(source, ApsDeliveryMode::Unicast, tsn, &response) { + tracing::warn!("Failed to send a node descriptor response to {source:?}: {err}"); } } @@ -225,6 +266,13 @@ impl ZigbeeStack { fn handle_parent_annce(&self, nwk_frame: &NwkFrame, aps_frame: &ApsDataFrame) { let source = nwk_frame.nwk_header.source; + if !should_process_parent_annce(source, self.state.network_address) { + tracing::debug!( + "Ignoring looped-back parent announcement from our own network address" + ); + return; + } + let (tsn, annce) = match ParentAnnce::deserialize(&aps_frame.asdu) { Ok(parsed) => parsed, Err(err) => { @@ -441,3 +489,15 @@ impl ZigbeeStack { } } } + +#[cfg(test)] +mod tests { + use super::should_process_parent_annce; + use ziggurat_ieee_802154::types::Nwk; + + #[test] + fn coordinator_ignores_its_own_looped_back_parent_announcement() { + assert!(!should_process_parent_annce(Nwk(0x0000), Nwk(0x0000))); + assert!(should_process_parent_annce(Nwk(0x1234), Nwk(0x0000))); + } +} diff --git a/crates/ziggurat-zigbee/src/aps/security.rs b/crates/ziggurat-zigbee/src/aps/security.rs index 2ef2a06..fc1b2b3 100644 --- a/crates/ziggurat-zigbee/src/aps/security.rs +++ b/crates/ziggurat-zigbee/src/aps/security.rs @@ -221,6 +221,14 @@ impl ApsSecurity { self.device_key(eui64).is_some() || self.tclk_seed.is_some() } + /// Whether a fresh unique trust center link key has been issued but not yet + /// proven by the device with a Verify-Key command. + pub fn link_key_update_pending(&self, eui64: Eui64) -> bool { + self.devices + .get(&eui64) + .is_some_and(|entry| entry.pending_key.is_some()) + } + pub fn device_key_count(&self) -> usize { self.devices.values().filter(|e| e.key.is_some()).count() } @@ -238,6 +246,14 @@ impl ApsSecurity { /// `fresh_key` is caller-generated randomness, used only when no TCLK seed is /// configured. pub fn issue_device_key(&mut self, eui64: Eui64, fresh_key: Key) -> Key { + if let Some(pending_key) = self + .devices + .get(&eui64) + .and_then(|entry| entry.pending_key.clone()) + { + return pending_key; + } + let key = self .tclk_seed .as_ref() @@ -519,3 +535,41 @@ impl ApsSecurity { .map(|(frame, _key)| frame) } } + +#[cfg(test)] +mod tests { + use super::*; + + const LOCAL: Eui64 = Eui64([0x10; 8]); + const DEVICE: Eui64 = Eui64([0x20; 8]); + + fn security() -> ApsSecurity { + ApsSecurity::new(Key(*b"ZigBeeAlliance09"), LOCAL, None) + } + + #[test] + fn repeated_link_key_issue_reuses_the_pending_key() { + let mut security = security(); + let first = security.issue_device_key(DEVICE, Key([0x31; 16])); + let retry = security.issue_device_key(DEVICE, Key([0x42; 16])); + + assert_eq!(first, Key([0x31; 16])); + assert_eq!(retry, first); + assert!(security.link_key_update_pending(DEVICE)); + assert!(!security.has_unique_link_key(DEVICE)); + } + + #[test] + fn verified_pending_key_becomes_the_active_unique_key() { + let mut security = security(); + let pending = security.issue_device_key(DEVICE, Key([0x53; 16])); + + assert_eq!( + security.verify_device_key(DEVICE, &verify_key_hash(&pending)), + Some(true) + ); + assert!(!security.link_key_update_pending(DEVICE)); + assert!(security.has_unique_link_key(DEVICE)); + assert_eq!(security.device_link_key(DEVICE), pending); + } +} diff --git a/crates/ziggurat-zigbee/src/zdp.rs b/crates/ziggurat-zigbee/src/zdp.rs index d56ab2b..2b1e891 100644 --- a/crates/ziggurat-zigbee/src/zdp.rs +++ b/crates/ziggurat-zigbee/src/zdp.rs @@ -39,15 +39,81 @@ pub const ZDP_PROFILE_ID: u16 = 0x0000; #[derive(Debug, Eq, PartialEq, TryFromPrimitive, Clone, Copy)] #[repr(u16)] pub enum ZdpClusterId { + NodeDescReq = 0x0002, DeviceAnnce = 0x0013, ParentAnnce = 0x001F, MgmtLqiReq = 0x0031, MgmtRtgReq = 0x0032, + NodeDescRsp = 0x8002, ParentAnnceRsp = 0x801F, MgmtLqiRsp = 0x8031, MgmtRtgRsp = 0x8032, } +/// Zigbee spec 2.4.3.1.2: request the node descriptor for a network address. +#[abstract_bits] +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct NodeDescReq { + pub nwk_addr_of_interest: Nwk, +} + +impl ZdpCommand for NodeDescReq { + const CLUSTER_ID: ZdpClusterId = ZdpClusterId::NodeDescReq; +} + +/// Stack-compliance revision advertised by this implementation. +pub const STACK_COMPLIANCE_REVISION: u8 = 22; + +/// Zigbee spec 2.3.2.3: the coordinator's node descriptor. +#[abstract_bits] +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct NodeDescriptor { + logical_type_and_flags: u8, + aps_flags_and_frequency_band: u8, + mac_capability_flags: u8, + manufacturer_code: u16, + maximum_buffer_size: u8, + maximum_incoming_transfer_size: u16, + server_mask: u16, + maximum_outgoing_transfer_size: u16, + descriptor_capability_field: u8, +} + +impl NodeDescriptor { + /// Describe a 2.4 GHz coordinator that owns the primary Trust Center. + pub const fn coordinator() -> Self { + Self { + // Logical type Coordinator; no complex or user descriptor. + logical_type_and_flags: 0x00, + // APS flags 0; 2.4 GHz frequency band. + aps_flags_and_frequency_band: 0x40, + // Alternate PAN coordinator, FFD, mains powered, receiver on while idle, + // security capable, and able to allocate addresses. + mac_capability_flags: 0x8f, + manufacturer_code: 0x0000, + maximum_buffer_size: 82, + maximum_incoming_transfer_size: 82, + // Primary Trust Center (bit 0); stack compliance revision in bits 9..15. + server_mask: 0x0001 | ((STACK_COMPLIANCE_REVISION as u16) << 9), + maximum_outgoing_transfer_size: 82, + descriptor_capability_field: 0x00, + } + } +} + +/// Zigbee spec 2.4.4.1.2: successful node descriptor response. +#[abstract_bits] +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct NodeDescRsp { + pub status: ZdpStatus, + pub nwk_addr_of_interest: Nwk, + pub node_descriptor: NodeDescriptor, +} + +impl ZdpCommand for NodeDescRsp { + const CLUSTER_ID: ZdpClusterId = ZdpClusterId::NodeDescRsp; +} + /// Zigbee spec Table 2-129 (partial): ZDP response status values. #[abstract_bits(bits = 8)] #[derive(Debug, Eq, PartialEq, Clone, Copy)] @@ -275,3 +341,36 @@ fn deserialize(bytes: &[u8]) -> Result<(u8, T), DeserializeErro Ok((*tsn, command)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn node_descriptor_request_round_trips() { + let request = NodeDescReq { + nwk_addr_of_interest: Nwk(0x0000), + }; + + let encoded = request.serialize(0x27).unwrap(); + assert_eq!(encoded, [0x27, 0x00, 0x00]); + assert_eq!(NodeDescReq::deserialize(&encoded).unwrap(), (0x27, request)); + } + + #[test] + fn coordinator_node_descriptor_advertises_r22_trust_center() { + let response = NodeDescRsp { + status: ZdpStatus::Success, + nwk_addr_of_interest: Nwk(0x0000), + node_descriptor: NodeDescriptor::coordinator(), + }; + + assert_eq!( + response.serialize(0x27).unwrap(), + [ + 0x27, 0x00, 0x00, 0x00, 0x00, 0x40, 0x8f, 0x00, 0x00, 0x52, 0x52, 0x00, 0x01, 0x2c, + 0x52, 0x00, 0x00, + ] + ); + } +}