From 09770d207e14abcb90b2c7e1024e9d9d3dd0123c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sten=C3=A5?= Date: Tue, 1 Sep 2026 19:45:08 +0200 Subject: [PATCH 1/3] Initiate trust center link key updates after join --- .../src/zigbee_stack/joining.rs | 66 +++++++++++++++++++ .../ziggurat-driver/src/zigbee_stack/zdp.rs | 4 ++ crates/ziggurat-zigbee/src/aps/security.rs | 54 +++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index 0446456..d6ebb3a 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -713,6 +713,72 @@ impl ZigbeeStack { }) } + /// Start the Zigbee 3.0 trust-center link-key update once a freshly joined + /// device announces itself. Most devices request this update themselves, but + /// some BDB implementations wait for the trust center to initiate it and leave + /// the network when their key-establishment timeout expires. + pub(super) fn initiate_trust_center_link_key_update( + &self, + destination: Nwk, + device_ieee: Eui64, + ) { + let (current_link_key, new_link_key) = { + let mut core = self.core(); + + if core.aib.aps_security.has_unique_link_key(device_ieee) + || core.aib.aps_security.link_key_update_pending(device_ieee) + { + return; + } + + let current_link_key = core.aib.aps_security.device_link_key(device_ieee); + let new_link_key = core + .aib + .aps_security + .issue_device_key(device_ieee, Key(crate::rng::random_array())); + + (current_link_key, new_link_key) + }; + + tracing::info!("Initiating trust center link key update for {device_ieee:?}"); + + let transport_key_command = ApsCommandFrame { + frame_control: ApsFrameControl { + frame_type: ApsFrameType::Command, + delivery_mode: ApsDeliveryMode::Unicast, + reserved1: 0b0, + security: true, + ack_request: false, + extended_header: false, + }, + counter: self.next_aps_counter(), + command: ApsCommandFrameCommand::TransportKey(ApsTransportKeyCommandFrame { + standard_key_type: ApsStandardKeyType::TrustCenterLinkKey, + key_descriptor: ApsTransportKeyDescriptor::TrustCenterLinkKey( + ApsTrustCenterLinkKeyDescriptor { + key: new_link_key, + destination_address: device_ieee, + source_address: self.state.ieee_address, + }, + ), + }), + }; + + let encrypted_command = self.core().aib.aps_security.encrypt_command_with_link_key( + ¤t_link_key, + NwkSecurityHeaderKeyId::KeyLoadKey, + &transport_key_command, + ); + + self.send_secured_aps_payload(destination, encrypted_command.to_bytes()) + .map_err(|err| { + tracing::warn!( + "Failed to initiate trust center link key update for {device_ieee:?}: {err}" + ); + }) + .ok(); + } + /// Zigbee spec 4.7.3.8: a device requests a unique trust center link key to replace /// the well-known key it joined with. fn handle_request_key( diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index c3e4955..5f0c1c1 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs @@ -216,6 +216,10 @@ impl ZigbeeStack { .nib .neighbors .update_network_address(annce.ieee_addr, annce.nwk_addr); + + if self.state.role == NwkDeviceType::Coordinator { + self.initiate_trust_center_link_key_update(annce.nwk_addr, annce.ieee_addr); + } } /// Spec 2.4.3.1.12: a router announces the end devices it believes are its 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); + } +} From db055cd1c351d925f5c0d10f65ff55d9d5d6b52f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sten=C3=A5?= Date: Wed, 2 Sep 2026 13:12:19 +0200 Subject: [PATCH 2/3] Respond to coordinator node descriptor requests --- .../src/zigbee_stack/joining.rs | 66 ------------- .../ziggurat-driver/src/zigbee_stack/zdp.rs | 47 +++++++-- crates/ziggurat-zigbee/src/zdp.rs | 99 +++++++++++++++++++ 3 files changed, 139 insertions(+), 73 deletions(-) diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index d6ebb3a..0446456 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -713,72 +713,6 @@ impl ZigbeeStack { }) } - /// Start the Zigbee 3.0 trust-center link-key update once a freshly joined - /// device announces itself. Most devices request this update themselves, but - /// some BDB implementations wait for the trust center to initiate it and leave - /// the network when their key-establishment timeout expires. - pub(super) fn initiate_trust_center_link_key_update( - &self, - destination: Nwk, - device_ieee: Eui64, - ) { - let (current_link_key, new_link_key) = { - let mut core = self.core(); - - if core.aib.aps_security.has_unique_link_key(device_ieee) - || core.aib.aps_security.link_key_update_pending(device_ieee) - { - return; - } - - let current_link_key = core.aib.aps_security.device_link_key(device_ieee); - let new_link_key = core - .aib - .aps_security - .issue_device_key(device_ieee, Key(crate::rng::random_array())); - - (current_link_key, new_link_key) - }; - - tracing::info!("Initiating trust center link key update for {device_ieee:?}"); - - let transport_key_command = ApsCommandFrame { - frame_control: ApsFrameControl { - frame_type: ApsFrameType::Command, - delivery_mode: ApsDeliveryMode::Unicast, - reserved1: 0b0, - security: true, - ack_request: false, - extended_header: false, - }, - counter: self.next_aps_counter(), - command: ApsCommandFrameCommand::TransportKey(ApsTransportKeyCommandFrame { - standard_key_type: ApsStandardKeyType::TrustCenterLinkKey, - key_descriptor: ApsTransportKeyDescriptor::TrustCenterLinkKey( - ApsTrustCenterLinkKeyDescriptor { - key: new_link_key, - destination_address: device_ieee, - source_address: self.state.ieee_address, - }, - ), - }), - }; - - let encrypted_command = self.core().aib.aps_security.encrypt_command_with_link_key( - ¤t_link_key, - NwkSecurityHeaderKeyId::KeyLoadKey, - &transport_key_command, - ); - - self.send_secured_aps_payload(destination, encrypted_command.to_bytes()) - .map_err(|err| { - tracing::warn!( - "Failed to initiate trust center link key update for {device_ieee:?}: {err}" - ); - }) - .ok(); - } - /// Zigbee spec 4.7.3.8: a device requests a unique trust center link key to replace /// the well-known key it joined with. fn handle_request_key( diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index 5f0c1c1..c8981c8 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, }; @@ -41,13 +42,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}"); } } @@ -216,10 +253,6 @@ impl ZigbeeStack { .nib .neighbors .update_network_address(annce.ieee_addr, annce.nwk_addr); - - if self.state.role == NwkDeviceType::Coordinator { - self.initiate_trust_center_link_key_update(annce.nwk_addr, annce.ieee_addr); - } } /// Spec 2.4.3.1.12: a router announces the end devices it believes are its 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, + ] + ); + } +} From b209bf6621df036598ca5452c630d2ac2bf5b458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sten=C3=A5?= Date: Wed, 2 Sep 2026 14:37:59 +0200 Subject: [PATCH 3/3] fix: ignore looped-back parent announcements --- .../ziggurat-driver/src/zigbee_stack/zdp.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs index c8981c8..2e519d4 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/zdp.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/zdp.rs @@ -26,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; @@ -262,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) => { @@ -478,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))); + } +}