├── .gitignore ├── Makefile ├── README.md ├── ZbPy ├── AES.py ├── CCM.py ├── Device.py ├── Gecko.py ├── IEEE802154.py ├── NIC.py ├── Packet.py ├── Parser.py ├── ZCL.py ├── ZigbeeApplication.py ├── ZigbeeCluster.py └── ZigbeeNetwork.py ├── images └── ikea-remote.jpg ├── zbdecode ├── zbdev └── zbsniff /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | __pycache__ 3 | *.log 4 | *~ 5 | *.mpy 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DEV = /dev/ttyACM0 2 | MPY_CROSS=../micropython/mpy-cross/mpy-cross 3 | 4 | 5 | PY_FILES := $(wildcard ZbPy/*.py) 6 | MPY_FILES = $(PY_FILES:.py=.mpy) 7 | 8 | %.mpy: %.py 9 | $(MPY_CROSS) \ 10 | -march=armv7m \ 11 | -mno-unicode \ 12 | -o $@ \ 13 | $< 14 | 15 | all: $(MPY_FILES) 16 | 17 | pre-install: 18 | ampy -p "$(DEV)" mkdir ZbPy 19 | 20 | %.install: %.mpy 21 | @echo $< 22 | ampy -p "$(DEV)" put $< $< 23 | 24 | install: $(PY_FILES:.py=.install) 25 | 26 | clean: 27 | $(RM) */*.mpy *.mpy 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gecko Zigbee Python interface 2 | 3 | This is a pure Python implementation of a IEEE802.15.4 and ZigBee network stack 4 | that works with MicroPython. 5 | 6 | It was developed for the Silicon Labs Gecko boards used in 7 | Ikea Tradfri devices. The firmware port of MicroPython lives in 8 | [osresearch/micropython/ports/efm32](https://github.com/osresearch/micropython/tree/efm32/ports/efm32). 9 | The underlying ZigBee radio interface also lives in the machine specific 10 | port for that board; this is the hardware agnostic software stack that 11 | processes the packets. 12 | 13 | For more information on the project, see [Ikea+Micropython lightning talk](https://trmm.net/Ikea). 14 | 15 | # Installing 16 | 17 | ![Ikea Tradfri remote with header attached](images/ikea-remote.jpg) 18 | 19 | See the 20 | [README](https://github.com/osresearch/micropython/blob/efm32/ports/efm32/README.md) 21 | in the MicroPython port for details on building and installing using OpenOCD + SWD. 22 | 23 | Using [`ampy.py`](https://learn.adafruit.com/micropython-basics-load-files-and-run-code/install-ampy) you can 24 | install all of the code from the `ZbPy` directory into the flash on the device. 25 | 26 | # ZigBee network layers 27 | 28 | * Phyiscal layer (handled by the radio, supported via `import Radio`) 29 | * IEEE802.15.4: `import ZbPy.IEEE802154` 30 | * NWK - Network Layer: `import ZbPy.ZigbeeNetwork` 31 | * Security Layer (not really a layer, an optional part of the NWK header) 32 | * APS - Application Support Layer: `import ZbPy.ZigbeeApplication` 33 | * ZCL - ZigBee Cluster Library: `import ZbPy.ZigbeeCluster` 34 | 35 | 36 | Most of the layers are sufficiently implemented to join an 37 | existing Zigbee network, receive a 16-bit short network address, 38 | decrypt+validate and encrypt+mac messages encrypted with the 39 | [AES-CCM mode](https://en.wikipedia.org/wiki/CCM_mode). 40 | 41 | The Application and Cluster layers are only partially 42 | implemented right now; the messages are decoded but not 43 | mapped to functionality. 44 | Perhaps parts of [ZigPy](https://github.com/zigpy/zigpy) can be 45 | ported to MicroPython to provide these mappings. 46 | 47 | 48 | # Debugging Zigbee 49 | 50 | It is easiest to use `zbsniff` in this tree, talking to an 51 | Ikea device on `/dev/ttyACM0`, which will output a PCAP file 52 | on stdout. You can pipe this to `wireshark` to trace what 53 | is going on: 54 | 55 | ``` 56 | zbsniff | wireshark -k -i - 57 | ``` 58 | 59 | However, there is lots of "noise" in the Zigbee protocol 60 | with repeat messages, acks, etc that make the wireshark 61 | display messy. 62 | 63 | Don't display acks: `!(wpan.frame_type == 0x2)` 64 | Don't display repeats: `(!(wpan.frame_type == 0x1) || wpan.src16 == zbee_nwk.src)` 65 | -------------------------------------------------------------------------------- /ZbPy/AES.py: -------------------------------------------------------------------------------- 1 | # 2 | # 3 | try: 4 | # if Crypto.Cipher.AES is availble, use it instead 5 | from Crypto.Cipher import AES as PyAES 6 | class AES: 7 | def __init__(self, key_encrypt): 8 | self.aes = PyAES.new(key_encrypt, PyAES.MODE_ECB) 9 | 10 | def encrypt(self, plaintext): 11 | return bytearray(self.aes.encrypt(bytes(plaintext))) 12 | 13 | def decrypt(self, ciphertext): 14 | return bytearray(self.aes.decrypt(bytes(ciphertext))) 15 | 16 | except: 17 | # Gecko AES wrapper in ECB mode. 18 | # on the actual hardware, use the accelerated mode 19 | # This uses a temporary buffer for the output and must be used 20 | # before the next operation. 21 | from machine import Crypto 22 | 23 | class AES: 24 | def __init__(self, key_encrypt): 25 | self.key_encrypt = key_encrypt 26 | self.key_decrypt = Crypto.aes_decryptkey(key_encrypt) 27 | self.out = bytearray(16) 28 | 29 | def encrypt(self, plaintext): 30 | Crypto.aes_ecb_encrypt(self.key_encrypt, plaintext, self.out) 31 | return self.out 32 | 33 | def decrypt(self, ciphertext): 34 | Crypto.aes_ecb_decrypt(self.key_decrypt, ciphertext, self.out) 35 | return self.out 36 | -------------------------------------------------------------------------------- /ZbPy/CCM.py: -------------------------------------------------------------------------------- 1 | # Zigbee uses a weird AES mode called CCM* 2 | # It requires encrypting/decrypting the message and auth data twice 3 | # But it is a standard, so we have to support it. 4 | # Trying to read the spec is awful, but translating the wireshark code 5 | # is not too bad... 6 | 7 | # Decrypt a message in place and compute the message integrity code (MIC) 8 | # based on the clear text and the authorization message. The nonce will 9 | # be updated in counter mode as it is used; should be 0 at the start 10 | # 11 | # Caveats: 12 | # This does not handle l_m > 65535, but zigbee never has more than 64 bytes 13 | # There is weirdness in the B0 computation that hardcodes the length l_M 14 | 15 | def decrypt(auth, message, mic, nonce, aes, validate=True): 16 | l_m = len(message) 17 | l_M = len(mic) 18 | l_a = len(auth) 19 | 20 | if l_M != 4: 21 | raise ValueError("MIC must be 4 bytes") 22 | if len(nonce) != 16: 23 | raise ValueError("Nonce must be 16 bytes") 24 | 25 | # decrypt the MIC block in place if there is a MIC 26 | if validate: 27 | xor = aes.encrypt(nonce) 28 | for j in range(l_M): 29 | mic[j] ^= xor[j] 30 | 31 | # decrypt each 16-byte block in counter mode 32 | # including any partial block at the end 33 | for i in range(0, l_m, 16): 34 | block_len = l_m - i 35 | if block_len > 16: 36 | block_len = 16 37 | 38 | # increment the counter word, 39 | nonce[15] += 1 40 | xor = aes.encrypt(nonce) 41 | for j in range(block_len): 42 | message[i+j] ^= xor[j] 43 | 44 | # avoid the extra encryption if not validating 45 | if not validate: 46 | return True 47 | 48 | # 49 | # check the message integrity code with the messy CCM* 50 | # 51 | 52 | # Generate the first cipher block B0 53 | xor[0] = 1 << 3 # int((4 - 2)/2) << 3 54 | if l_a != 0: 55 | xor[0] |= 0x40 56 | xor[0] |= 1 57 | xor[1:14] = nonce[1:14] # src addr and counter 58 | xor[14] = (l_m >> 8) & 0xFF 59 | xor[15] = (l_m >> 0) & 0xFF 60 | xor = aes.encrypt(xor) 61 | 62 | # process the auth length and auth data blocks 63 | xor[0] ^= (l_a >> 8) & 0xFF 64 | xor[1] ^= (l_a >> 0) & 0xFF 65 | j = 2 66 | for i in range(l_a): 67 | if (j == 16): 68 | xor = aes.encrypt(xor) 69 | j = 0 70 | xor[j] ^= auth[i] 71 | j += 1 72 | 73 | # pad out the rest of this block with 0 74 | j = 16 75 | 76 | # process the clear text message blocks 77 | for i in range(l_m): 78 | if (j == 16): 79 | xor = aes.encrypt(xor) 80 | j = 0 81 | xor[j] ^= message[i] 82 | j += 1 83 | 84 | # fixup any last bits 85 | if j != 0: 86 | xor = aes.encrypt(xor) 87 | 88 | # If all went well, the xor block should match the decrypted MIC 89 | return xor[0:l_M] == mic 90 | 91 | 92 | # Encypts a message in place and returns the 4-byte MIC 93 | # Nonce should have the counter value of 0; will be modified in place as well 94 | def encrypt(auth, message, nonce, aes): 95 | l_m = len(message) 96 | l_M = 4 97 | l_a = len(auth) 98 | 99 | if len(nonce) != 16: 100 | raise ValueError("Nonce must be 16 bytes") 101 | 102 | # Generate the first cipher block B0 103 | # and run in CBC mode to compute the MIC 104 | xor = bytearray(16) 105 | xor[0] |= 1 << 3 # int((4 - 2)/2) << 3 106 | xor[0] |= 0x40 107 | xor[0] |= 1 108 | xor[1:14] = nonce[1:14] # src addr and counter 109 | xor[14] = (l_m >> 8) & 0xFF 110 | xor[15] = (l_m >> 0) & 0xFF 111 | xor = aes.encrypt(xor) 112 | 113 | # process the auth length and auth data blocks 114 | xor[0] ^= (l_a >> 8) & 0xFF 115 | xor[1] ^= (l_a >> 0) & 0xFF 116 | j = 2 117 | for i in range(l_a): 118 | if (j == 16): 119 | xor = aes.encrypt(xor) 120 | j = 0 121 | xor[j] ^= auth[i] 122 | j += 1 123 | 124 | # pad out the rest of this block with 0 125 | j = 16 126 | 127 | # process the clear text message blocks 128 | for i in range(l_m): 129 | if (j == 16): 130 | xor = aes.encrypt(xor) 131 | j = 0 132 | xor[j] ^= message[i] 133 | j += 1 134 | 135 | # fixup any last bits 136 | if j != 0: 137 | xor = aes.encrypt(xor) 138 | 139 | # the cleartext MIC is the first few bytes of the last cipher text block 140 | mic = xor[0:l_M] 141 | 142 | # Use the nonce with counter 0 to encrypt the MIC 143 | xor = aes.encrypt(nonce) 144 | for i in range(l_M): 145 | mic[i] ^= xor[i] 146 | 147 | # now repeat the process to encrypt the message in a counter mode 148 | # XOR each 16-byte block in counter mode 149 | # including any partial block at the end 150 | for i in range(0, l_m, 16): 151 | block_len = l_m - i 152 | if block_len > 16: 153 | block_len = 16 154 | 155 | # increment the counter word, 156 | nonce[15] += 1 157 | xor = aes.encrypt(nonce) 158 | for j in range(block_len): 159 | message[i+j] ^= xor[j] 160 | 161 | # return the encrypted MIC 162 | return mic 163 | -------------------------------------------------------------------------------- /ZbPy/Device.py: -------------------------------------------------------------------------------- 1 | # Zigbee/IEEE 802.15.4 device 2 | # 3 | # 4 | 5 | from ZbPy import AES 6 | from ZbPy import IEEE802154 7 | from ZbPy import ZigbeeNetwork 8 | from ZbPy import ZigbeeApplication 9 | from ZbPy import ZigbeeCluster 10 | from ZbPy import ZCL 11 | from ZbPy import Parser 12 | 13 | from binascii import unhexlify, hexlify 14 | from struct import pack, unpack 15 | 16 | try: 17 | from time import ticks_us 18 | except: 19 | import time 20 | def ticks_us(): 21 | return int(time.time() * 1e6) 22 | 23 | 24 | class IEEEDevice: 25 | data_request_timeout = 1000000 # usec == 100 ms 26 | retransmit_timeout = 1000000 # usec == 100 ms 27 | 28 | def __init__(self, radio, router = None): 29 | self.radio = radio 30 | self.router = router 31 | self.seq = 0 32 | self.pending_seq = None 33 | self.pending_data = None 34 | self.handler = lambda x: None 35 | self.joined = lambda x: None 36 | self.seqs = {} 37 | self.verbose = False 38 | 39 | self.tx_fail = 0 40 | self.last_data_request = 0 41 | self.last_tx = 0 42 | self.join_failed = False 43 | 44 | def rx(self, data): 45 | if data is None or len(data) < 2: # or len(data) < 2 or len(data) & 1 != 0: 46 | self.tick() 47 | return 48 | 49 | try: 50 | ieee = IEEE802154.IEEE802154(data=data) 51 | 52 | # check for duplicates, using the short address 53 | # long address duplicates will be processed 54 | if type(ieee.src) is int: 55 | if ieee.src in self.seqs \ 56 | and self.seqs[ieee.src] == ieee.seq: 57 | return 58 | 59 | self.seqs[ieee.src] = ieee.seq 60 | 61 | if self.verbose: 62 | print("RX: " + str(Parser.parse(data)[0])) 63 | 64 | except Exception as e: 65 | print("IEEE error: " + str(hexlify(data))) 66 | raise 67 | return 68 | 69 | if ieee.frame_type == IEEE802154.FRAME_TYPE_CMD: 70 | return self.handle_command(ieee) 71 | elif ieee.frame_type == IEEE802154.FRAME_TYPE_ACK: 72 | return self.handle_ack(ieee) 73 | elif ieee.frame_type == IEEE802154.FRAME_TYPE_BEACON: 74 | return self.handle_beacon(ieee) 75 | elif ieee.frame_type == IEEE802154.FRAME_TYPE_DATA: 76 | # for the next layer up, pass it to the handler 77 | return self.handler(ieee.payload) 78 | else: 79 | # should signal a wtf? 80 | print("RX: " + str(Parser.parse(data)[0])) 81 | pass 82 | 83 | 84 | def tick(self): 85 | pkt = self.radio.rx() 86 | if pkt is not None: 87 | self.rx(pkt) 88 | 89 | now = ticks_us() 90 | 91 | # if we are hoping for more data, and we are not waiting for 92 | # an ack, go ahead and send a data request 93 | if self.pending_data \ 94 | and now - self.last_data_request > self.data_request_timeout \ 95 | and self.pending_seq is None: 96 | self.data_request() 97 | self.last_data_request = now 98 | 99 | if self.pending_seq is None: 100 | return 101 | 102 | if self.retries == 0: 103 | # todo: log that the transmit failed to ever be acked 104 | self.pending_seq = None 105 | self.tx_fail += 1 106 | return 107 | 108 | if now - self.last_tx > self.retransmit_timeout: 109 | print("RETX %02x %d" % (self.pending_seq, self.retries)) 110 | self.last_tx = now 111 | self.radio.tx(self.last_pkt) 112 | self.retries -= 1 113 | 114 | def loop(self): 115 | while True: 116 | self.tick() 117 | 118 | # Send an ACK for a sequence number, indicating that it has 119 | # been received by this device (but nothing else about it) 120 | # This should never be called: the lowest layer in the radio.c 121 | # sends autoacks 122 | def ack(self, ack_seq): 123 | self.radio.tx(IEEE802154.IEEE802154( 124 | frame_type = IEEE802154.FRAME_TYPE_ACK, 125 | seq = ack_seq, 126 | ).serialize()) 127 | #print("ACKING %02x" % (ack_seq)) 128 | 129 | # Send a data request to say "yo we're ready for stuff" 130 | def data_request(self): 131 | pan = self.radio.pan() 132 | src = self.radio.address() 133 | if src is None: 134 | src = self.radio.mac() 135 | 136 | self.tx_ieee(IEEE802154.IEEE802154( 137 | frame_type = IEEE802154.FRAME_TYPE_CMD, 138 | command = IEEE802154.COMMAND_DATA_REQUEST, 139 | dst = self.router, 140 | dst_pan = pan, 141 | src = src, 142 | src_pan = pan, 143 | ack_req = True, 144 | )) 145 | 146 | # Send a packet from the upper layer, wrapping it with the 147 | # IEEE 802.15.4 header, setting the flags as requested 148 | def tx(self, payload, dst = None, ack_req = False, long_addr = False): 149 | pan = self.radio.pan() 150 | src = self.radio.address() 151 | if src is None: 152 | src = self.radio.mac() 153 | 154 | self.tx_ieee(IEEE802154.IEEE802154( 155 | frame_type = IEEE802154.FRAME_TYPE_DATA, 156 | dst = dst, 157 | dst_pan = pan, 158 | src = src, 159 | src_pan = pan, 160 | ack_req = ack_req, 161 | payload = payload, 162 | )) 163 | 164 | def tx_ieee(self, pkt): 165 | pkt.seq = self.seq 166 | self.seq = (self.seq + 1) & 0x3F 167 | 168 | #print("SENDING %02x" % (pkt.seq)) 169 | if self.verbose: 170 | print("TX: ", pkt) 171 | 172 | self.last_pkt = pkt.serialize() 173 | if pkt.command != IEEE802154.COMMAND_DATA_REQUEST: 174 | print("TX: " + str(Parser.parse(self.last_pkt)[0])) 175 | self.radio.tx(self.last_pkt) 176 | 177 | if pkt.ack_req: 178 | self.last_tx = ticks_us() 179 | self.pending_seq = pkt.seq 180 | self.retries = 3 181 | else: 182 | self.pending_seq = None 183 | 184 | # Send a join request; we must know the PAN 185 | # from the beacon before sending the join 186 | def join(self, payload=b'\x80'): 187 | self.tx_ieee(IEEE802154.IEEE802154( 188 | frame_type = IEEE802154.FRAME_TYPE_CMD, 189 | command = IEEE802154.COMMAND_JOIN_REQUEST, 190 | ack_req = 1, 191 | seq = self.seq, 192 | src = self.radio.mac(), 193 | src_pan = 0xFFFF, # not yet part of the PAN 194 | dst = self.router, 195 | dst_pan = self.radio.pan(), 196 | payload = payload, # b'\x80' = Battery powered, please allocate address 197 | )) 198 | self.pending_data = True 199 | self.join_failed = False 200 | 201 | # Send a beacon request 202 | def beacon(self): 203 | self.tx_ieee(IEEE802154.IEEE802154( 204 | frame_type = IEEE802154.FRAME_TYPE_CMD, 205 | command = IEEE802154.COMMAND_BEACON_REQUEST, 206 | seq = self.seq, 207 | src = None, 208 | src_pan = None, 209 | dst = 0xFFFF, # everyone 210 | dst_pan = 0xFFFF, # every PAN 211 | payload = b'', 212 | )) 213 | 214 | # if we have a pending ACK for this sequence number, flag it as received 215 | def handle_ack(self, ieee): 216 | if ieee.seq == self.pending_seq: 217 | if self.verbose: 218 | print("RX ACK %02x" % (ieee.seq)) 219 | self.pending_seq = None 220 | else: 221 | print("RX ACK %02x != expected %02x" % (ieee.seq, self.pending_seq)) 222 | 223 | # process a IEEE802154 command message to us 224 | def handle_command(self, ieee): 225 | if ieee.command == IEEE802154.COMMAND_JOIN_RESPONSE: 226 | if not self.pending_data: 227 | return 228 | #self.pending_data = False 229 | (new_nwk, status) = unpack("> 0) & 0x7 47 | self.ack_req = ((fcf >> 5) & 1) != 0 48 | dst_mode = (fcf >> 10) & 0x3 49 | src_mode = (fcf >> 14) & 0x3 50 | self.src = None 51 | self.dst = None 52 | self.dst_pan = None 53 | self.src_pan = None 54 | self.command = None 55 | 56 | self.seq = b[j] 57 | j += 1 58 | 59 | if dst_mode != 0: 60 | # Destination pan is always in the message 61 | self.dst_pan = (b[j+0] << 0) | (b[j+1] << 8) 62 | j += 2 63 | if dst_mode == 2: 64 | # short destination addresses 65 | self.dst = b[j+0] << 0 | b[j+1] << 8 66 | j += 2 67 | elif dst_mode == 3: 68 | # long addresses 69 | self.dst = b[j:j+8] 70 | j += 8 71 | else: 72 | throw("Unknown dst_mode %d" % (dst_mode)) 73 | 74 | if src_mode != 0: 75 | if (fcf >> 6) & 1: 76 | # pan compression, use the dst_pan 77 | self.src_pan = self.dst_pan 78 | else: 79 | # pan is in the message 80 | self.src_pan = b[j+0] << 0 | b[j+1] << 8 81 | j += 2 82 | 83 | if src_mode == 2: 84 | # short source addressing 85 | self.src = b[j+0] << 0 | b[j+1] << 8 86 | j += 2 87 | elif src_mode == 3: 88 | # long source addressing 89 | self.src = b[j:j+8] 90 | j += 8 91 | else: 92 | throw("Unknown src_mode %d" % (src_mode)) 93 | 94 | if self.frame_type == FRAME_TYPE_CMD: 95 | self.command = b[j] 96 | j += 1 97 | 98 | # the rest of the message is the payload for the next layer 99 | self.payload = b[j:] 100 | 101 | return self 102 | 103 | def serialize(self): 104 | hdr = bytearray() 105 | hdr.append(0) # FCF will be filled in later 106 | hdr.append(0) 107 | hdr.append(self.seq & 0x7F) 108 | 109 | fcf = self.frame_type & 0x7 110 | if self.ack_req: 111 | fcf |= 1 << 5 # Ack request 112 | 113 | # Destination address mode 114 | if self.dst_pan is not None: 115 | hdr.append((self.dst_pan >> 0) & 0xFF) 116 | hdr.append((self.dst_pan >> 8) & 0xFF) 117 | if type(self.dst) is int: 118 | # short addressing, only 16-bits 119 | fcf |= 0x2 << 10 120 | hdr.append((self.dst >> 0) & 0xFF) 121 | hdr.append((self.dst >> 8) & 0xFF) 122 | elif self.dst is not None: 123 | # long address, should be 8 bytes 124 | if len(self.dst) != 8: 125 | throw("dst address must be 8 bytes") 126 | fcf |= 0x3 << 10 127 | hdr.extend(self.dst) 128 | 129 | # Source address mode; can be ommitted entirely 130 | if self.src is not None: 131 | if self.src_pan is None or self.src_pan == self.dst_pan: 132 | fcf |= 1 << 6 # Pan ID compression 133 | else: 134 | hdr.append((self.src_pan >> 0) & 0xFF) 135 | hdr.append((self.src_pan >> 8) & 0xFF) 136 | 137 | if type(self.src) is int: 138 | # short address, only 16-bits 139 | fcf |= 0x2 << 14 140 | hdr.append((self.src >> 0) & 0xFF) 141 | hdr.append((self.src >> 8) & 0xFF) 142 | else: 143 | # long address, should be 8 bytes 144 | if len(self.src) != 8: 145 | throw("src address must be 8 bytes") 146 | fcf |= 0x3 << 14 147 | hdr.extend(self.src) 148 | 149 | # add in the frame control field 150 | hdr[0] = (fcf >> 0) & 0xFF 151 | hdr[1] = (fcf >> 8) & 0xFF 152 | 153 | if self.frame_type == FRAME_TYPE_CMD: 154 | hdr.append(self.command) 155 | 156 | if type(self.payload) is memoryview \ 157 | or type(self.payload) is bytearray \ 158 | or type(self.payload) is bytes: 159 | hdr.extend(self.payload) 160 | else: 161 | hdr.extend(self.payload.serialize()) 162 | 163 | return hdr 164 | 165 | 166 | # parse an IEEE802.15.4 command 167 | def cmd_parse(self, b): 168 | cmd = b.u8() 169 | if cmd == 0x04: 170 | self.payload = "Data request" 171 | elif cmd == 0x07: 172 | self.payload = "Beacon request" 173 | else: 174 | self.payload = "Command %02x" % (cmd) 175 | 176 | def __str__(self): 177 | params = [ 178 | "frame_type=" + str(self.frame_type), 179 | "seq=" + str(self.seq), 180 | ] 181 | 182 | if self.frame_type == FRAME_TYPE_CMD: 183 | params.append("command=0x%02x" % (self.command)) 184 | 185 | if self.ack_req: 186 | params.append("ack_req=1") 187 | 188 | if type(self.dst) is int: 189 | params.append("dst=0x%04x" % (self.dst)) 190 | elif type(self.dst) is not None: 191 | params.append("dst=" + str(self.dst)) 192 | if type(self.dst_pan) is int: 193 | params.append("dst_pan=0x%04x" % (self.dst_pan)) 194 | 195 | if type(self.src) is int: 196 | params.append("src=0x%04x" % (self.src)) 197 | elif type(self.src) is not None: 198 | params.append("src=" + str(self.src)) 199 | if type(self.src_pan) is int: 200 | params.append("src_pan=0x%04x" % (self.src_pan)) 201 | 202 | if type(self.payload) is memoryview \ 203 | or type(self.payload) is bytearray \ 204 | or type(self.payload) is bytes: 205 | if len(self.payload) != 0: 206 | params.append("payload=" + str(bytes(self.payload))) 207 | else: 208 | params.append("payload=" + str(self.payload)) 209 | 210 | return "IEEE802154(" + ", ".join(params) + ")" 211 | 212 | 213 | def parse(pkt): 214 | return IEEE802154(data=pkt) 215 | -------------------------------------------------------------------------------- /ZbPy/NIC.py: -------------------------------------------------------------------------------- 1 | # Zigbee/IEEE 802.15.4 serial NIC device 2 | # 3 | # 4 | 5 | import gc 6 | import os 7 | import sys 8 | import machine 9 | import Radio 10 | from ZbPy import IEEE802154 11 | from binascii import unhexlify, hexlify 12 | from struct import unpack 13 | 14 | Radio.init() 15 | 16 | def loop(): 17 | pan_set = False 18 | 19 | incoming = '' 20 | machine.zrepl(False) 21 | 22 | while True: 23 | pkt = Radio.rx() 24 | if pkt is not None: 25 | # throw away the extra b' and ' on the string 26 | # representation 27 | x = repr(hexlify(pkt))[2:-1] 28 | print(x) 29 | 30 | ieee = IEEE802154.IEEE802154(data=pkt) 31 | 32 | if ieee.frame_type == IEEE802154.FRAME_TYPE_CMD \ 33 | and ieee.command == IEEE802154.COMMAND_JOIN_RESPONSE: 34 | # reponse to our join request; update our short address 35 | (new_nwk, status) = unpack("ZCL ClusterParser 8 | import struct 9 | 10 | clusters = { 11 | 0x0000: { 12 | 'name': 'Basic', 13 | }, 14 | 0x0001: { 15 | 'name': 'PowerConfig', 16 | }, 17 | 0x0002: { 18 | 'name': 'DeviceTemp', 19 | }, 20 | 0x0003: { 21 | 'name': 'Identify', 22 | }, 23 | 0x0004: { 24 | 'name': 'Groups', 25 | }, 26 | 0x0005: { 27 | 'name': 'Scenes', 28 | 0x0b: [ "DefaultResp", "!BB", ["id", "status"] ], 29 | }, 30 | 0x0006: { 31 | 'name': 'OnOff', 32 | 0x00: [ "Off", "!", [] ], 33 | 0x01: [ "On", "!", [] ], 34 | 0x02: [ "Toggle", "!", [] ], 35 | 0x40: [ "OffWithEffect", "!BB", ["id", "variant"] ], 36 | 0x41: [ "OnWithRecall", "!", [] ], 37 | 0x42: [ "OnWithTimedOff", "!Bhh", ["on", "time", "wait"] ], 38 | 0x0b: [ "DefaultResp", "!BB", ["id", "status"] ], 39 | }, 40 | 0x0007: { 41 | 'name': 'OnOffConfig', 42 | }, 43 | 0x0008: { 44 | 'name': 'LevelControl', 45 | 0x00: [ "MoveToLevel", "!Bh", [ "level", "time" ] ], 46 | 0x01: [ "Move", "!BB", [ "dir", "rate" ] ], 47 | 0x02: [ "Step", "!BBh", [ "dir", "step", "time" ] ], 48 | 0x03: [ "Stop", "!", [] ], 49 | 0x04: [ "MoveToLevelOnOff", "!Bh", [ "level", "time" ] ], 50 | 0x05: [ "MoveOnOff", "!BB", [ "dir", "rate" ] ], 51 | 0x06: [ "StepOnOff", "!BBh", [ "dir", "step", "time" ] ], 52 | 0x07: [ "Stop", "!", [] ], 53 | 0x0b: [ "DefaultResp", "!BB", ["id", "status"] ], 54 | }, 55 | } 56 | 57 | def lookup(name): 58 | cluster_name,command_name = name.split(".") 59 | for cluster_id, cluster in clusters.items(): 60 | if cluster_name != cluster["name"]: 61 | continue 62 | for command_id, fmt in cluster.items(): 63 | if fmt[0] == command_name: 64 | return cluster_id, command_id 65 | raise NameError("Unknown cluster.command " + name) 66 | 67 | def unpack(fmt,names,data): 68 | try: 69 | ret = {} 70 | vals = struct.unpack(fmt, data) 71 | i = 0 72 | for name in names: 73 | ret[name] = vals[i] 74 | i += 1 75 | return ret 76 | except: 77 | print("unpack('"+fmt+"'",data,") failed") 78 | raise 79 | 80 | def pack(fmt, names, data): 81 | l = [] 82 | for name in names: 83 | l.append(data[name]) 84 | return struct.pack(fmt, *l) 85 | 86 | 87 | class ZCL: 88 | def __init__(self, 89 | data = None, 90 | cluster = None, 91 | command = None, 92 | name = None, 93 | payload = {} 94 | ): 95 | self.command = command 96 | self.cluster = cluster 97 | if data is not None: 98 | self.deserialize(data) 99 | else: 100 | self.name = name 101 | self.payload = payload 102 | 103 | def __str__(self): 104 | return "ZCL(name='" + self.name + "', payload=" + str(self.payload) + ")" 105 | 106 | def deserialize(self, b): 107 | if self.cluster not in clusters: 108 | return None 109 | cluster = clusters[self.cluster]; 110 | if self.command not in cluster: 111 | return None 112 | fmt = cluster[self.command] 113 | self.name = cluster["name"] + "." + fmt[0] 114 | #print("unpack(",fmt,")") 115 | self.payload = unpack(fmt[1], fmt[2], b) 116 | return self 117 | 118 | def serialize(self): 119 | cluster_id, command_id = lookup(self.name) 120 | fmt = clusters[cluster_id][command_id] 121 | return pack(fmt[1], fmt[2], self.payload) 122 | 123 | #print(ZCL(cluster=0x08, command=0x06, data=b'\x00+\x05\x00')) 124 | #x = ZCL(name='LevelControl.StepOnOff', payload={'dir': 0, 'step': 43, 'time': 5}) 125 | #print(x.serialize()) 126 | 127 | -------------------------------------------------------------------------------- /ZbPy/ZigbeeApplication.py: -------------------------------------------------------------------------------- 1 | # Zigbee Application Support Layer packet parser 2 | # IEEE802154 3 | # ZigbeeNetwork (NWK) 4 | # (Optional encryption, with either Trust Center Key or Transport Key) 5 | #-> ZigbeeApplicationSupport (APS) 6 | # ZigbeeClusterLibrary (ZCL) 7 | 8 | MODE_UNICAST = 0x0 9 | MODE_BROADCAST = 0x2 10 | MODE_GROUP = 0x3 11 | 12 | FRAME_TYPE_DATA = 0x0 13 | FRAME_TYPE_ACK = 0x1 14 | 15 | PROFILE_DEVICE = 0x0000 16 | PROFILE_HOME = 0x0104 17 | 18 | CLUSTER_DEVICE_NODE_DESCRIPTOR_REQUEST = 0x0002 19 | CLUSTER_DEVICE_NODE_DESCRIPTOR_RESPONSE = 0x8002 20 | CLUSTER_DEVICE_ENDPOINT_REQUEST = 0x0005 21 | CLUSTER_DEVICE_ENDPOINT_RESPONSE = 0x8005 22 | CLUSTER_DEVICE_ENDPOINT_DESCR_REQUEST = 0x0004 23 | CLUSTER_DEVICE_ENDPOINT_DESCR_RESPONSE = 0x8004 24 | CLUSTER_DEVICE_ANNOUNCEMENT = 0x0013 25 | 26 | class ZigbeeApplication: 27 | 28 | # parse the ZigBee Application packet 29 | # src/dst are endpoints 30 | def __init__(self, 31 | data = None, 32 | frame_type = FRAME_TYPE_DATA, 33 | mode = MODE_UNICAST, 34 | src = 0, 35 | dst = 0, 36 | cluster = 0, 37 | profile = 0, 38 | seq = 0, 39 | payload = b'', 40 | ack_req = False, 41 | ): 42 | if data is not None: 43 | self.deserialize(data) 44 | else: 45 | self.frame_type = frame_type 46 | self.mode = mode 47 | self.src = src 48 | self.dst = dst 49 | self.cluster = cluster 50 | self.profile = profile 51 | self.seq = seq 52 | self.ack_req = ack_req 53 | self.payload = payload 54 | 55 | def __str__(self): 56 | params = [ 57 | "frame_type=" + str(self.frame_type), 58 | "cluster=0x%04x" % (self.cluster), 59 | "profile=0x%04x" % (self.profile), 60 | "mode=" + str(self.mode), 61 | "seq=" + str(self.seq), 62 | "src=0x%02x" % (self.src), 63 | ] 64 | 65 | if self.mode == MODE_GROUP: 66 | params.append("dst=0x%04x" % (self.dst)) 67 | else: 68 | params.append("dst=0x%02x" % (self.dst)) 69 | 70 | if self.ack_req: 71 | params.append("ack_req=1") 72 | 73 | if type(self.payload) is memoryview \ 74 | or type(self.payload) is bytearray \ 75 | or type(self.payload) is bytes: 76 | if len(self.payload) != 0: 77 | params.append("payload=" + str(bytes(self.payload))) 78 | else: 79 | params.append("payload=" + str(self.payload)) 80 | 81 | return "ZigbeeApplication(" + ", ".join(params) + ")" 82 | 83 | def deserialize(self, b): 84 | j = 0 85 | fcf = b[j]; j += 1 86 | self.frame_type = (fcf >> 0) & 3 87 | self.mode = (fcf >> 2) & 3 88 | self.security = (fcf >> 5) & 1 # not yet supported 89 | self.ack_req = (fcf >> 6) & 1 90 | 91 | if self.mode == MODE_GROUP: 92 | self.dst = (b[j+1] << 8) | (b[j+0] << 0) 93 | j += 2 94 | else: 95 | self.dst = b[j] 96 | j += 1 97 | 98 | self.cluster = (b[j+1] << 8) | (b[j+0] << 0); j += 2 99 | self.profile = (b[j+1] << 8) | (b[j+0] << 0); j += 2 100 | self.src = b[j]; j += 1 101 | self.seq = b[j]; j += 1 102 | 103 | self.payload = b[j:] 104 | 105 | return self 106 | 107 | def serialize(self): 108 | hdr = bytearray() 109 | fcf = 0 \ 110 | | (self.frame_type << 0) \ 111 | | (self.mode << 2) \ 112 | | (self.ack_req << 6) 113 | 114 | hdr.append(fcf) 115 | if self.mode == MODE_GROUP: 116 | hdr.append((self.dst >> 0) & 0xFF) 117 | hdr.append((self.dst >> 8) & 0xFF) 118 | else: 119 | hdr.append(self.dst & 0xFF) 120 | 121 | hdr.append((self.cluster >> 0) & 0xFF) 122 | hdr.append((self.cluster >> 8) & 0xFF) 123 | hdr.append((self.profile >> 0) & 0xFF) 124 | hdr.append((self.profile >> 8) & 0xFF) 125 | hdr.append(self.src) 126 | hdr.append(self.seq & 0x7F) 127 | 128 | if type(self.payload) is bytes or type(self.payload) is bytearray: 129 | hdr.extend(self.payload) 130 | else: 131 | hdr.extend(self.payload.serialize()) 132 | 133 | return hdr 134 | -------------------------------------------------------------------------------- /ZbPy/ZigbeeCluster.py: -------------------------------------------------------------------------------- 1 | # Zigbee Cluster Library packet parser 2 | # IEEE802154 3 | # ZigbeeNetwork (NWK) 4 | # (Optional encryption, with either Trust Center Key or Transport Key) 5 | # ZigbeeApplicationSupport (APS) 6 | #-> ZigbeeClusterLibrary (ZCL) 7 | # Various clusters 8 | 9 | # "Cluster" is a horrible name for a mesh network protocol - it implies 10 | # something about groups of nodes, but is Zigbee-speak for groups of commands. 11 | # The commands are split into different groups for things like On/Off, Level control, 12 | # OTA updates, etc. 13 | 14 | FRAME_TYPE_CLUSTER_SPECIFIC = 1 15 | DIRECTION_TO_SERVER = 0 16 | DIRECTION_TO_CLIENT = 1 17 | 18 | class ZigbeeCluster: 19 | 20 | def __init__(self, 21 | data = None, 22 | frame_type = FRAME_TYPE_CLUSTER_SPECIFIC, 23 | direction = DIRECTION_TO_SERVER, 24 | manufacturer_specific = False, 25 | disable_default_response = False, 26 | seq = 0, 27 | command = 0, 28 | payload = b'', 29 | ): 30 | if data is not None: 31 | self.deserialize(data) 32 | else: 33 | self.frame_type = frame_type 34 | self.direction = direction 35 | self.seq = seq 36 | self.disable_default_response = disable_default_response 37 | self.manufacturer_specific = manufacturer_specific 38 | self.command = command 39 | self.payload = payload 40 | 41 | def __str__(self): 42 | params = [ 43 | "command=0x%02x" % (self.command), 44 | "seq=%d" % (self.seq), 45 | "direction=%d" % (self.direction), 46 | ] 47 | 48 | if self.disable_default_response: 49 | params.append("disable_default_response=1") 50 | if self.manufacturer_specific: 51 | params.append("manufacturer_specific=1") 52 | 53 | if type(self.payload) is memoryview \ 54 | or type(self.payload) is bytearray \ 55 | or type(self.payload) is bytes: 56 | if len(self.payload) != 0: 57 | params.append("payload=" + str(bytes(self.payload))) 58 | else: 59 | params.append("payload=" + str(self.payload)) 60 | 61 | return "ZigbeeCluster(" + ", ".join(params) + ")" 62 | 63 | def deserialize(self, b): 64 | j = 0 65 | fcf = b[j]; j += 1 66 | self.frame_type = (fcf >> 0) & 3 67 | self.manufacturer_specific = (fcf >> 2) & 1 68 | self.direction = (fcf >> 3) & 1 69 | self.disable_default_response = (fcf >> 4) & 1 70 | 71 | self.seq = b[j]; j += 1 72 | self.command = b[j]; j += 1 73 | self.payload = b[j:] 74 | 75 | return self 76 | 77 | def serialize(self): 78 | hdr = bytearray() 79 | fcf = 0 \ 80 | | (self.frame_type << 0) \ 81 | | (self.manufacturer_specific << 2) \ 82 | | (self.direction << 3) \ 83 | | (self.disable_default_response << 4) 84 | 85 | hdr.append(fcf) 86 | hdr.append(self.seq & 0x7F) 87 | hdr.append(self.command) 88 | 89 | if type(self.payload) is bytes or type(self.payload) is bytearray: 90 | hdr.extend(self.payload) 91 | else: 92 | hdr.extend(self.payload.serialize()) 93 | 94 | return hdr 95 | -------------------------------------------------------------------------------- /ZbPy/ZigbeeNetwork.py: -------------------------------------------------------------------------------- 1 | # Zigbee packet parser 2 | # IEEE802154 3 | # ZigbeeNetwork (NWK) 4 | # (Optional encryption, with either Trust Center Key or Transport Key) 5 | # ZigbeeApplicationSupport (APS) 6 | # ZigbeeClusterLibrary (ZCL) 7 | 8 | # Zigbee Trust Center key: 5A:69:67:42:65:65:41:6C:6C:69:61:6E:63:65:30:39 9 | 10 | # Not properly supported: 11 | # * Multicast 12 | # * Route discovery 13 | # * Source routing 14 | FRAME_TYPE_DATA = 0 15 | FRAME_TYPE_CMD = 1 16 | FRAME_TYPE_RESERVED = 2 17 | FRAME_TYPE_PAN = 3 18 | 19 | CMD_ROUTE_REQUEST = 0x01 20 | CMD_ROUTE_REPLY = 0x02 21 | CMD_NETWORK_STATUS = 0x03 22 | CMD_LEAVE = 0x04 23 | CMD_ROUTE_RECORD = 0x05 24 | CMD_REJOIN_REQUEST = 0x06 25 | CMD_REJOIN_RESPONSE = 0x07 26 | CMD_LINK_STATUS = 0x08 27 | CMD_NETWORK_REPORT = 0x09 28 | CMD_NETWORK_UPDATE = 0x0a 29 | 30 | DEST_BROADCAST = 0xFFFD 31 | 32 | from ZbPy import CCM 33 | 34 | class ZigbeeNetwork: 35 | 36 | # parse the ZigBee network layer 37 | def __init__(self, 38 | data = None, 39 | frame_type = FRAME_TYPE_DATA, 40 | version = 2, 41 | discover_route = 1, 42 | multicast = 0, 43 | source_route = 0, 44 | src = 0, 45 | dst = 0, 46 | radius = 1, 47 | seq = 0, 48 | ext_src = None, 49 | ext_dst = None, 50 | payload = b'', 51 | security = False, 52 | sec_seq = 0, 53 | sec_key = 0, 54 | sec_key_seq = 0, 55 | aes = None, 56 | validate = True, 57 | ): 58 | self.aes = aes 59 | self.validate = validate 60 | 61 | if data is not None: 62 | self.deserialize(data) 63 | else: 64 | self.frame_type = frame_type 65 | self.version = version 66 | self.source_route = source_route 67 | self.discover_route = discover_route 68 | self.multicast = multicast 69 | self.radius = radius 70 | self.payload = payload 71 | self.seq = seq 72 | self.src = src 73 | self.dst = dst 74 | self.ext_src = ext_src 75 | self.ext_dst = ext_dst 76 | 77 | self.security = security 78 | self.sec_seq = sec_seq 79 | self.sec_key = sec_key 80 | self.sec_key_seq = sec_key_seq 81 | self.valid = True # since we have a cleartext payload 82 | 83 | def __str__(self): 84 | params = [ 85 | "frame_type=" + str(self.frame_type), 86 | "version=" + str(self.version), 87 | "radius=" + str(self.radius), 88 | "seq=" + str(self.seq), 89 | "dst=0x%04x" % (self.dst), 90 | "src=0x%04x" % (self.src), 91 | ] 92 | 93 | if self.ext_src is not None: 94 | params.append("ext_src=" + str(bytes(self.ext_src))) 95 | 96 | if self.source_route != 0: 97 | params.append("source_route=1") 98 | if self.multicast != 0: 99 | params.append("multicast=1") 100 | if self.discover_route == 0: 101 | params.append("discover_route=0") 102 | 103 | if self.security: 104 | params.extend([ 105 | "security=1", 106 | "sec_seq=" + str(bytes(self.sec_seq)), 107 | "sec_key=" + str(self.sec_key), 108 | "sec_key_seq=" + str(self.sec_key_seq), 109 | ]) 110 | if not self.valid: 111 | params.append("valid=FALSE") 112 | 113 | if type(self.payload) is memoryview \ 114 | or type(self.payload) is bytearray \ 115 | or type(self.payload) is bytes: 116 | if len(self.payload) != 0: 117 | params.append("payload=" + str(bytes(self.payload))) 118 | else: 119 | params.append("payload=" + str(self.payload)) 120 | 121 | return "ZigbeeNetwork(" + ", ".join(params) + ")" 122 | 123 | # Create an object from bytes on a wire 124 | def deserialize(self, b): 125 | j = 0 126 | fcf = (b[j+1] << 8) | (b[j+0] << 0); j += 2 127 | self.frame_type = (fcf >> 0) & 3 128 | self.version = (fcf >> 2) & 15 129 | self.discover_route = (fcf >> 6) & 3 130 | self.multicast = (fcf >> 8) & 1 131 | self.security = (fcf >> 9) & 1 132 | self.source_route = (fcf >> 10) & 1 133 | dst_mode = (fcf >> 11) & 1 134 | src_mode = (fcf >> 12) & 1 135 | 136 | self.dst = (b[j+1] << 8) | (b[j+0] << 0); j += 2 137 | self.src = (b[j+1] << 8) | (b[j+0] << 0); j += 2 138 | self.radius = b[j]; j += 1 139 | self.seq = b[j]; j += 1 140 | 141 | self.ext_dst = None 142 | self.ext_src = None 143 | 144 | # extended dest is present 145 | if dst_mode: 146 | self.ext_dst = b[j:j+8] 147 | j += 8 148 | 149 | # extended source is present 150 | if src_mode: 151 | self.ext_src = b[j:j+8] 152 | j += 8 153 | 154 | if not self.security: 155 | # the rest of the packet is the payload 156 | self.payload = b[j:] 157 | self.valid = True 158 | else: 159 | # security header is present, attempt to decrypt 160 | # and validate the message. 161 | try: 162 | self.ccm_decrypt(b, j) 163 | self.valid = True 164 | except: 165 | print("---- BAD CCM ----") 166 | self.valid = False 167 | self.payload = b[j:] 168 | return self 169 | 170 | # Convert an object back to bytes, with optional encryption 171 | def serialize(self): 172 | hdr = bytearray() 173 | fcf = 0 \ 174 | | (self.frame_type << 0) \ 175 | | (self.version << 2) \ 176 | | (self.discover_route << 6) \ 177 | | (self.multicast << 8) \ 178 | | (self.security << 9) \ 179 | | (self.source_route << 10) 180 | hdr.append(0) # FCF will be filled in later 181 | hdr.append(0) 182 | hdr.append((self.dst >> 0) & 0xFF) 183 | hdr.append((self.dst >> 8) & 0xFF) 184 | hdr.append((self.src >> 0) & 0xFF) 185 | hdr.append((self.src >> 8) & 0xFF) 186 | hdr.append(self.radius) 187 | hdr.append(self.seq & 0x7F) 188 | if self.ext_dst is not None: 189 | hdr.extend(self.ext_dst) 190 | fcf |= 1 << 11 191 | if self.ext_src is not None: 192 | hdr.extend(self.ext_src) 193 | fcf |= 1 << 12 194 | 195 | if type(self.payload) is bytes or type(self.payload) is bytearray: 196 | payload = self.payload 197 | else: 198 | payload = self.payload.serialize() 199 | 200 | # fill in the updated field control field 201 | hdr[0] = (fcf >> 0) & 0xFF 202 | hdr[1] = (fcf >> 8) & 0xFF 203 | 204 | if not self.security: 205 | hdr.extend(payload) 206 | return hdr 207 | else: 208 | fcf |= 1 << 9 209 | return self.ccm_encrypt(hdr, payload) 210 | 211 | # security header is present; b contains the entire Zigbee NWk header 212 | # so that the entire MIC can be computed 213 | def ccm_decrypt(self, b, j): 214 | # the security control field is not filled in correctly in the header, 215 | # so it is necessary to patch it up to contain ZBEE_SEC_ENC_MIC32 216 | # == 5. Not sure why, but wireshark does it. 217 | sec_hdr = b[j] 218 | sec_hdr = (sec_hdr & ~0x07) | 0x05 219 | b[j] = sec_hdr # modify in place 220 | j += 1 221 | self.sec_key = (sec_hdr >> 3) & 3 222 | self.sec_seq = b[j:j+4] ; j += 4 223 | 224 | # 8 byte ieee address, used in the extended nonce 225 | if sec_hdr & 0x20: # extended nonce bit 226 | self.ext_src = b[j:j+8] 227 | j += 8 228 | else: 229 | # hopefully the one in the header was present 230 | self.ext_src = None 231 | 232 | # The key seq tells us which key is used; 233 | # should always be zero? 234 | self.sec_key_seq = b[j] ; j += 1 235 | 236 | # section 4.20 says extended nonce is 237 | # 8 bytes of IEEE address, little endian 238 | # 4 bytes of counter, little endian 239 | # 1 byte of patched security control field 240 | nonce = bytearray(16) 241 | nonce[0] = 0x01 242 | nonce[1:9] = self.ext_src 243 | nonce[9:13] = self.sec_seq 244 | nonce[13] = sec_hdr # modified sec hdr! 245 | nonce[14] = 0x00 246 | nonce[15] = 0x00 247 | 248 | # authenticated data is everything up to the message 249 | # which includes the network header and the unmodified sec_hdr value 250 | auth = b[0:j] 251 | C = b[j:-4] # cipher text 252 | M = b[-4:] # message integrity code 253 | 254 | if not CCM.decrypt(auth, C, M, nonce, self.aes, validate=self.validate): 255 | print("BAD DECRYPT: ", bytes(b)) 256 | #print("message=", C) 257 | self.payload = C 258 | self.valid = False 259 | else: 260 | self.payload = C 261 | self.valid = True 262 | 263 | # Re-encrypt a message and return the security header plus encrypted payload and MIC 264 | def ccm_encrypt(self, hdr, payload): 265 | sec_hdr = (0x05 << 0) \ 266 | | (self.sec_key << 3) \ 267 | | (1 << 5) 268 | sec_hdr_offset = len(hdr) # for updates later 269 | hdr.append(sec_hdr) 270 | hdr.extend(self.sec_seq) 271 | hdr.extend(self.ext_src) # should be only if we have a ext_src, but it has better be there 272 | hdr.append(self.sec_key_seq) 273 | 274 | nonce = bytearray(16) 275 | nonce[0] = 0x01 276 | nonce[1:9] = self.ext_src 277 | nonce[9:13] = self.sec_seq 278 | nonce[13] = sec_hdr 279 | nonce[14] = 0x00 280 | nonce[15] = 0x00 281 | 282 | mic = CCM.encrypt(hdr, payload, nonce, self.aes) 283 | 284 | # payload is encrypted in place 285 | hdr.extend(payload) 286 | hdr.extend(mic) 287 | 288 | # for WTF reasons, they don't send the MIC parameters in the header. 289 | hdr[sec_hdr_offset] = sec_hdr & ~7 290 | 291 | return hdr 292 | -------------------------------------------------------------------------------- /images/ikea-remote.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osresearch/ZbPy/da1f161a2ccc9d60af722c204c1c437de9eb502f/images/ikea-remote.jpg -------------------------------------------------------------------------------- /zbdecode: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Decode hex dumps of ZigBee packets on stdin 3 | import sys 4 | from binascii import unhexlify 5 | from ZbPy import AES 6 | from ZbPy import IEEE802154 7 | from ZbPy import ZigbeeNetwork 8 | from ZbPy import ZigbeeApplication 9 | from ZbPy import ZigbeeCluster 10 | from ZbPy import Parser 11 | 12 | 13 | # This is the "well known" zigbee2mqtt key. 14 | # The Ikea gateway uses a different key that has to be learned 15 | # by joining the network (not yet implemented) 16 | nwk_key = unhexlify("01030507090b0d0f00020406080a0c0d") 17 | aes = AES.AES(nwk_key) 18 | 19 | def process_packet(data, verbose=False): 20 | #print(data) 21 | ieee = IEEE802154.IEEE802154(data=data) 22 | 23 | if verbose: print("IEEE:", ieee) 24 | if ieee.frame_type != IEEE802154.FRAME_TYPE_DATA or len(ieee.payload) == 0: 25 | return ieee 26 | 27 | nwk = ZigbeeNetwork.ZigbeeNetwork(data=ieee.payload, aes=aes) 28 | if verbose: print("NWK:", nwk) 29 | 30 | ieee.payload = nwk 31 | 32 | if nwk.frame_type != ZigbeeNetwork.FRAME_TYPE_DATA: 33 | return ieee 34 | 35 | aps = ZigbeeApplication.ZigbeeApplication(data=nwk.payload) 36 | nwk.payload = aps 37 | if verbose: print("APS:", aps) 38 | 39 | if aps.frame_type != ZigbeeApplication.FRAME_TYPE_DATA: 40 | return ieee 41 | 42 | # join requests are "special" for now 43 | if aps.cluster == 0x36: 44 | print("JOIN:", ieee) 45 | return ieee 46 | 47 | zcl = ZigbeeCluster.ZigbeeCluster(data=aps.payload) 48 | aps.payload = zcl 49 | 50 | if verbose: print("ZCL:", ieee) 51 | return ieee 52 | 53 | 54 | while True: 55 | line = sys.stdin.readline() 56 | if not line: 57 | break 58 | 59 | data = bytearray(unhexlify(line.rstrip())) 60 | ieee = process_packet(data, verbose=True) 61 | print(ieee) 62 | -------------------------------------------------------------------------------- /zbdev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # zbdev pretends to be a Zigbee device 3 | # the local interface should be running the NIC.py firmware so that 4 | # it is hexdumping the raw packets that are being received 5 | 6 | import os 7 | import sys 8 | import serial 9 | import threading 10 | import readline 11 | import time 12 | from select import select 13 | from binascii import unhexlify, hexlify 14 | from struct import pack, unpack 15 | from ZbPy import Device 16 | from ZbPy import ZigbeeNetwork 17 | from ZbPy import ZigbeeApplication 18 | 19 | # re-open stdout as binary 20 | #stdout = os.fdopen(sys.stdout.fileno(), "wb") 21 | #stderr = os.fdopen(sys.stderr.fileno(), "wb") 22 | from sys import stdout, stderr 23 | 24 | device = "/dev/ttyACM0" 25 | speed = 115200 26 | do_reset = False 27 | serial_dev = serial.Serial(device, speed, timeout=0.1) 28 | 29 | def process_line(line): 30 | try: 31 | data = bytearray(unhexlify(line)) 32 | return data 33 | except Exception as e: 34 | #print(str(e) + ": " + str(line)) 35 | print("Read '" + str(line) + "'") 36 | return None 37 | 38 | line = b'' 39 | def process_serial(): 40 | global line 41 | c = serial_dev.read(1) 42 | if c is None: 43 | return 44 | if c == b'\r': 45 | return 46 | 47 | #print(str(c,"UTF-8"), end='') 48 | #stderr.write(c) 49 | 50 | # if (b'0' <= c and c <= b'9') \ 51 | # or (b'a' <= c and c <= b'f'): 52 | if b'\n' != c: 53 | line += c 54 | return 55 | 56 | if c == b'\n' and line != b'': 57 | pkt = process_line(line) 58 | line = b'' 59 | return pkt 60 | 61 | # either a line was processed, or a non-hex char 62 | # was received, in either case restart the line 63 | line = b'' 64 | return 65 | 66 | def wait_for(s): 67 | l = '' 68 | while True: 69 | c = serial_dev.read(1) 70 | if c is None: 71 | continue 72 | 73 | c = str(c, "UTF-8") 74 | if '\r' == c: 75 | continue 76 | 77 | if '\n' == c: 78 | print("Read '", l, "'") 79 | l = '' 80 | continue 81 | 82 | l += c 83 | 84 | if l == s: 85 | print(l) 86 | return 87 | 88 | # send the commands to reboot and load the NIC firmware 89 | serial_dev.write(b"\x03\x03\x03") 90 | serial_dev.flushOutput() 91 | time.sleep(0.2) 92 | wait_for(">>> ") 93 | if do_reset: 94 | serial_dev.write(b"reset()\r") # why \r? 95 | serial_dev.flushOutput() 96 | wait_for("MicroPython v") 97 | wait_for(">>> ") 98 | serial_dev.write(b"import ZbPy.NIC\r") 99 | serial_dev.flushOutput() 100 | wait_for(">>> ") 101 | serial_dev.write(b"ZbPy.NIC.loop()\r") 102 | serial_dev.flushOutput() 103 | 104 | #stderr.write(b"reset\n") 105 | 106 | 107 | last_status = 0 108 | 109 | addr = [ 110 | b'\x8co\xdb\xfe\xff\xd7k\x08', # mac 111 | None, # nwk 112 | None, # pan 113 | ] 114 | 115 | # Mock the Radio with the serial NIC 116 | class Radio: 117 | def __init__(self, serial_dev): 118 | self.serial_dev = serial_dev 119 | def tx(self, b): 120 | s = hexlify(b) 121 | print("TX: ", s) 122 | s += b"\n" 123 | #self.serial_dev.write(s) 124 | #self.serial_dev.flushOutput() 125 | #return 126 | 127 | # slow down the transmission a bit 128 | chunk = 32 129 | for i in range(0,len(s),chunk): 130 | p = s[i:i+chunk] 131 | print(p, end=',') 132 | self.serial_dev.write(p) 133 | self.serial_dev.flushOutput() 134 | 135 | if i + chunk < len(s): 136 | time.sleep(0.05) 137 | def rx(self): 138 | return process_serial() 139 | def mac(self): 140 | return addr[0] 141 | def address(self,new_nwk=None): 142 | if new_nwk is not None: 143 | addr[1] = new_nwk 144 | return addr[1] 145 | def pan(self,new_pan=None): 146 | if new_pan is not None: 147 | addr[2] = new_pan 148 | return addr[2] 149 | 150 | 151 | zbdev = Device.IEEEDevice( 152 | radio = Radio(serial_dev), 153 | ) 154 | 155 | nwkdev = Device.NetworkDevice( 156 | dev = zbdev, 157 | seq = int(time.time()) 158 | ) 159 | 160 | 161 | # 162 | # Handle Zigbee Device profile cluster messages 163 | # These are important for joining the network, etc. 164 | # 165 | aps_counter = 0 166 | 167 | def zcl_device(nwkdev, pkt): 168 | global aps_counter 169 | 170 | aps = pkt.payload 171 | if aps.frame_type != ZigbeeApplication.FRAME_TYPE_DATA: 172 | return 173 | 174 | if aps.cluster == ZigbeeApplication.CLUSTER_DEVICE_NODE_DESCRIPTOR_RESPONSE: 175 | print("Node Descriptor:", pkt) 176 | elif aps.cluster == ZigbeeApplication.CLUSTER_DEVICE_NODE_DESCRIPTOR_REQUEST: 177 | print("--- Node Descriptor Request ---") 178 | aps_counter = (aps_counter + 1) & 0x7F 179 | nwkdev.tx(dst=pkt.src, ack_req=True, payload=ZigbeeApplication.ZigbeeApplication( 180 | profile = ZigbeeApplication.PROFILE_DEVICE, 181 | cluster = ZigbeeApplication.CLUSTER_DEVICE_NODE_DESCRIPTOR_RESPONSE, 182 | seq = aps_counter, 183 | payload = pack(" 1.0: 356 | last_status = now 357 | print(now) 358 | 359 | if addr[2] is None: 360 | if now - last_beacon > 0.5 \ 361 | and zbdev.pending_seq is None: 362 | # no PAN set: send a beacon request 363 | print("---- BEACON REQUEST ----") 364 | zbdev.beacon() 365 | last_beacon = now 366 | 367 | elif addr[1] is None: 368 | if now - last_join > 1.5 \ 369 | and zbdev.pending_seq is None: 370 | # PAN is set, but not NWK: send a join request 371 | print("\n\n\n---- JOIN REQUEST ----") 372 | zbdev.join() 373 | last_join = now 374 | 375 | elif not announcement_sent \ 376 | and now - last_join > 3 \ 377 | and zbdev.pending_seq is None: 378 | print("--- Device announcement ---") 379 | announcement_sent = True 380 | nwkdev.tx(dst=ZigbeeNetwork.DEST_BROADCAST, ack_req=False, payload=ZigbeeApplication.ZigbeeApplication( 381 | profile = ZigbeeApplication.PROFILE_DEVICE, 382 | cluster = ZigbeeApplication.CLUSTER_DEVICE_ANNOUNCEMENT, 383 | payload = bytes([ 384 | 0x12, 385 | (addr[1] >> 0) & 0xFF, 386 | (addr[1] >> 8) & 0xFF, 387 | addr[0][0], 388 | addr[0][1], 389 | addr[0][2], 390 | addr[0][3], 391 | addr[0][4], 392 | addr[0][5], 393 | addr[0][6], 394 | addr[0][7], 395 | 0x80, 396 | ]), 397 | )) 398 | 399 | elif not query_sent \ 400 | and False \ 401 | and now - last_join > 4 \ 402 | and zbdev.pending_seq is None: 403 | print("--- Coordinator query ---") 404 | query_sent = True 405 | coord_nwk = 0x0000 406 | nwkdev.tx(dst=coord_nwk, ack_req=True, payload=ZigbeeApplication.ZigbeeApplication( 407 | profile = ZigbeeApplication.PROFILE_DEVICE, 408 | cluster = ZigbeeApplication.CLUSTER_DEVICE_NODE_DESCRIPTOR_REQUEST, 409 | payload = bytes([ 410 | 0x01, # seq 411 | (coord_nwk >> 0) & 0xFF, 412 | (coord_nwk >> 8) & 0xFF, 413 | ]), 414 | )) 415 | 416 | elif now - last_req > 0.8 \ 417 | and zbdev.pending_seq is None: 418 | # NWK is now set; send data requests to keep the party going 419 | #print("--- Data request ---") 420 | zbdev.data_request() 421 | last_req = now 422 | -------------------------------------------------------------------------------- /zbsniff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # zbsniff turns stdin into pcap files 3 | # the local interface should be running the NIC.py firmware so that 4 | # it is hexdumping the raw packets that are being received 5 | 6 | import os 7 | import sys 8 | import serial 9 | import threading 10 | import readline 11 | import time 12 | from select import select 13 | from binascii import unhexlify, hexlify 14 | from struct import pack 15 | #from ZbPy import Parser 16 | 17 | # re-open stdout as binary 18 | #stdout = os.fdopen(sys.stdout.fileno(), "wb") 19 | #stderr = os.fdopen(sys.stderr.fileno(), "wb") 20 | from sys import stdout, stderr 21 | 22 | device = "/dev/ttyACM0" 23 | speed = 115200 24 | dev = serial.Serial(device, speed) 25 | 26 | # send the commands to reboot and load the NIC firmware 27 | dev.write(b"\x03\x03\x03") 28 | time.sleep(0.2) 29 | dev.write(b"reset()\r") # why \r? 30 | time.sleep(0.5) 31 | dev.write(b"import NIC\r") 32 | 33 | #stderr.write(b"reset\n") 34 | 35 | # and output the pcap header https://wiki.wireshark.org/Development/LibpcapFileFormat 36 | """ 37 | typedef struct pcap_hdr_s { 38 | guint32 magic_number; /* magic number 0xa1b2c3d4 */ 39 | guint16 version_major; /* major version number */ 40 | guint16 version_minor; /* minor version number */ 41 | gint32 thiszone; /* GMT to local correction */ 42 | guint32 sigfigs; /* accuracy of timestamps */ 43 | guint32 snaplen; /* max length of captured packets, in octets */ 44 | guint32 network; /* data link type LINKTYPE_IEEE802_15_4_NOFCS 230 */ 45 | } pcap_hdr_t; 46 | 47 | typedef struct pcaprec_hdr_s { 48 | guint32 ts_sec; /* timestamp seconds */ 49 | guint32 ts_usec; /* timestamp microseconds */ 50 | guint32 incl_len; /* number of octets of packet saved in file */ 51 | guint32 orig_len; /* actual length of packet */ 52 | } pcaprec_hdr_t; 53 | """ 54 | stdout.buffer.write(pack("