diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c643157e..1c56d38f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,6 @@ jobs: - name: Lint and static analysis run: prek run --show-diff-on-failure --color=always --all-files + + - name: Host workspace regression tests + run: cargo test --locked --workspace --all-targets diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining.rs b/crates/ziggurat-driver/src/zigbee_stack/joining.rs index e6930522..7429e56a 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/joining.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/joining.rs @@ -23,6 +23,7 @@ use ziggurat_zigbee::aps::security::AppliedKey; use ziggurat_zigbee::nwk::frame::{ BROADCAST_RX_ON_WHEN_IDLE, NwkFrame, NwkPayload, NwkRouteDiscovery, NwkSecurityHeaderKeyId, }; +use ziggurat_zigbee::nwk::security::NewJoinCounterReset; use alloc::vec::Vec; use core::time::Duration; @@ -41,6 +42,9 @@ use super::{ neighbors, }; +#[cfg(all(test, feature = "tokio"))] +mod tests; + impl ZigbeeStack { #[allow(clippy::significant_drop_tightening)] pub fn process_802154_association_request( @@ -97,6 +101,11 @@ impl ZigbeeStack { tracing::info!("Device {source_eui64:?} is joining as {short_address:?}"); self.begin_join(source_eui64); + let counter_reset = self + .core() + .nib + .nwk_security + .new_join_counter_reset(source_eui64); // Joiners retry association requests if they miss our response, so the address // and table entries must be stable across retries @@ -141,7 +150,9 @@ impl ZigbeeStack { .expect("Unable to upgrade self reference"); self.tasklets.push(async move { - stack.run_join(source_eui64, short_address).await; + stack + .run_join(source_eui64, short_address, counter_reset) + .await; }); } @@ -149,7 +160,12 @@ impl ZigbeeStack { /// the association response out of the indirect queue, confirming its short /// address; the network key follows; the join is announced once the key transport /// lands. - async fn run_join(&self, eui64: Eui64, short_address: Nwk) { + async fn run_join( + &self, + eui64: Eui64, + short_address: Nwk, + counter_reset: Option, + ) { let Some(response) = self.queue_association_response( eui64, short_address, @@ -165,13 +181,19 @@ impl ZigbeeStack { return; } - self.deliver_key_and_announce(short_address, eui64, JoinKind::New) + self.deliver_key_and_announce(short_address, eui64, JoinKind::New, counter_reset) .await; } /// Deliver the network key and, once the transport lands, announce the join. The /// shared tail of the association and unsecured-rejoin flows. - async fn deliver_key_and_announce(&self, nwk: Nwk, eui64: Eui64, join_kind: JoinKind) { + async fn deliver_key_and_announce( + &self, + nwk: Nwk, + eui64: Eui64, + join_kind: JoinKind, + counter_reset: Option, + ) { let Some(key_transport) = self.send_network_key(nwk, eui64, join_kind) else { return; }; @@ -191,6 +213,13 @@ impl ZigbeeStack { "Network key transport was extracted by the joining device" ); + if matches!(join_kind, JoinKind::New) + && let Some(reset) = counter_reset + && self.core().nib.nwk_security.complete_new_join(reset) + { + tracing::info!(ieee = ?eui64, "Retired departed device's NWK replay floor after fresh key delivery"); + } + self.announce_join(nwk, eui64); } @@ -1274,7 +1303,7 @@ impl ZigbeeStack { self.tasklets.push(async move { stack - .deliver_key_and_announce(assigned_nwk, source_ieee, JoinKind::Rejoin) + .deliver_key_and_announce(assigned_nwk, source_ieee, JoinKind::Rejoin, None) .await; }); } else { @@ -1324,7 +1353,12 @@ impl ZigbeeStack { /// Zigbee spec 3.6.1.10.3: a device announces that it is leaving the network, or /// asks us to leave (which a coordinator ignores). - pub(super) fn handle_leave(&self, nwk_frame: &NwkFrame, leave_cmd: NwkLeaveCommand) { + pub(super) fn handle_leave( + &self, + nwk_frame: &NwkFrame, + leave_cmd: NwkLeaveCommand, + sender_nwk: Nwk, + ) { let source = nwk_frame.nwk_header.source; if leave_cmd.request { @@ -1349,7 +1383,22 @@ impl ZigbeeStack { ); if let Some(source_ieee) = source_ieee { - self.core().nib.neighbors.remove(source_ieee); + let mut core = self.core(); + core.nib.neighbors.remove(source_ieee); + // Only the leaving peer's own authenticated announcement can prepare + // a fresh counter epoch. A router's relayed leave must never reset + // that router's replay floor, nor authorize a reset for another peer. + if !leave_cmd.rejoin + && sender_nwk == source + && nwk_frame + .aux_header + .as_ref() + .is_some_and(|aux| aux.extended_source == Some(source_ieee)) + { + core.nib + .nwk_security + .note_authenticated_departure(source_ieee); + } } // The address map entry and any negotiated link key are kept around so that the diff --git a/crates/ziggurat-driver/src/zigbee_stack/joining/tests.rs b/crates/ziggurat-driver/src/zigbee_stack/joining/tests.rs new file mode 100644 index 00000000..3d4ff9fa --- /dev/null +++ b/crates/ziggurat-driver/src/zigbee_stack/joining/tests.rs @@ -0,0 +1,374 @@ +use super::*; +use crate::runtime::TokioSpawner; +use crate::zigbee_stack::{DeliveryError, NetworkConfig, Tunables, WELL_KNOWN_LINK_KEY}; +use abstract_bits::{AbstractBits, BitReader}; +use alloc::sync::Arc; +use ziggurat_ieee_802154::Ieee802154DataFrame; +use ziggurat_phy::{ExclusiveRadio, RadioConfig, RadioError, Receiver, TxFrame, TxResult}; +use ziggurat_zigbee::nwk::frame::{NwkAuxHeader, NwkSecurityHeaderControlField}; + +#[derive(Debug)] +struct NoRadio; + +struct EmptyReceiver; +impl Receiver for EmptyReceiver { + async fn recv(&mut self) -> Option { + None + } +} +impl ExclusiveRadio for &NoRadio { + async fn set_channel(&self, _: u8) -> Result<(), RadioError> { + Ok(()) + } + async fn transmit(&self, _: TxFrame) -> Result { + Ok(TxResult::Acked) + } +} +impl RadioPhy for NoRadio { + type Exclusive<'a> = &'a Self; + type RxStream = EmptyReceiver; + type ResetStream = EmptyReceiver; + async fn reset(&self) -> Result<(), RadioError> { + Ok(()) + } + async fn reconfigure(&self, _: &RadioConfig) -> Result<(), RadioError> { + Ok(()) + } + async fn set_frame_pending_table(&self, _: &[Nwk], _: &[Eui64]) -> Result<(), RadioError> { + Ok(()) + } + async fn transmit(&self, _: TxFrame) -> Result { + Ok(TxResult::Acked) + } + async fn energy_detect(&self, _: u8, _: Duration) -> Result { + Ok(-80) + } + async fn lock(&self) -> Self::Exclusive<'_> { + self + } + fn subscribe_rx(&self) -> Self::RxStream { + EmptyReceiver + } + fn subscribe_reset(&self) -> Self::ResetStream { + EmptyReceiver + } +} + +fn peer() -> Eui64 { + Eui64::from_hex("00124b0000000001") +} +fn other() -> Eui64 { + Eui64::from_hex("00124b0000000002") +} +fn stack() -> Arc> { + ZigbeeStack::new( + Arc::new(NoRadio), + NetworkConfig { + role: NwkDeviceType::Coordinator, + channel: 15, + update_id: 0, + pan_id: ziggurat_ieee_802154::types::PanId(0x1234), + extended_pan_id: Eui64::from_hex("00124b0000000000"), + network_address: Nwk(0), + ieee_address: Eui64::from_hex("00124b0000000000"), + network_key: Key::from_hex("00112233445566778899aabbccddeeff"), + network_key_seq_number: 0, + network_key_tx_counter: 500, + tc_link_key: WELL_KNOWN_LINK_KEY, + tclk_seed: None, + tx_power: 0, + source_routing: false, + }, + Tunables::default(), + TokioSpawner::default(), + ) +} + +fn receive_leave( + stack: &ZigbeeStack, + rejoin: bool, + request: bool, + relayer: Eui64, + valid_mic: bool, +) -> bool { + receive_secured_command( + stack, + NwkCommand::Leave(NwkLeaveCommand { + request, + rejoin, + remove_children: false, + }), + relayer, + 58, + valid_mic, + ) +} + +fn receive_secured_command( + stack: &ZigbeeStack, + command: NwkCommand, + relayer: Eui64, + counter: u32, + valid_mic: bool, +) -> bool { + receive_secured_command_from(stack, command, relayer, counter, valid_mic, Nwk(0x4831)) +} + +fn receive_secured_command_from( + stack: &ZigbeeStack, + command: NwkCommand, + relayer: Eui64, + counter: u32, + valid_mic: bool, + mac_sender: Nwk, +) -> bool { + let mut frame = stack.nwk_command_frame(Nwk(0), command); + frame.nwk_header.source = Nwk(0x4831); + frame.nwk_header.source_ieee = Some(peer()); + frame.aux_header = Some(NwkAuxHeader { + security_control: NwkSecurityHeaderControlField::read_abstract_bits(&mut BitReader::from( + &[0x28][..], + )) + .unwrap(), + frame_counter: counter, + extended_source: Some(relayer), + key_sequence_number: 0, + }); + let key = stack.core().nib.nwk_security.network_key(); + let mut bytes = frame.encrypt(&key).to_bytes(); + if !valid_mic { + *bytes.last_mut().unwrap() ^= 1; + } + let mut header = stack + .build_802154_association_response::( + peer(), + Nwk(0x4831), + Ieee802154AssociationStatus::AssociationSuccessful, + ) + .header() + .clone(); + header.frame_control.frame_type = Ieee802154FrameType::Data; + header.frame_control.src_addr_mode = Ieee802154AddressingMode::Short; + header.frame_control.dest_addr_mode = Ieee802154AddressingMode::Short; + header.src_address = Some(Ieee802154Address::Nwk(mac_sender)); + header.dest_address = Some(Ieee802154Address::Nwk(Nwk(0))); + stack + .process_802154_data_frame( + &Ieee802154DataFrame { + header, + payload: FrameBytes::from_slice(&bytes).unwrap(), + fcs: 0, + }, + 100, + -50, + ) + .is_some() +} + +fn associate(stack: &ZigbeeStack) { + let request = + Ieee802154AssociationRequestCommand::read_abstract_bits(&mut BitReader::from(&[0x80][..])) + .unwrap(); + let mut header = stack + .build_802154_association_response::( + peer(), + Nwk(0x4831), + Ieee802154AssociationStatus::AssociationSuccessful, + ) + .header() + .clone(); + header.src_address = Some(Ieee802154Address::Eui64(peer())); + header.dest_address = Some(Ieee802154Address::Eui64(stack.state.ieee_address)); + stack.process_802154_association_request( + &Ieee802154CommandFrame { + header, + command_payload: Ieee802154CommandPayload::AssociationRequest(request.clone()), + fcs: 0, + }, + &request, + ); +} + +async fn extract(stack: &ZigbeeStack, result: Result<(), DeliveryError>) { + for _ in 0..100 { + let nwk = stack.core().nib.address_map.nwk_for(peer()); + let delivery = stack + .core() + .mac + .indirect_queue + .extract(Some(peer()), nwk, stack.core_now()) + .delivery; + if let Some(delivery) = delivery { + stack.resolve_outcome(delivery.transaction.completion, result); + tokio::task::yield_now().await; + return; + } + tokio::task::yield_now().await; + } + panic!("join did not queue its expected indirect frame"); +} + +fn replay_rejected(stack: &ZigbeeStack, sender: Eui64, counter: u32) -> bool { + stack + .core() + .nib + .nwk_security + .inbound_network_key(sender, 0, counter) + .is_none() +} + +async fn complete_association(stack: &Arc>) { + associate(stack); + extract(stack, Ok(())).await; // MAC association response + extract(stack, Ok(())).await; // network key +} + +#[tokio::test] +async fn authenticated_departure_and_fresh_key_delivery_admit_reset_counter_once() { + let stack = stack(); + stack + .core() + .nib + .nwk_security + .note_inbound_frame_counter(peer(), 57); + stack + .core() + .nib + .nwk_security + .note_inbound_frame_counter(other(), 900); + assert!(receive_leave(&stack, false, false, peer(), true)); + assert!(replay_rejected(&stack, peer(), 0)); // leave alone cannot admit a replay + stack.permit_joins(180, true); + let running = stack.clone(); + let runner = tokio::spawn(async move { running.tasklet_task().await }); + associate(&stack); + extract(&stack, Ok(())).await; + assert!(replay_rejected(&stack, peer(), 0)); // association response alone is insufficient + extract(&stack, Ok(())).await; + assert!( + !replay_rejected(&stack, peer(), 0), + "reset sensor must be able to answer Active_EP" + ); + // Exercise the encrypted MAC -> NWK receive path, not just a table lookup. + let command = NwkCommand::Unparsed(FrameBytes::from_slice(&[0xfe]).unwrap()); + assert!(receive_secured_command( + &stack, + command.clone(), + peer(), + 0, + true + )); + assert!(!receive_secured_command(&stack, command, peer(), 0, true)); + assert!(replay_rejected(&stack, other(), 900)); + stack + .core() + .nib + .nwk_security + .note_inbound_frame_counter(peer(), 6); + complete_association(&stack).await; // duplicate association/key extraction + assert!(replay_rejected(&stack, peer(), 6)); + assert!(!replay_rejected(&stack, peer(), 7)); + runner.abort(); +} + +#[tokio::test] +async fn failed_key_delivery_keeps_replay_floor_until_a_successful_retry() { + let stack = stack(); + stack + .core() + .nib + .nwk_security + .note_inbound_frame_counter(peer(), 57); + assert!(receive_leave(&stack, false, false, peer(), true)); + stack.permit_joins(180, true); + let running = stack.clone(); + let runner = tokio::spawn(async move { running.tasklet_task().await }); + associate(&stack); + extract(&stack, Ok(())).await; + extract(&stack, Err(DeliveryError::Cancelled)).await; + assert!(replay_rejected(&stack, peer(), 0)); + complete_association(&stack).await; + assert!(!replay_rejected(&stack, peer(), 0)); + runner.abort(); +} + +#[tokio::test] +async fn rejoin_requested_leave_and_invalid_or_relayed_leave_keep_replay_floor() { + for (rejoin, request, relayer, valid_mic) in [ + (true, false, peer(), true), + (false, true, peer(), true), + (false, false, other(), true), + (false, false, peer(), false), + ] { + let stack = stack(); + stack + .core() + .nib + .nwk_security + .note_inbound_frame_counter(peer(), 57); + assert_eq!( + receive_leave(&stack, rejoin, request, relayer, valid_mic), + valid_mic + ); + stack.permit_joins(180, true); + let running = stack.clone(); + let runner = tokio::spawn(async move { running.tasklet_task().await }); + complete_association(&stack).await; + assert!(replay_rejected(&stack, peer(), 0)); + runner.abort(); + } +} + +#[tokio::test] +async fn closed_admission_and_ordinary_association_keep_replay_floor() { + let stack = stack(); + stack + .core() + .nib + .nwk_security + .note_inbound_frame_counter(peer(), 57); + assert!(receive_leave(&stack, false, false, peer(), true)); + associate(&stack); // denied: no permit-join window + assert!(replay_rejected(&stack, peer(), 0)); + assert!(stack.core().nib.address_map.nwk_for(peer()).is_some()); // leave retained old mapping + let ordinary = self::stack(); + ordinary + .core() + .nib + .nwk_security + .note_inbound_frame_counter(peer(), 57); + ordinary.permit_joins(180, true); + let running = ordinary.clone(); + let runner = tokio::spawn(async move { running.tasklet_task().await }); + complete_association(&ordinary).await; + assert!(replay_rejected(&ordinary, peer(), 0)); + runner.abort(); +} + +#[tokio::test] +async fn relayed_mac_sender_cannot_retire_the_originators_replay_floor() { + let stack = stack(); + stack + .core() + .nib + .nwk_security + .note_inbound_frame_counter(peer(), 57); + assert!(receive_secured_command_from( + &stack, + NwkCommand::Leave(NwkLeaveCommand { + request: false, + rejoin: false, + remove_children: false, + }), + peer(), + 58, + true, + Nwk(0x9902), + )); + stack.permit_joins(180, true); + let running = stack.clone(); + let runner = tokio::spawn(async move { running.tasklet_task().await }); + complete_association(&stack).await; + assert!(replay_rejected(&stack, peer(), 0)); + runner.abort(); +} diff --git a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs index 89bfc135..3ca53323 100644 --- a/crates/ziggurat-driver/src/zigbee_stack/nwk.rs +++ b/crates/ziggurat-driver/src/zigbee_stack/nwk.rs @@ -448,7 +448,7 @@ impl ZigbeeStack { NwkCommand::RejoinRequest(cmd) => { self.handle_rejoin_request(nwk_frame, cmd.clone(), true); } - NwkCommand::Leave(cmd) => self.handle_leave(nwk_frame, cmd.clone()), + NwkCommand::Leave(cmd) => self.handle_leave(nwk_frame, cmd.clone(), sender_nwk), NwkCommand::NetworkStatus(cmd) => { self.handle_network_status(nwk_frame, cmd.clone()) } diff --git a/crates/ziggurat-spinel/src/lib.rs b/crates/ziggurat-spinel/src/lib.rs index 246598db..8b9c0499 100644 --- a/crates/ziggurat-spinel/src/lib.rs +++ b/crates/ziggurat-spinel/src/lib.rs @@ -489,7 +489,7 @@ pub struct SpinelHeader { } impl SpinelHeader { - pub fn from_bytes(bytes: &[u8]) -> Result { + pub const fn from_bytes(bytes: &[u8]) -> Result { if bytes.is_empty() { return Err(SpinelFrameParsingError::PayloadTooShort { expected: 1, diff --git a/crates/ziggurat-tools/src/bin/pcap_rekey.rs b/crates/ziggurat-tools/src/bin/pcap_rekey.rs index 2cf284bd..d5633ace 100644 --- a/crates/ziggurat-tools/src/bin/pcap_rekey.rs +++ b/crates/ziggurat-tools/src/bin/pcap_rekey.rs @@ -267,7 +267,7 @@ fn rekey_packet( /// The length of the IEEE 802.15.4 TAP pseudo-header (its little-endian length field), /// or `None` if the buffer is too short to contain one. -fn tap_header_len(data: &[u8]) -> Option { +const fn tap_header_len(data: &[u8]) -> Option { if data.len() < 4 { return None; } diff --git a/crates/ziggurat-zigbee/src/aps/frame.rs b/crates/ziggurat-zigbee/src/aps/frame.rs index ba2e0b8d..5eb0d04d 100644 --- a/crates/ziggurat-zigbee/src/aps/frame.rs +++ b/crates/ziggurat-zigbee/src/aps/frame.rs @@ -100,33 +100,26 @@ impl ApsAckFrame { )); } - let destination_endpoint; - let cluster_id; - let profile_id; - let source_endpoint; - let counter; - // An ack-format ack (for an APS command) carries only frame control and a // counter; a data ack additionally carries the addressing fields - if frame_control.ack_format { - if remaining.is_empty() { - return Err(ParseError::UnexpectedEnd { ty: "ApsAckFrame" }); - } - destination_endpoint = None; - cluster_id = None; - profile_id = None; - source_endpoint = None; - counter = u8::from_le_bytes([remaining[0]]); - } else { - if remaining.len() < 7 { - return Err(ParseError::UnexpectedEnd { ty: "ApsAckFrame" }); - } - destination_endpoint = Some(u8::from_le_bytes([remaining[0]])); - cluster_id = Some(u16::from_le_bytes([remaining[1], remaining[2]])); - profile_id = Some(u16::from_le_bytes([remaining[3], remaining[4]])); - source_endpoint = Some(u8::from_le_bytes([remaining[5]])); - counter = u8::from_le_bytes([remaining[6]]); - } + let (destination_endpoint, cluster_id, profile_id, source_endpoint, counter) = + if frame_control.ack_format { + if remaining.is_empty() { + return Err(ParseError::UnexpectedEnd { ty: "ApsAckFrame" }); + } + (None, None, None, None, remaining[0]) + } else { + if remaining.len() < 7 { + return Err(ParseError::UnexpectedEnd { ty: "ApsAckFrame" }); + } + ( + Some(remaining[0]), + Some(u16::from_le_bytes([remaining[1], remaining[2]])), + Some(u16::from_le_bytes([remaining[3], remaining[4]])), + Some(remaining[5]), + remaining[6], + ) + }; Ok(Self { frame_control, diff --git a/crates/ziggurat-zigbee/src/crypto.rs b/crates/ziggurat-zigbee/src/crypto.rs index 917829d5..34225a2a 100644 --- a/crates/ziggurat-zigbee/src/crypto.rs +++ b/crates/ziggurat-zigbee/src/crypto.rs @@ -134,10 +134,9 @@ pub fn aes_mmo_hash(data: &[u8]) -> [u8; 16] { let backend = backend(); let mut digest = [0u8; 16]; - for chunk in padded.chunks_exact(16) { + for &block in padded.as_chunks::<16>().0 { // MMO: encrypt the message block under the running digest as the key, then XOR the // ciphertext with the plaintext block. - let block: [u8; 16] = chunk.try_into().expect("16-byte chunk is always valid"); let mut encrypted = block; backend.aes128_encrypt_block(&digest, &mut encrypted); diff --git a/crates/ziggurat-zigbee/src/nwk/frame.rs b/crates/ziggurat-zigbee/src/nwk/frame.rs index 38445734..9834fbdd 100644 --- a/crates/ziggurat-zigbee/src/nwk/frame.rs +++ b/crates/ziggurat-zigbee/src/nwk/frame.rs @@ -433,18 +433,11 @@ impl EncryptedNwkFrame { aux_header } - #[allow(clippy::unnecessary_unwrap)] pub fn get_nonce(&self, aux_header: &NwkAuxHeader) -> [u8; 13] { - let source; - - if aux_header.extended_source.is_some() { - source = aux_header.extended_source.unwrap(); - } else if self.nwk_header.source_ieee.is_some() { - source = self.nwk_header.source_ieee.unwrap(); - } else { - // XXX: this can't happen - panic!("Cannot compute nonce with no source address"); - } + let source = aux_header + .extended_source + .or(self.nwk_header.source_ieee) + .expect("Cannot compute nonce with no source address"); let mut nonce = [0; 13]; nonce[..8].copy_from_slice(&source.to_bytes()); @@ -538,18 +531,11 @@ impl NwkFrame { aux_header } - #[allow(clippy::unnecessary_unwrap)] pub fn get_nonce(&self, aux_header: &NwkAuxHeader) -> [u8; 13] { - let source; - - if aux_header.extended_source.is_some() { - source = aux_header.extended_source.unwrap(); - } else if self.nwk_header.source_ieee.is_some() { - source = self.nwk_header.source_ieee.unwrap(); - } else { - // XXX: this can't happen - panic!("Cannot compute nonce with no source address"); - } + let source = aux_header + .extended_source + .or(self.nwk_header.source_ieee) + .expect("Cannot compute nonce with no source address"); let mut nonce = [0; 13]; nonce[..8].copy_from_slice(&source.to_bytes()); diff --git a/crates/ziggurat-zigbee/src/nwk/security.rs b/crates/ziggurat-zigbee/src/nwk/security.rs index e7d82aa2..59232ed8 100644 --- a/crates/ziggurat-zigbee/src/nwk/security.rs +++ b/crates/ziggurat-zigbee/src/nwk/security.rs @@ -28,6 +28,14 @@ pub struct CounterAdvance { pub should_persist: bool, } +/// One completed, authenticated departure awaiting a new association. Captured +/// at admission so an older join task cannot reset a later security epoch. +#[derive(Debug, Clone, Copy)] +pub struct NewJoinCounterReset { + sender: Eui64, + generation: u64, +} + /// The NWK security material: the network key, its outgoing frame counter, and the /// per-relayer incoming frame counters used for replay protection (spec 4.3.1.2). #[derive(Debug)] @@ -43,6 +51,8 @@ pub struct NwkSecurity { all_fresh: bool, /// Every this many outgoing frames, a counter advance asks to be persisted persist_interval: u32, + departed: FlatMap, + departure_generation: u64, } impl NwkSecurity { @@ -70,6 +80,8 @@ impl NwkSecurity { active_key_seq_number: key_seq_number, all_fresh: false, persist_interval, + departed: FlatMap::new(), + departure_generation: 0, } } @@ -157,4 +169,77 @@ impl NwkSecurity { .incoming_frame_counter_set .insert(sender, frame_counter) } + + /// Called only for a successfully authenticated, direct leave announcement + /// with rejoin=false. Keep the old replay floor until a newly admitted device + /// actually extracts its network key; departure alone does not admit traffic. + pub fn note_authenticated_departure(&mut self, sender: Eui64) { + self.departure_generation = self.departure_generation.checked_add(1).unwrap(); + self.departed.insert(sender, self.departure_generation); + } + + pub fn new_join_counter_reset(&self, sender: Eui64) -> Option { + self.departed + .get(&sender) + .map(|&generation| NewJoinCounterReset { sender, generation }) + } + + /// Complete the peer's fresh association exactly once. Ordinary rejoins, + /// duplicate associations and stale completions keep their replay floor. + pub fn complete_new_join(&mut self, reset: NewJoinCounterReset) -> bool { + if self.departed.get(&reset.sender) != Some(&reset.generation) { + return false; + } + self.departed.remove(&reset.sender); + self.primary + .incoming_frame_counter_set + .remove(&reset.sender); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn security() -> NwkSecurity { + NwkSecurity::new( + Key::from_hex("00112233445566778899aabbccddeeff"), + 3, + 500, + 100, + ) + } + + #[test] + fn stale_join_completion_cannot_consume_a_later_departure() { + let sender = Eui64::from_hex("00124b0000000001"); + let mut security = security(); + security.note_inbound_frame_counter(sender, 58); + security.note_authenticated_departure(sender); + let old = security.new_join_counter_reset(sender).unwrap(); + security.note_authenticated_departure(sender); + assert!(!security.complete_new_join(old)); + assert!(security.inbound_network_key(sender, 3, 0).is_none()); + let current = security.new_join_counter_reset(sender).unwrap(); + assert!(security.complete_new_join(current)); + security.note_inbound_frame_counter(sender, 6); + assert!(!security.complete_new_join(current)); + assert!(security.inbound_network_key(sender, 3, 6).is_none()); + } + + #[test] + fn fresh_counter_epoch_preserves_key_sequence_exhaustion_and_outgoing_counter() { + let sender = Eui64::from_hex("00124b0000000001"); + let mut security = security(); + assert!(security.new_join_counter_reset(sender).is_none()); + security.note_inbound_frame_counter(sender, 58); + security.note_authenticated_departure(sender); + let reset = security.new_join_counter_reset(sender).unwrap(); + assert!(security.complete_new_join(reset)); + assert!(security.inbound_network_key(sender, 3, 0).is_some()); + assert!(security.inbound_network_key(sender, 2, 0).is_none()); + assert!(security.inbound_network_key(sender, 3, u32::MAX).is_none()); + assert_eq!(security.next_outgoing_frame_counter().value, 501); + } }