Skip to content

Commit b672a49

Browse files
committed
Blog post about WPA crypto hardware acceleration
1 parent a0feb33 commit b672a49

File tree

1 file changed

+335
-0
lines changed

1 file changed

+335
-0
lines changed

content/posts/0010-wpa.md

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
+++
2+
authors = ["Jasper Devreker"]
3+
title = "Reverse engineering WPA crypto acceleration on ESP32"
4+
date = "2025-03-07"
5+
description = "Figuring out how the hardware cryptography acceleration works"
6+
+++
7+
8+
This is a more technical blob post about a specific aspect of the ESP32 Wi-Fi hardware; see previous blog posts for a more general overview.
9+
10+
In one of the previous blog posts, we talked about implementing station mode on the ESP32. We then only implemented connecting to open networks, since there is a bunch of extra work that goes into connecting to WPA protected networks. In this blog post, we'll go into what needs to be implemented to connect WPA2 networks, and how the ESP32 hardware plays into that.
11+
12+
# Protected networks: general structure
13+
14+
To use a WPA2 protected network, you basically have to do three things:
15+
basically consists of 3 parts:
16+
17+
- key derivation / 4 way handshake (when connecting)
18+
- encrypting/decrypting packets (for every packet sent/received)
19+
- keeping the keys correct at runtime (every n minu)
20+
21+
# Key derivation
22+
23+
1. The Pairwise Master Key (PMK) is generated from the passphrase and SSID (`PMK = PBKDF2(HMAC−SHA1, passphrase, ssid, 4096, 256)`)
24+
2. The station and AP then do a 4-way handshake to derive and install a Pairwise Temporal Key between the AP and the station. This key encrypts all unicast traffic
25+
3. The 4 way handshake also provides the Group Temporal Key to the client. This key is used to encrypt multicast traffic. They are rekeyed every time a STA joins or leave, or every n minutes, depending on AP settings.
26+
27+
# Hardware acceleration of encrypting/decrypting packets
28+
29+
Every protected packet we send needs to be encrypted, and every protected packet we receive needs to be decrypted. If the ESP32 would need to do this in software, it would cost a lot of CPU time; this is why it is implemented in hardware. Instead of having to encrypt a packet before handing the encrypted result over to the radio hardware, we can just hand the plaintext packet to the hardware and tell it which "crypto key slot" index to use.
30+
31+
The ESP32 has 25 crypto key slots. Every crypto key slot contains the nescessary information to encrypt and decrypt packets:
32+
33+
- the cipher suite (CCMP, GCMP, ...)
34+
- the virtual interface index on which the packet will be decrypted (see also the previous blog post on the RX filter; which is basically the VIF)
35+
- the key ID
36+
- the MAC address of the peer who will send encrypted packets to us
37+
- the key
38+
- (some extra bits relating to protected management frames and Signaling and Payload Protected A-MSDU's)
39+
40+
## Encryption flow
41+
42+
The encryption flow works as follows:
43+
44+
1. Before sending any packets, set up the crypto slot with the correct information
45+
2. To have the hardware send an encrypted packet: prepare the packet as for unencrypted data, but with some exceptions:
46+
- set the 'protected' bit in the frame control field
47+
- after the 802.11 MAC header, but before the data, construct and insert the 8 byte CCMP header (contains the nonce and key id)
48+
- add 8 bytes to the end of the packet: the hardware will overwrite these to insert the MIC (message integrity checksum)
49+
3. Send the packet as normally, but indicate which crypto key slot that the hardware should use in the PLCP1 register
50+
51+
It should be noted that there are a lot of algorithms that the hardware has support for:
52+
53+
```c
54+
enum wpa_alg {
55+
WIFI_WPA_ALG_NONE = 0,
56+
WIFI_WPA_ALG_WEP40 = 1,
57+
WIFI_WPA_ALG_TKIP = 2,
58+
WIFI_WPA_ALG_CCMP = 3,
59+
WIFI_WAPI_ALG_SMS4 = 4,
60+
WIFI_WPA_ALG_WEP104 = 5,
61+
WIFI_WPA_ALG_WEP = 6,
62+
WIFI_WPA_ALG_IGTK = 7,
63+
WIFI_WPA_ALG_PMK = 8,
64+
WIFI_WPA_ALG_GCMP = 9,
65+
};
66+
```
67+
68+
It's neat to see that they have support for SMS4, a Chinese cipher that was suggested for 802.11i, but was ultimately rejected. It lives on in [WAPI](https://en.wikipedia.org/wiki/WLAN_Authentication_and_Privacy_Infrastructure).
69+
70+
71+
## Decryption flow
72+
73+
The decryption flow is transparent for the hardware: once you set up the key slot with the correct interface and MAC address, the hardware will automatically decrypt the packet.
74+
This is done based on the MAC address in the key slot and in the frame. There is a bit in the key slot that indicates whether the keyslot is for unicast or multicast frames.
75+
76+
*Unicast: RA (addr 1) of the encrypted frame is the MAC address of module*
77+
78+
For unicast slots, a packet will decrypt if the MAC address in the key slot matches the TA (address 2) of a frame.
79+
80+
*Multicast: RA (addr 1) of the encrypted frame is a multicast address*
81+
82+
Here, it does not really seem to matter what the address in the key slot is; decryption will happen as long as the RA is multicast and the packet gets through the filters
83+
84+
As a recap:
85+
86+
| hw key idx 0 | To DS | From DS | reception if addr 2 matches BSSID in RX filter | reception if addr 3 matches BSSID in RX filter | notes |
87+
|--------------|-------|---------|--------|--------|---------------------------------------------------------------------------|
88+
| | 0 | 0 | no | yes | |
89+
| | 0 | 1 | yes | no | |
90+
| | 1 | 0 | yes | yes | decryption works even works when addr2 and addr3 both don't match the addr in the keyslot! |
91+
| | 1 | 1 | yes | yes | decryption even works when addr2, addr3 and addr4 both don't match the addr in the keyslot! |
92+
93+
94+
95+
# Testing this
96+
97+
To reverse engineer this part of the hardware, I wrote a Python script that generates example plaintexts and ciphertexts, also called 'test vectors' in the cryptography world. However, this had a bit of a chicken and egg problem: how do we know that our Python implementation to generate test vectors is correct? Luckily for us, there are some public test vectors: the [FreeBSD net80211 regression tests](https://web.mit.edu/freebsd/head/tools/regression/net80211/ccmp/test_ccmp.c) contain 8 CCMP test vectors.
98+
99+
Unfortunately, none of the 8 test vectors contain a test case where there is a '4 address frame' (my term for a packet that has both from-DS and to-DS set in its Frame Control field, and as such has 4 MAC addresses). To back up a bit and explain what a '4 address frame' is: normally, Wi-Fi frames contain 3 MAC addresses. This is enough for a transmitter, receiver and an extra address for either the destination, source or BSSID. This is sufficient for the case where you have access points and stations. However, there is a special case (where a packet is both going to and coming from the distribution network; most often the case in mesh networks) where there are 4 addresses in a 802.11 frame.
100+
101+
This 4th address, if present, is used in the encryption algorithm (more specifically, in calculating the AAD); so is critical to handle this correctly to correctly encrypt/decrypt '4 address frames'. I was worried that the hardware might not do this correctly since:
102+
103+
- it is likely a tiny bit cheaper with regards to amount of gates used to not handle this special case
104+
- Espressif does not send any '4 address frames' as far as I know, let alone encrypted '4 address frames'
105+
- '4 address frames' seem to be pretty uncommon
106+
107+
Using the 802.11 standard, I implemented this special case in the Python test program. This was then validated by generating a packet and decrypting it with Wireshark. I then tested encryption on the ESP32, and to my relief, the hardware *does* handle this correctly. The decryption is also correctly implemented. Good job Espressif!
108+
109+
110+
## Appendix: demonstation code
111+
112+
See https://github.com/esp32-open-mac/esp32-open-mac/pull/24. Note that this still uses the Espressif HAL (`wDev_Insert_KeyEntry`); implementing the HAL ourselves and doing the 4 way handshake will be done in another PR.
113+
114+
## Appendix: Python test vector script:
115+
116+
```python
117+
from scapy.layers.dot11 import Dot11, Dot11QoS
118+
from scapy.all import hexdump, wrpcap, sendp, RadioTap
119+
from Crypto.Cipher import AES
120+
import struct
121+
122+
def bytes_to_c_arr(data, lowercase=True):
123+
return [format(b, '#04x' if lowercase else '#04X') for b in data]
124+
125+
TESTCASE = 11
126+
127+
transmit_if = 'wlan2mon'
128+
129+
# These testcases come from freebsd/head/tools/regression/net80211/ccmp/test_ccmp.c
130+
if TESTCASE == 1:
131+
plain = bytes([
132+
0x08, 0x48, 0xc3, 0x2c, 0x0f, 0xd2, 0xe1, 0x28, # /* 802.11 Header */
133+
0xa5, 0x7c, 0x50, 0x30, 0xf1, 0x84, 0x44, 0x08,
134+
0xab, 0xae, 0xa5, 0xb8, 0xfc, 0xba, 0x80, 0x33,
135+
136+
0xf8, 0xba, 0x1a, 0x55, 0xd0, 0x2f, 0x85, 0xae, # /* Plaintext Data */
137+
0x96, 0x7b, 0xb6, 0x2f, 0xb6, 0xcd, 0xa8, 0xeb,
138+
0x7e, 0x78, 0xa0, 0x50,
139+
])
140+
pn = 0xB5039776E70C
141+
key = bytes.fromhex("c9 7c 1f 67 ce 37 11 85 51 4a 8a 19 f2 bd d5 2f")
142+
key_id = 0
143+
expected = bytes([
144+
0x08, 0x48, 0xc3, 0x2c, 0x0f, 0xd2, 0xe1, 0x28,
145+
0xa5, 0x7c, 0x50, 0x30, 0xf1, 0x84, 0x44, 0x08,
146+
0xab, 0xae, 0xa5, 0xb8, 0xfc, 0xba, 0x80, 0x33,
147+
0x0c, 0xe7, 0x00, 0x20, 0x76, 0x97, 0x03, 0xb5,
148+
0xf3, 0xd0, 0xa2, 0xfe, 0x9a, 0x3d, 0xbf, 0x23,
149+
0x42, 0xa6, 0x43, 0xe4, 0x32, 0x46, 0xe8, 0x0c,
150+
0x3c, 0x04, 0xd0, 0x19, 0x78, 0x45, 0xce, 0x0b,
151+
0x16, 0xf9, 0x76, 0x23
152+
])
153+
elif TESTCASE == 4:
154+
plain = bytes([
155+
0xa8, 0xca, 0x3a, 0x11, 0x71, 0x2a, 0x9d, 0xdf, 0x11, 0xdb,
156+
0x8e, 0xf8, 0x22, 0x73, 0x47, 0x01, 0x59, 0x14, 0x0d, 0xd6,
157+
0x46, 0xa2, 0xc0, 0x2f, 0x67, 0xa5,
158+
0x4f, 0xad, 0x2b, 0x1c, 0x29, 0x0f, 0xa5, 0xeb, 0xd8, 0x72,
159+
0xfb, 0xc3, 0xf3, 0xa0, 0x74, 0x89, 0x8f, 0x8b, 0x2f, 0xbb,
160+
])
161+
pn = 0xF670A55A0FE3
162+
key = bytes.fromhex('8c 89 a2 eb c9 6c 76 02 70 7f cf 24 b3 2d 38 33')
163+
key_id = 0
164+
expected = bytes([
165+
0xa8, 0xca, 0x3a, 0x11, 0x71, 0x2a, 0x9d, 0xdf, 0x11, 0xdb,
166+
0x8e, 0xf8, 0x22, 0x73, 0x47, 0x01, 0x59, 0x14, 0x0d, 0xd6,
167+
0x46, 0xa2, 0xc0, 0x2f, 0x67, 0xa5, 0xe3, 0x0f, 0x00, 0x20,
168+
0x5a, 0xa5, 0x70, 0xf6, 0x9d, 0x59, 0xb1, 0x5f, 0x37, 0x14,
169+
0x48, 0xc2, 0x30, 0xf4, 0xd7, 0x39, 0x05, 0x2e, 0x13, 0xab,
170+
0x3b, 0x1a, 0x7b, 0x10, 0x31, 0xfc, 0x88, 0x00, 0x4f, 0x35,
171+
0xee, 0x3d,
172+
])
173+
elif TESTCASE == 7:
174+
plain = bytes([
175+
0x18, 0x79, 0x81, 0x46, 0x9b, 0x50, 0xf4, 0xfd, 0x56, 0xf6,
176+
0xef, 0xec, 0x95, 0x20, 0x16, 0x91, 0x83, 0x57, 0x0c, 0x4c,
177+
0xcd, 0xee, 0x20, 0xa0,
178+
0x98, 0xbe, 0xca, 0x86, 0xf4, 0xb3, 0x8d, 0xa2, 0x0c, 0xfd,
179+
0xf2, 0x47, 0x24, 0xc5, 0x8e, 0xb8, 0x35, 0x66, 0x53, 0x39,
180+
])
181+
pn = 0x5EEC4073E723
182+
key = bytes.fromhex('1b db 34 98 0e 03 81 24 a1 db 1a 89 2b ec 36 6a')
183+
key_id = 3
184+
expected = bytes([
185+
0x18, 0x79, 0x81, 0x46, 0x9b, 0x50, 0xf4, 0xfd, 0x56, 0xf6,
186+
0xef, 0xec, 0x95, 0x20, 0x16, 0x91, 0x83, 0x57, 0x0c, 0x4c,
187+
0xcd, 0xee, 0x20, 0xa0, 0x23, 0xe7, 0x00, 0xe0, 0x73, 0x40,
188+
0xec, 0x5e, 0x12, 0xc5, 0x37, 0xeb, 0xf3, 0xab, 0x58, 0x4e,
189+
0xf1, 0xfe, 0xf9, 0xa1, 0xf3, 0x54, 0x7a, 0x8c, 0x13, 0xb3,
190+
0x22, 0x5a, 0x2d, 0x09, 0x57, 0xec, 0xfa, 0xbe, 0x95, 0xb9,
191+
])
192+
elif TESTCASE == 10: # custom testcase for 4 address mode
193+
plain = bytes([
194+
0x08, 0x48, 0xc3, 0x2c, 0x0f, 0xd2, 0xe1, 0x28, # /* 802.11 Header */
195+
0xa5, 0x7c, 0x50, 0x30, 0xf1, 0x84, 0x44, 0x08,
196+
0xab, 0xae, 0xa5, 0xb8, 0xfc, 0xba, 0x80, 0x33,
197+
0xf8, 0xba, 0x1a, 0x55, 0xd0, 0x2f, 0x85, 0xae, # /* Plaintext Data */
198+
0x96, 0x7b, 0xb6, 0x2f, 0xb6, 0xcd, 0xa8, 0xeb,
199+
0x7e, 0x78, 0xa0, 0x50,
200+
])
201+
pn = 0xB5039776E70C
202+
key = bytes.fromhex("c9 7c 1f 67 ce 37 11 85 51 4a 8a 19 f2 bd d5 2f")
203+
key_id = 0
204+
parsed = Dot11(plain)
205+
setattr(parsed.FCfield, 'to-DS', True)
206+
setattr(parsed.FCfield, 'from-DS', True)
207+
parsed.addr4 = '00:23:45:67:89:ab'
208+
plain = parsed.build()
209+
expected = None
210+
elif TESTCASE == 11:
211+
# encrypt with custom mac addr
212+
plain = bytes([
213+
0x08, 0x48, 0xc3, 0x2c, 0x0f, 0xd2, 0xe1, 0x28, # /* 802.11 Header */
214+
0xa5, 0x7c, 0x50, 0x30, 0xf1, 0x84, 0x44, 0x08,
215+
0xab, 0xae, 0xa5, 0xb8, 0xfc, 0xba, 0x80, 0x33,
216+
0xf8, 0xba, 0x1a, 0x55, 0xd0, 0x2f, 0x85, 0xae, # /* Plaintext Data */
217+
0x96, 0x7b, 0xb6, 0x2f, 0xb6, 0xcd, 0xa8, 0xeb,
218+
0x7e, 0x78, 0xa0, 0x50,
219+
])
220+
pn = 0
221+
key = bytes.fromhex("c9 7c 1f 67 ce 37 11 85 51 4a 8a 19 f2 bd d5 2f")
222+
key_id = 0
223+
parsed = Dot11(plain)
224+
225+
broadcast = 'ff:ff:ff:ff:ff:ff'
226+
ra = '00:23:45:67:89:ab'
227+
bssid = 'f0:ae:a5:b8:fc:ba'
228+
unrelated = 'ae:25:aa:63:1c:8e'
229+
230+
setattr(parsed.FCfield, 'from-DS', False)
231+
setattr(parsed.FCfield, 'to-DS', True)
232+
233+
parsed.addr1 = broadcast
234+
parsed.addr2 = unrelated
235+
parsed.addr3 = unrelated
236+
# parsed.addr4 = unrelated
237+
# parsed.addr4 = bssid
238+
parsed.FCfield.retry = False
239+
240+
plain = parsed.build()
241+
expected = None
242+
else:
243+
assert False, 'Testcase not found'
244+
245+
dot11 = Dot11(plain)
246+
print(dot11)
247+
# Sanity check on scapy library
248+
assert (dot11.build() == plain)
249+
assert dot11.proto == 0, "Only PV0 supported, no 802.11ah"
250+
251+
252+
dot11_copy = dot11.copy()
253+
mac_1 = bytes.fromhex(dot11.addr1.replace(':', ''))
254+
mac_2 = bytes.fromhex(dot11.addr2.replace(':', ''))
255+
mac_3 = bytes.fromhex(dot11.addr3.replace(':', ''))
256+
mac_4 = None if dot11.addr4 is None else bytes.fromhex(dot11.addr4.replace(':', ''))
257+
258+
plaintext_data = dot11.payload.build()
259+
260+
print("Original packet:")
261+
hexdump(dot11)
262+
263+
# mask out the bits that should be masked out
264+
dot11.subtype &= 0b1000
265+
dot11.FCfield.retry = False
266+
setattr(dot11.FCfield, 'pw-mgt', False)
267+
dot11.FCfield.MD = False
268+
dot11.FCfield.protected = 1
269+
270+
assert Dot11QoS not in dot11, "QoS not implemented; see page 2493 (CCMP AAD) of 80211-2020.pdf"
271+
272+
frame = dot11.build()
273+
274+
# this bytes.fromhex("00 00") assumes the fragment number is 0
275+
assert dot11.SC & 0b1111 == 0, "Fragment number != 0 not implemented"
276+
aad = frame[:2] + mac_1 + mac_2 + mac_3 + bytes.fromhex("00 00") + (bytes() if mac_4 is None else mac_4)
277+
278+
print('AAD:')
279+
hexdump(aad)
280+
281+
pn_packed = struct.pack("<Q", pn)[:6]
282+
283+
print("Packed PN:")
284+
hexdump(pn_packed)
285+
286+
ccmp_header = bytes([
287+
pn_packed[0],
288+
pn_packed[1],
289+
0, # reserved
290+
(1<<5 | key_id << 6),
291+
pn_packed[2],
292+
pn_packed[3],
293+
pn_packed[4],
294+
pn_packed[5],
295+
])
296+
297+
print("CCMP header:")
298+
hexdump(ccmp_header)
299+
300+
# bytes.fromhex("00") assumes [Priority Management PV1 Zeros] are all zero
301+
nonce = bytes.fromhex("00") + mac_2 + struct.pack(">Q", pn)[-6:]
302+
303+
print("CCM nonce")
304+
hexdump(nonce)
305+
306+
cipher = AES.new(key, AES.MODE_CCM, nonce, mac_len=8)
307+
cipher.update(aad)
308+
msg = nonce, aad, cipher.encrypt(plaintext_data), cipher.digest()
309+
310+
311+
calculated = dot11_copy.build()[:(24 if dot11.addr4 is None else 24+6)] + ccmp_header + msg[2] + msg[3]
312+
313+
print("on ESP32:")
314+
print(', '.join(bytes_to_c_arr(dot11_copy.build()[:(24 if dot11.addr4 is None else 24+6)] + ccmp_header + plaintext_data + bytes([0]*8))))
315+
316+
317+
if transmit_if is not None:
318+
dot11 = Dot11(calculated)
319+
dot11.FCfield.protected = True
320+
sendp(RadioTap() / dot11, iface=transmit_if)
321+
322+
if expected is None:
323+
# wrpcap("out.pcap", [Dot11(calculated)])
324+
pass
325+
else:
326+
if calculated == expected:
327+
print("Calculated frame matches expectation")
328+
hexdump(calculated)
329+
else:
330+
print("Calculated does not match expected")
331+
print("Calculated:")
332+
hexdump(calculated)
333+
print("Expected")
334+
hexdump(expected)
335+
```

0 commit comments

Comments
 (0)