ZigBee frame encryption with AES-128-CCM*

This page details how to encrypt ZigBee frames with AES-128-CCM*. Zigbee encryption is performed in three stages:

The frame used in this example was listened on a real ZigBee network detailed on this page: Autopsy of a ZigBee frame.

The algorithm is detailed in the section 4.3.1.1 and annex A of the ZigBee specification.

Libraries and functions

Here are the libraries and functions used in the following:

!pip install pycrypto

# Python Cryptography Toolkit for AES encryption
from Crypto.Cipher import AES

# Print aray of bytes in hexadecimal
def printhex(x, sep = ' '):
  str = ''
  for b in x:  
    byte = hex(b)[2:]
    if (len(byte)<2)  :
      str += '0' + byte + sep
    else:
      str += hex(b)[2:] + sep 
  print (str[:-1].upper())

# 16 bits padding (with 0x00)
def pad(x):
  n=(16-len(x)%16)%16
  return x + bytes([0x00]*n)

Network key

According to annex A.2 of the ZigBee specification:

A bit string Key of length keylen bits to be used as the key. Each entity shall have evidence that access to this key is restricted to the entity itself and its intended key-sharing group member(s).

In this example, the network key has been previously listen on the network thank this ZigBee sniffer:

Network key: AD:8E:BB:C4:F9:6A:E7:00:05:06:D3:FC:D1:62:7F:B8

key =   bytes([0xAD, 0x8E, 0xBB, 0xC4, 0xF9, 0x6A, 0xE7, 0x00, 0x05, 0x06, 0xD3, 0xFC, 0xD1, 0x62, 0x7F, 0xB8])
printhex (key)
AD 8E BB C4 F9 6A E7 00 05 06 D3 FC D1 62 7F B8

L of the message length field

According to annex A of the ZigBee specification:

The length L of the message length field, in octets, shall have been chosen. Valid values for L are the integers 2, 3,..., 8 (the value L=1 is reserved).

In the present case, L=2.

L = 2

M (length of frame integrity)

According to annex A of the ZigBee specification:

The length M of the authentication field, in octets, shall have been chosen. Valid values for M are the integers 0, 4, 6, 8, 10, 12, 14, and 16. (The value M=0 corresponds to disabling authenticity, since then the authentication field contains an empty string.)

M is the length of the frame integrity (MIC) in number of bytes. In the present case, and from section 4.5.1.1.1 of the ZigBee specification, we can deduce the length of M is 4 bytes: AC:4C:76:AF.

M=4

NWK header

The NWK header (network header) is extracted from the raw frame:

NWK header: 48:02:00:00:8A:5C:1E:5D

NwkHeader = bytes([0x48, 0x02, 0x00, 0x00, 0x8A, 0x5C, 0x1E, 0x5D])
printhex (NwkHeader)
48 02 00 00 8A 5C 1E 5D

NWK AUX Header

According to section 4.5.1 of the ZigBee specification:

The auxiliary frame header, as illustrated by Figure 4.18, shall include a security control field and a frame counter field, and may include a sender address field and key sequence number field.

Octets: 1 4 0/8 0/1
Security control Frame counter Source address Key sequence number

More details on the section about NWK auxiliary header.

AuxiliaryHeader = bytes([0x2D, 0xE1, 0x00, 0x00, 0x00, 0x01, 0x3C, 0xE8, 0x01, 0x00, 0x8D, 0x15, 0x00, 0x01])
printhex (AuxiliaryHeader)
2D E1 00 00 00 01 3C E8 01 00 8D 15 00 01

Nonce

According to section 4.5.2.2 of the ZigBee specification:

The nonce input used for the CCM encryption and authentication transformation and for the CCM decryption and authentication checking transformation consists of data explicitly included in the frame and data that both devices can independently obtain.

Octets: 8 4 1
Source address Frame counter Security control
nonce = bytes([0x01, 0x3C, 0xE8, 0x01, 0x00, 0x8D, 0x15, 0x00  ,  0xE1, 0x00, 0x00, 0x00  ,  0x2D])
print (len(nonce), 'bytes')
printhex (nonce)
13 bytes
01 3C E8 01 00 8D 15 00 E1 00 00 00 2D

a and m

From section 4.3.1.1 from the ZigBee specification:

If the security level requires encryption, the octet string a shall be the string NwkHeader || AuxiliaryHeader and the octet string m shall be the string Payload.

Since in our case, security level requires encryption:

a = NwkHeader || AuxiliaryHeader

m = payload (The payload contains the data sent by the switch, see section NWK payload for more details).

# Octet string a
a = NwkHeader + AuxiliaryHeader
print ('a:')
printhex (a)

# Octet string m
print ('\nm:')
m = bytes([0x00, 0x01, 0x12, 0x00, 0x04, 0x01, 0x01, 0x62, 0x18, 0xC3, 0x0A, 0x55, 0x00, 0x21, 0x01, 0x00])
printhex(m)
a:
48 02 00 00 8A 5C 1E 5D 2D E1 00 00 00 01 3C E8 01 00 8D 15 00 01

m:
00 01 12 00 04 01 01 62 18 C3 0A 55 00 21 01 00

AddAuthData

From annex A.2.1 of the ZigBee specification:

  1. Form the octet string representation L(a) of the length l(a) of the octet string a. [...]
  2. Right-concatenate the octet string L(a) with the octet string a itself. Note that the resulting string contains and a encoded in a reversible manner.
  3. Form the padded message AddAuthData by right-concatenating the resulting string with the smallest non-negative number of all-zero octets such that the octet string AddAuthData has length divisible by 16.

In the present case (case b in the ZigBee specification), L(a) is the 2-octets encoding of l(a).

# Right-concatenate the octet string L(a) with the octet string a itself.
AddAuthData = len(a).to_bytes(2, byteorder = 'big') + a
# Form the padded message AddAuthData
AddAuthData = pad(AddAuthData)

print(len(AddAuthData), 'bytes')
printhex(AddAuthData)
32 bytes
00 16 48 02 00 00 8A 5C 1E 5D 2D E1 00 00 00 01 3C E8 01 00 8D 15 00 01 00 00 00 00 00 00 00 00

PlaintextData

From annex A.2.1 of the ZigBee specification:

Form the padded message PlaintextData by right-concatenating the octet string m with the smallest non-negative number of all-zero octets such that the octet string PlaintextData has length divisible by 16.

PlaintextData = m
# Padding (not necessary here, because length(m)=16)
PlaintextData = pad(PlaintextData)

print (len(PlaintextData), 'bytes')
printhex (PlaintextData)
16 bytes
00 01 12 00 04 01 01 62 18 C3 0A 55 00 21 01 00

AuthData

From annex A.2.1 of the ZigBee specification:

Form the message AuthData consisting of the octet strings AddAuthData and PlaintextData:

AuthData = AddAuthData || PlaintextData

Note that AuthData is part of the input string of the EAS-CBC algorithm and can be truncated into 16 bytes segments for AES:

AuthData = B1 || B2 || B3 || B4

AuthData = AddAuthData + PlaintextData
print (len(AuthData), 'bytes')
printhex (AuthData)
48 bytes
00 16 48 02 00 00 8A 5C 1E 5D 2D E1 00 00 00 01 3C E8 01 00 8D 15 00 01 00 00 00 00 00 00 00 00 00 01 12 00 04 01 01 62 18 C3 0A 55 00 21 01 00

Flags

The 1-octet flags is created according to section A.2.2 of the ZigBee specification:

Form the 1-octet Flags field consisting of the 1-bit Reserved field, the 1-bit Adata field, and the 3-bit representations of the integers M and L, as follows:

Flags = Reserved || Adata || M || L

Here, the 1-bit Reserved field is reserved for future expansions and shall be set to ‘0’.

Reserved = 0b0

The 1-bit Adata field is set to ‘0’ if l(a)=0, and set to ‘1’ if l(a)>0.

Adata = 0b1 since l(a)=22

The L field is the 3-bit representation of the integer L-1, in most-significant-bit-first order.

Since L=2, L-1=1 => L field = 001

The M field is the 3-bit representation of the integer (M-2)/2 if M>0 and of the integer 0 if M=0, in most-significant-bit-first order

Since M=4, M-2/2=1 => M field = 001

In conclusion:

Flags = 0b01001001 = 0x49

Flags = bytes([0x49]) # = ([0b01001001])
printhex (Flags)
49

B0

B0 is the first 16 bytes input of the AES algorithm. According to section A.2.2 of the ZigBee specification:

Form the 16-octet B0 field consisting of the 1-octet Flags field defined above, the 15-L octet nonce field N, and the L-octet representation of the length field l(m), as follows:

B0 = Flags || Nonce N || l(m)

B0 = Flags + nonce + len(m).to_bytes(2, byteorder = 'big')
print ('Should be 16 bytes =>', len(B0), 'bytes')
printhex (B0)
Should be 16 bytes => 16 bytes
49 01 3C E8 01 00 8D 15 00 E1 00 00 00 2D 00 10

X0

X0 is the initialization vector (IV) of the AES algoritme. According to section A.2.2 of the ZigBee specification, here is the recipe for building X0:

Parse the message AuthData as B1 || B2 || ... ||Bt, where each message block Bi is a 16-octet string. The CBC-MAC value Xt+1 is defined by:

X0: = 0b0 (x128); Xi+1:= E(Key, Xi ⊕ Bi) for i=0, ... , t.

X0 is initialized with:

X0 = 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00

X0 = bytes([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
printhex(X0)
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

Authentication tag T (MIC)

In this section, the message integrity code (MIC) is generated. According to section A.2.2 of the ZigBee specification, the algorithm used for encryption is AES-128-CBC. Instead of processing groups of 16 bytes (B0, B1, B2 ...), we use here the Python Cryptography Toolkit for encryption with the following parameters:

The MIC generated is AC 4C 76 AF which is the expected NWK MIC.

cipher = AES.new(key, AES.MODE_CBC, X0)
X1 = cipher.encrypt(B0 + AuthData)
print ('MIC = ')
printhex (X1[-16:-12])
MIC = 
AC 4C 76 AF

Encryption transformation

The last part of the process is the encryption transformation based on Section A.2.3 of the Zigbee Specification.

The first step is to build the 1-octet Flags:

Form the 1-octet Flags field consisting of two 1-bit Reserved fields, and the 3- bit representations of the integers 0 and L, as follows:

Flags = Reserved || Reserved || 0 || L

Here, the two 1-bit Reserved fields are reserved for future expansions and shall be set to ‘0’. The L field is the 3-bit representation of the integer L-1, in most-significant- bit-first order. The ‘0’ field is the 3-bit representation of the integer 0, in most-significant-bit-first order.

In our case, L=2 => L-1 = 1

Flags = bytes ([0b00000001])
printhex (Flags)
01

Ai

According to section A.2.3 of the ZigBee specification, the 16-octet Ai are built with the following method:

Define the 16-octet Ai field consisting of the 1-octet Flags field defined above, the 15-L octet nonce field N, and the L-octet representation of the integer i, as follows:

Ai = Flags || Nonce N || Counter i, for i=0, 1, 2, …

Note that this definition ensures that all the Ai fields are distinct from the B0 fields that are actually used, as those have a Flags field with a non-zero encoding of M in the positions where all Ai fields have an all-zero encoding of the integer 0 (see section A.2.2, step 1).

Since PlaintextData is a 16-bytes length, we only need A0 and A1.

A0 = Flags + nonce + bytes([0x00, 0x00])
A1 = Flags + nonce + bytes([0x00, 0x01])
printhex (A0)
printhex (A1) 
01 01 3C E8 01 00 8D 15 00 E1 00 00 00 2D 00 00
01 01 3C E8 01 00 8D 15 00 E1 00 00 00 2D 00 01

AES CTR

The algorithm used for encrypting the payload is AES-CTR. Here, we use the Python Cryptography Toolkit that supports AES-CTR. Since PlaintextData is only 16 bytes, we cheat the CTR counter by always returning A1.

According to the raw encrypted frame listened on the network, the expected encrypted data is:

Expected encrypted payload: EA 59 DE 1F 96 0E EA 8A EE 18 5A 11 89 30 96 41

def counter():  
  return A1
cipher = AES.new(key, AES.MODE_CTR, counter = counter)
Ciphertext = cipher.encrypt(PlaintextData)
printhex (Ciphertext)
EA 59 DE 1F 96 0E EA 8A EE 18 5A 11 89 30 96 41

Encrypted authentication tag U

The last part of the encryption is the tag U (encrypted message integrity code):

According to section A.2.3 of the ZigBee specification, the authentification tag is built according to the following:

Define the 16-octet encryption block S0 by:

S0:= E(Key, A0)

The encrypted authentication tag U is the result of XOR-ing the string consisting of the leftmost M octets of S0 and the authentication tag T.

According to the Raw encrypted frame listened on the network, the expected encrypted data is:

Expected encrypted tag U: 4E 05 A2 43

# Encryption: S0:= E(Key, A0)
cipher = AES.new(key)
S0 = cipher.encrypt(A0)

# Perform S0[0:4] XOR T
U = bytes(a ^ b for (a, b) in zip(S0[0:4], T))
printhex (U)
4E 05 A2 43

Download


Last update : 11/17/2022