From 4096308d09ed3f756e4b4700afa0760ecca71f7e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:31:41 -0400 Subject: [PATCH 1/2] CLI tool to change the network key of PCAPs --- Cargo.lock | 133 ++++-- crates/ziggurat-tools/Cargo.toml | 21 + crates/ziggurat-tools/src/bin/pcap_rekey.rs | 425 ++++++++++++++++++++ 3 files changed, 549 insertions(+), 30 deletions(-) create mode 100644 crates/ziggurat-tools/Cargo.toml create mode 100644 crates/ziggurat-tools/src/bin/pcap_rekey.rs diff --git a/Cargo.lock b/Cargo.lock index f12209c..f90284e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ dependencies = [ "abstract-bits-derive", "arbitrary-int 1.3.0", "bitvec", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -21,7 +21,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -191,6 +191,15 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder_slice" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b294e30387378958e8bf8f4242131b930ea615ff81e8cac2440cea0a6013190" +dependencies = [ + "byteorder", +] + [[package]] name = "bytes" version = "1.11.1" @@ -295,7 +304,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -427,7 +436,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -438,7 +447,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -447,6 +456,17 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "derive-into-owned" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d94d81e3819a7b06a8638f448bc6339371ca9b6076a99d4a43eece3c4c923" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "digest" version = "0.10.7" @@ -475,7 +495,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -500,7 +520,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -616,7 +636,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -705,7 +725,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1131,7 +1151,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1169,6 +1189,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pcap-file" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc1f139757b058f9f37b76c48501799d12c9aa0aa4c0d4c980b062ee925d1b2" +dependencies = [ + "byteorder_slice", + "derive-into-owned", + "thiserror 1.0.69", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1191,7 +1222,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1222,7 +1253,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1383,7 +1414,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1489,6 +1520,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -1506,13 +1548,33 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1523,7 +1585,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1558,7 +1620,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1636,7 +1698,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1692,7 +1754,7 @@ dependencies = [ "log", "rand 0.9.4", "sha1", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1707,7 +1769,7 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" dependencies = [ - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1802,7 +1864,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1892,7 +1954,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1903,7 +1965,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2057,7 +2119,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2073,7 +2135,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2141,7 +2203,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2157,7 +2219,7 @@ dependencies = [ "parking_lot", "rand 0.10.1", "spin", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "ziggurat-ieee-802154", @@ -2175,14 +2237,14 @@ dependencies = [ "hex", "hex-literal", "num_enum", - "thiserror", + "thiserror 2.0.18", ] [[package]] name = "ziggurat-phy" version = "0.1.0" dependencies = [ - "thiserror", + "thiserror 2.0.18", "ziggurat-ieee-802154", ] @@ -2238,13 +2300,24 @@ dependencies = [ "hex-literal", "num_enum", "rand 0.10.1", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-serial", "tracing", "ziggurat-ieee-802154", ] +[[package]] +name = "ziggurat-tools" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "pcap-file", + "ziggurat-ieee-802154", + "ziggurat-zigbee", +] + [[package]] name = "ziggurat-zigbee" version = "0.1.0" @@ -2259,7 +2332,7 @@ dependencies = [ "num_enum", "once_cell", "subtle", - "thiserror", + "thiserror 2.0.18", "tracing", "ziggurat-ieee-802154", ] diff --git a/crates/ziggurat-tools/Cargo.toml b/crates/ziggurat-tools/Cargo.toml new file mode 100644 index 0000000..23cd5eb --- /dev/null +++ b/crates/ziggurat-tools/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ziggurat-tools" +version.workspace = true +description = "Standalone CLI tools" +rust-version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[[bin]] +name = "pcap-rekey" +path = "src/bin/pcap_rekey.rs" + +[dependencies] +ziggurat-ieee-802154.workspace = true +ziggurat-zigbee.workspace = true + +anyhow = "1" +clap = { version = "4", features = ["derive"] } +pcap-file = "2" diff --git a/crates/ziggurat-tools/src/bin/pcap_rekey.rs b/crates/ziggurat-tools/src/bin/pcap_rekey.rs new file mode 100644 index 0000000..aeab303 --- /dev/null +++ b/crates/ziggurat-tools/src/bin/pcap_rekey.rs @@ -0,0 +1,425 @@ +//! Rekey a Zigbee capture: rewrite every NWK frame secured with one network key so it is +//! secured with another, leaving frames from other networks untouched. Optionally prepend +//! a synthetic APS Transport-Key command so Wireshark learns the new key automatically. + +use std::borrow::Cow; +use std::fs::File; +use std::io::{BufReader, BufWriter}; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::Parser; +use pcap_file::pcapng::blocks::enhanced_packet::EnhancedPacketBlock; +use pcap_file::pcapng::{Block, PcapNgReader, PcapNgWriter}; + +use ziggurat_ieee_802154::types::{Eui64, Key, Nwk, PanId}; +use ziggurat_ieee_802154::{ + FrameBytes, Ieee802154Address, Ieee802154AddressingMode, Ieee802154DataFrame, Ieee802154Frame, + Ieee802154FrameControl, Ieee802154FrameHeader, Ieee802154FrameType, Ieee802154FrameVersion, +}; +use ziggurat_zigbee::aps::frame::{ + ApsAuxHeader, ApsCommandFrame, ApsCommandFrameCommand, ApsDeliveryMode, ApsFrameControl, + ApsFrameType, ApsNetworkKeyDescriptor, ApsStandardKeyType, ApsTransportKeyCommandFrame, + ApsTransportKeyDescriptor, +}; +use ziggurat_zigbee::crypto::key_transport_key; +use ziggurat_zigbee::nwk::frame::{ + EncryptedNwkFrame, NwkFrameControl, NwkFrameType, NwkHeader, NwkRouteDiscovery, + NwkSecurityHeaderControlField, NwkSecurityHeaderKeyId, NwkSecurityLevel, +}; + +/// The well-known default global Trust Center link key ("ZigBeeAlliance09"), which +/// Wireshark ships preconfigured. The synthetic transport-key command is protected with +/// the key derived from it so Wireshark can decrypt it out of the box. +const ZIGBEE_ALLIANCE_09: Key = Key::from_string(b"ZigBeeAlliance09"); + +#[derive(Parser)] +#[command( + about = "Change the network key of a Zigbee pcap/pcapng capture", + long_about = "Decrypts every NWK-secured frame protected by the current network key and \ +re-encrypts it under the target key. Frames belonging to other networks (whose MIC does not \ +verify) are left untouched, and the transform round-trips when the two keys are equal." +)] +struct Args { + /// Input capture (.pcapng). + input: PathBuf, + + /// Output capture (.pcapng). + output: PathBuf, + + /// The current network key protecting the capture. + #[arg(long, value_parser = parse_key)] + old_key: Key, + + /// The network key to re-encrypt the capture with. + #[arg(long, value_parser = parse_key)] + new_key: Key, + + /// Do not prepend a synthetic Transport-Key command for the new key. + #[arg(long)] + no_transport_key: bool, + + /// The key sequence number the new key is announced with in the Transport-Key command. + #[arg(long, default_value_t = 0)] + key_sequence_number: u8, +} + +fn parse_key(text: &str) -> Result { + Key::try_from_hex(text).map_err(|e| e.to_string()) +} + +/// The 802.15.4 link-layer type of a capture's packets, which decides whether each +/// packet is prefixed by an IEEE 802.15.4 TAP pseudo-header. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LinkType { + /// LINKTYPE_IEEE802_15_4_TAP (283): a TAP pseudo-header precedes the PHY payload. + Tap, + /// LINKTYPE_IEEE802_15_4_WITHFCS (195) or _NOFCS: the packet is the raw PHY payload. + Raw, +} + +impl LinkType { + const fn from_pcap_linktype(linktype: u16) -> Option { + match linktype { + 283 => Some(Self::Tap), + 195 | 230 => Some(Self::Raw), + _ => None, + } + } +} + +fn main() -> Result<()> { + let args = Args::parse(); + + let reader = PcapNgReader::new(BufReader::new( + File::open(&args.input).with_context(|| format!("opening {}", args.input.display()))?, + )) + .context("parsing pcapng input")?; + + let writer_file = BufWriter::new( + File::create(&args.output) + .with_context(|| format!("creating {}", args.output.display()))?, + ); + let mut writer = PcapNgWriter::new(writer_file).context("initializing pcapng writer")?; + + // The link type of each interface, indexed by interface id, so packet blocks know + // whether they carry a TAP pseudo-header. + let mut link_types: Vec> = Vec::new(); + let mut transport_key_injected = args.no_transport_key; + + let mut total = 0u64; + let mut rekeyed = 0u64; + + let mut reader = reader; + while let Some(block) = reader.next_block() { + let block = block.context("reading pcapng block")?; + + match block { + Block::InterfaceDescription(idb) => { + link_types.push(LinkType::from_pcap_linktype(u32::from(idb.linktype) as u16)); + writer + .write_block(&Block::InterfaceDescription(idb.into_owned())) + .context("writing interface description")?; + } + Block::EnhancedPacket(packet) => { + let interface_id = packet.interface_id as usize; + let link_type = link_types.get(interface_id).copied().flatten(); + + total += 1; + + let new_block = match link_type { + Some(link_type) => { + match rekey_packet(link_type, &packet.data, &args.old_key, &args.new_key) { + Some(new_data) => { + rekeyed += 1; + + // The first frame we rekey identifies the network's PAN. + // Wireshark scopes a learned key to the PAN it saw the + // transport-key command on, so we emit that command on the + // same PAN, just before the frame it decrypts. + if !transport_key_injected + && let Some(pan_id) = mac_pan_id(link_type, &packet.data) + { + inject_transport_key( + &mut writer, + packet.interface_id, + link_type, + pan_id, + &args.new_key, + args.key_sequence_number, + )?; + transport_key_injected = true; + } + + rekeyed_block(&packet, new_data) + } + None => Block::EnhancedPacket(packet.into_owned()), + } + } + None => Block::EnhancedPacket(packet.into_owned()), + }; + + writer.write_block(&new_block).context("writing packet")?; + } + other => { + writer + .write_block(&other.into_owned()) + .context("writing block")?; + } + } + } + + eprintln!( + "Rekeyed {rekeyed} of {total} packets ({} left untouched)", + total - rekeyed + ); + Ok(()) +} + +/// Rebuild an enhanced-packet block around rekeyed data of the same length. +fn rekeyed_block(original: &EnhancedPacketBlock, new_data: Vec) -> Block<'static> { + Block::EnhancedPacket(EnhancedPacketBlock { + interface_id: original.interface_id, + timestamp: original.timestamp, + original_len: new_data.len() as u32, + data: Cow::Owned(new_data), + options: original + .options + .iter() + .map(|option| option.clone().into_owned()) + .collect(), + }) +} + +fn inject_transport_key( + writer: &mut PcapNgWriter, + interface_id: u32, + link_type: LinkType, + pan_id: PanId, + new_key: &Key, + key_sequence_number: u8, +) -> Result<()> { + let data = transport_key_packet( + link_type, + pan_id, + new_key, + key_sequence_number, + &ZIGBEE_ALLIANCE_09, + ); + + let block = Block::EnhancedPacket(EnhancedPacketBlock { + interface_id, + timestamp: std::time::Duration::from_secs(0), + original_len: data.len() as u32, + data: Cow::Owned(data), + options: Vec::new(), + }); + + writer + .write_block(&block) + .context("writing synthetic transport-key packet")?; + Ok(()) +} + +/// Rekey one capture packet, given its link type. Returns `Some(new_packet)` when the +/// packet carried a NWK frame secured with `old_key` (re-encrypted under `new_key`), or +/// `None` when the packet should be emitted unchanged (a foreign network, an +/// unencrypted frame, or anything that does not parse). +fn rekey_packet(link_type: LinkType, data: &[u8], old_key: &Key, new_key: &Key) -> Option> { + match link_type { + LinkType::Raw => rekey_phy(data, old_key, new_key), + LinkType::Tap => { + let header_len = tap_header_len(data)?; + let new_phy = rekey_phy(&data[header_len..], old_key, new_key)?; + + let mut out = data[..header_len].to_vec(); + out.extend(new_phy); + Some(out) + } + } +} + +/// 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 { + if data.len() < 4 { + return None; + } + + let header_len = u16::from_le_bytes([data[2], data[3]]) as usize; + if header_len < 4 || header_len > data.len() { + return None; + } + + Some(header_len) +} + +/// The destination PAN ID (falling back to the source PAN ID) of a capture packet's MAC +/// frame, used to scope the synthetic transport-key command to the right network. +fn mac_pan_id(link_type: LinkType, data: &[u8]) -> Option { + let phy = match link_type { + LinkType::Raw => data, + LinkType::Tap => &data[tap_header_len(data)?..], + }; + + let header = Ieee802154Frame::from_bytes(phy).ok()?; + let header = header.header(); + header.dest_pan_id.or(header.src_pan_id) +} + +/// Rekey a raw 802.15.4 PHY payload (including its FCS). See [`rekey_packet`]. +fn rekey_phy(phy: &[u8], old_key: &Key, new_key: &Key) -> Option> { + let Ieee802154Frame::Data(data_frame) = Ieee802154Frame::from_bytes(phy).ok()? else { + return None; + }; + + let encrypted = EncryptedNwkFrame::from_bytes(&data_frame.payload).ok()?; + + // Only NWK frames secured with the network key are ours to rekey. + if !encrypted.nwk_header.frame_control.security { + return None; + } + let aux_header = encrypted.aux_header.as_ref()?; + if aux_header.security_control.key_id != NwkSecurityHeaderKeyId::NetworkKey { + return None; + } + + // The CCM* nonce needs the originator's EUI64; without it we cannot re-encrypt. + if aux_header.extended_source.is_none() && encrypted.nwk_header.source_ieee.is_none() { + return None; + } + + // A MIC mismatch means the frame belongs to another network: leave it untouched. + let decrypted = encrypted.decrypt(old_key).ok()?; + let reencrypted = decrypted.encrypt(new_key); + + let new_frame = Ieee802154Frame::Data(Ieee802154DataFrame { + header: data_frame.header, + payload: FrameBytes::from_slice(&reencrypted.to_bytes()).ok()?, + fcs: 0, + }); + + Some(new_frame.to_bytes()) +} + +/// Build a synthetic capture packet carrying an APS Transport-Key command that transports +/// `network_key`, protected with the key-transport key derived from `link_key` (typically +/// [`ZIGBEE_ALLIANCE_09`]). Placed at the top of a capture, it lets Wireshark learn the +/// network key automatically, without the key being supplied out of band. +fn transport_key_packet( + link_type: LinkType, + pan_id: PanId, + network_key: &Key, + key_sequence_number: u8, + link_key: &Key, +) -> Vec { + // Fabricated addresses for the synthetic frame; only internal consistency matters. + let coordinator = Eui64::from_hex("aa:aa:aa:aa:aa:aa:aa:aa"); + let joining_device = Eui64::from_hex("bb:bb:bb:bb:bb:bb:bb:bb"); + + let aps_command = ApsCommandFrame { + frame_control: ApsFrameControl { + frame_type: ApsFrameType::Command, + delivery_mode: ApsDeliveryMode::Unicast, + reserved1: 0, + security: true, + ack_request: false, + extended_header: false, + }, + counter: 0, + command: ApsCommandFrameCommand::TransportKey(ApsTransportKeyCommandFrame { + standard_key_type: ApsStandardKeyType::StandardNetworkKey, + key_descriptor: ApsTransportKeyDescriptor::NetworkKey(ApsNetworkKeyDescriptor { + key: network_key.clone(), + sequence_number: key_sequence_number, + destination_address: joining_device, + source_address: coordinator, + }), + }), + }; + + let aps_aux_header = ApsAuxHeader { + security_control: NwkSecurityHeaderControlField { + security_level: NwkSecurityLevel::NoSecurity, + key_id: NwkSecurityHeaderKeyId::KeyTransportKey, + extended_nonce: true, + require_verified_frame_counter: false, + }, + frame_counter: 0, + extended_source: Some(coordinator), + key_sequence_number: None, + }; + + let aps_bytes = aps_command + .encrypt(&key_transport_key(link_key), &aps_aux_header) + .to_bytes(); + + // An unsecured NWK data frame carries the APS command (`EncryptedNwkFrame` with no + // aux header and no NWK encryption is just a cleartext NWK frame on the wire). + let nwk_frame = EncryptedNwkFrame { + nwk_header: NwkHeader { + frame_control: NwkFrameControl { + frame_type: NwkFrameType::Data, + protocol_version: 2, + discover_route: NwkRouteDiscovery::Suppress, + multicast: false, + security: false, + source_route: false, + destination: false, + extended_source: true, + end_device_initiator: false, + reserved1: 0, + }, + destination: Nwk(0x1234), + source: Nwk(0x0000), + radius: 30, + sequence_number: 0, + destination_ieee: None, + source_ieee: Some(coordinator), + multicast_control: None, + source_route: None, + }, + aux_header: None, + ciphertext: FrameBytes::from_slice(&aps_bytes).expect("APS command is frame-bounded"), + }; + + let mac_frame = Ieee802154Frame::Data(Ieee802154DataFrame { + header: Ieee802154FrameHeader { + frame_control: Ieee802154FrameControl { + frame_type: Ieee802154FrameType::Data, + security_enabled: false, + frame_pending: false, + ack_request: false, + pan_id_compression: true, + reserved1: false, + sequence_number_suppression: false, + information_elements_present: false, + dest_addr_mode: Ieee802154AddressingMode::Short, + frame_version: Ieee802154FrameVersion::Ieee2006, + src_addr_mode: Ieee802154AddressingMode::Short, + }, + sequence_number: Some(0), + dest_pan_id: Some(pan_id), + dest_address: Some(Ieee802154Address::Nwk(Nwk(0x1234))), + src_pan_id: Some(pan_id), + src_address: Some(Ieee802154Address::Nwk(Nwk(0x0000))), + }, + payload: FrameBytes::from_slice(&nwk_frame.to_bytes()).expect("NWK frame is frame-bounded"), + fcs: 0, + }); + + let phy = mac_frame.to_bytes(); + + match link_type { + LinkType::Raw => phy, + LinkType::Tap => { + // A 12-byte TAP header: 4-byte base plus one FCS-type TLV declaring a 16-bit + // FCS is present, so Wireshark validates the CRC we emit. + let mut out = vec![0x00, 0x00, 0x0C, 0x00]; + out.extend_from_slice(&[0x00, 0x00, 0x01, 0x00]); // TLV type 0 (FCS type), len 1 + out.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); // value 1 (16-bit FCS), padded + out.extend(phy); + out + } + } +} From 180a87b30793dbe4d50e2f8adae488e4f862af15 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:48:43 -0400 Subject: [PATCH 2/2] Rewrite APS "Transport-Key" commands as well --- crates/ziggurat-tools/src/bin/pcap_rekey.rs | 157 ++++++++++++++++---- 1 file changed, 129 insertions(+), 28 deletions(-) diff --git a/crates/ziggurat-tools/src/bin/pcap_rekey.rs b/crates/ziggurat-tools/src/bin/pcap_rekey.rs index aeab303..2cf284b 100644 --- a/crates/ziggurat-tools/src/bin/pcap_rekey.rs +++ b/crates/ziggurat-tools/src/bin/pcap_rekey.rs @@ -20,11 +20,11 @@ use ziggurat_ieee_802154::{ use ziggurat_zigbee::aps::frame::{ ApsAuxHeader, ApsCommandFrame, ApsCommandFrameCommand, ApsDeliveryMode, ApsFrameControl, ApsFrameType, ApsNetworkKeyDescriptor, ApsStandardKeyType, ApsTransportKeyCommandFrame, - ApsTransportKeyDescriptor, + ApsTransportKeyDescriptor, EncryptedApsCommandFrame, }; use ziggurat_zigbee::crypto::key_transport_key; use ziggurat_zigbee::nwk::frame::{ - EncryptedNwkFrame, NwkFrameControl, NwkFrameType, NwkHeader, NwkRouteDiscovery, + EncryptedNwkFrame, NwkFrameControl, NwkFrameType, NwkHeader, NwkPayload, NwkRouteDiscovery, NwkSecurityHeaderControlField, NwkSecurityHeaderKeyId, NwkSecurityLevel, }; @@ -107,8 +107,14 @@ fn main() -> Result<()> { let mut link_types: Vec> = Vec::new(); let mut transport_key_injected = args.no_transport_key; + // The one key we can use to open APS Transport-Key commands: the key-transport key + // derived from the well-known global TC link key. Commands under a device-specific + // link key stay opaque (and don't leak the network key to a third party anyway). + let aps_transport_key = key_transport_key(&ZIGBEE_ALLIANCE_09); + let mut total = 0u64; let mut rekeyed = 0u64; + let mut aps_rewrites = 0u64; let mut reader = reader; while let Some(block) = reader.next_block() { @@ -129,7 +135,14 @@ fn main() -> Result<()> { let new_block = match link_type { Some(link_type) => { - match rekey_packet(link_type, &packet.data, &args.old_key, &args.new_key) { + match rekey_packet( + link_type, + &packet.data, + &args.old_key, + &args.new_key, + &aps_transport_key, + &mut aps_rewrites, + ) { Some(new_data) => { rekeyed += 1; @@ -170,7 +183,8 @@ fn main() -> Result<()> { } eprintln!( - "Rekeyed {rekeyed} of {total} packets ({} left untouched)", + "Rekeyed {rekeyed} of {total} packets ({} left untouched); \ +rewrote {aps_rewrites} APS Transport-Key command(s) carrying the old network key", total - rekeyed ); Ok(()) @@ -221,16 +235,28 @@ fn inject_transport_key( Ok(()) } -/// Rekey one capture packet, given its link type. Returns `Some(new_packet)` when the -/// packet carried a NWK frame secured with `old_key` (re-encrypted under `new_key`), or -/// `None` when the packet should be emitted unchanged (a foreign network, an -/// unencrypted frame, or anything that does not parse). -fn rekey_packet(link_type: LinkType, data: &[u8], old_key: &Key, new_key: &Key) -> Option> { +/// Rekey one capture packet. `Some(new_packet)` if it changed (NWK re-secured under +/// `new_key`, and/or a transported key swapped `old_key` -> `new_key`); `None` if it +/// should be emitted verbatim (foreign network, or nothing to rewrite). +fn rekey_packet( + link_type: LinkType, + data: &[u8], + old_key: &Key, + new_key: &Key, + aps_transport_key: &Key, + aps_rewrites: &mut u64, +) -> Option> { match link_type { - LinkType::Raw => rekey_phy(data, old_key, new_key), + LinkType::Raw => rekey_phy(data, old_key, new_key, aps_transport_key, aps_rewrites), LinkType::Tap => { let header_len = tap_header_len(data)?; - let new_phy = rekey_phy(&data[header_len..], old_key, new_key)?; + let new_phy = rekey_phy( + &data[header_len..], + old_key, + new_key, + aps_transport_key, + aps_rewrites, + )?; let mut out = data[..header_len].to_vec(); out.extend(new_phy); @@ -268,40 +294,115 @@ fn mac_pan_id(link_type: LinkType, data: &[u8]) -> Option { } /// Rekey a raw 802.15.4 PHY payload (including its FCS). See [`rekey_packet`]. -fn rekey_phy(phy: &[u8], old_key: &Key, new_key: &Key) -> Option> { +fn rekey_phy( + phy: &[u8], + old_key: &Key, + new_key: &Key, + aps_transport_key: &Key, + aps_rewrites: &mut u64, +) -> Option> { let Ieee802154Frame::Data(data_frame) = Ieee802154Frame::from_bytes(phy).ok()? else { return None; }; let encrypted = EncryptedNwkFrame::from_bytes(&data_frame.payload).ok()?; - // Only NWK frames secured with the network key are ours to rekey. - if !encrypted.nwk_header.frame_control.security { - return None; - } - let aux_header = encrypted.aux_header.as_ref()?; - if aux_header.security_control.key_id != NwkSecurityHeaderKeyId::NetworkKey { - return None; - } + let new_nwk_bytes = if encrypted.nwk_header.frame_control.security { + // Network-key secured: decrypt, rewrite any transported key, re-secure under new_key. + let aux_header = encrypted.aux_header.as_ref()?; + if aux_header.security_control.key_id != NwkSecurityHeaderKeyId::NetworkKey { + return None; + } + // The CCM* nonce needs the originator's EUI64; without it we cannot re-encrypt. + if aux_header.extended_source.is_none() && encrypted.nwk_header.source_ieee.is_none() { + return None; + } - // The CCM* nonce needs the originator's EUI64; without it we cannot re-encrypt. - if aux_header.extended_source.is_none() && encrypted.nwk_header.source_ieee.is_none() { - return None; - } + // A MIC mismatch means the frame belongs to another network: leave it untouched. + let mut decrypted = encrypted.decrypt(old_key).ok()?; - // A MIC mismatch means the frame belongs to another network: leave it untouched. - let decrypted = encrypted.decrypt(old_key).ok()?; - let reencrypted = decrypted.encrypt(new_key); + if let NwkPayload::Opaque(aps) = &decrypted.payload + && let Some(new_aps) = rewrite_transport_key(aps, old_key, new_key, aps_transport_key) + { + *aps_rewrites += 1; + decrypted.payload = NwkPayload::Opaque(FrameBytes::from_slice(&new_aps).ok()?); + } + + decrypted.encrypt(new_key).to_bytes() + } else { + // Unsecured join frame: rewrite a cleartext Transport-Key command if it carries the key. + if encrypted.nwk_header.frame_control.frame_type != NwkFrameType::Data { + return None; + } + let new_aps = + rewrite_transport_key(&encrypted.ciphertext, old_key, new_key, aps_transport_key)?; + *aps_rewrites += 1; + + EncryptedNwkFrame { + nwk_header: encrypted.nwk_header, + aux_header: None, + ciphertext: FrameBytes::from_slice(&new_aps).ok()?, + } + .to_bytes() + }; let new_frame = Ieee802154Frame::Data(Ieee802154DataFrame { header: data_frame.header, - payload: FrameBytes::from_slice(&reencrypted.to_bytes()).ok()?, + payload: FrameBytes::from_slice(&new_nwk_bytes).ok()?, fcs: 0, }); Some(new_frame.to_bytes()) } +/// Swap `old_key` for `new_key` inside an APS Transport-Key command that transports the +/// network key and is decryptable with `aps_transport_key` (derived from the global TC +/// link key), returning the re-encrypted, same-length command. `None` for anything +/// else, including commands under a device-specific link key we don't hold. +fn rewrite_transport_key( + aps: &[u8], + old_key: &Key, + new_key: &Key, + aps_transport_key: &Key, +) -> Option> { + // Cleartext APS frame control: frame type in the low two bits, security is bit 5. + let &frame_control = aps.first()?; + if frame_control & 0b11 != ApsFrameType::Command as u8 || frame_control & 0b0010_0000 == 0 { + return None; + } + + let encrypted = EncryptedApsCommandFrame::from_bytes(aps).ok()?; + let command = encrypted.decrypt(aps_transport_key).ok()?; + + let ApsCommandFrameCommand::TransportKey(transport_key) = &command.command else { + return None; + }; + let ApsTransportKeyDescriptor::NetworkKey(descriptor) = &transport_key.key_descriptor else { + return None; + }; + if descriptor.key != *old_key { + return None; + } + + let mut new_descriptor = descriptor.clone(); + new_descriptor.key = new_key.clone(); + + let rewritten = ApsCommandFrame { + frame_control: command.frame_control.clone(), + counter: command.counter, + command: ApsCommandFrameCommand::TransportKey(ApsTransportKeyCommandFrame { + standard_key_type: transport_key.standard_key_type, + key_descriptor: ApsTransportKeyDescriptor::NetworkKey(new_descriptor), + }), + }; + + Some( + rewritten + .encrypt(aps_transport_key, &encrypted.aux_header) + .to_bytes(), + ) +} + /// Build a synthetic capture packet carrying an APS Transport-Key command that transports /// `network_key`, protected with the key-transport key derived from `link_key` (typically /// [`ZIGBEE_ALLIANCE_09`]). Placed at the top of a capture, it lets Wireshark learn the