Skip to content

Commit bcf20e5

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

File tree

1 file changed

+315
-0
lines changed

1 file changed

+315
-0
lines changed

content/posts/0010-wpa.md

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
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+
## Decryption flow
52+
53+
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.
54+
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.
55+
56+
*Unicast: RA (addr 1) of the encrypted frame is the MAC address of module*
57+
58+
For unicast slots, a packet will decrypt if the MAC address in the key slot matches the TA (address 2) of a frame.
59+
60+
*Multicast: RA (addr 1) of the encrypted frame is a multicast address*
61+
62+
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
63+
64+
As a recap:
65+
66+
| 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 |
67+
|--------------|-------|---------|--------|--------|---------------------------------------------------------------------------|
68+
| | 0 | 0 | no | yes | |
69+
| | 0 | 1 | yes | no | |
70+
| | 1 | 0 | yes | yes | decryption works even works when addr2 and addr3 both don't match the addr in the keyslot! |
71+
| | 1 | 1 | yes | yes | decryption even works when addr2, addr3 and addr4 both don't match the addr in the keyslot! |
72+
73+
74+
75+
# Testing this
76+
77+
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.
78+
79+
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.
80+
81+
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:
82+
83+
- it is likely a tiny bit cheaper with regards to amount of gates used to not handle this special case
84+
- Espressif does not send any '4 address frames' as far as I know, let alone encrypted '4 address frames'
85+
- '4 address frames' seem to be pretty uncommon
86+
87+
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!
88+
89+
90+
## Appendix: demonstation code
91+
92+
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.
93+
94+
## Appendix: Python test vector script:
95+
96+
```python
97+
from scapy.layers.dot11 import Dot11, Dot11QoS
98+
from scapy.all import hexdump, wrpcap, sendp, RadioTap
99+
from Crypto.Cipher import AES
100+
import struct
101+
102+
def bytes_to_c_arr(data, lowercase=True):
103+
return [format(b, '#04x' if lowercase else '#04X') for b in data]
104+
105+
TESTCASE = 11
106+
107+
transmit_if = 'wlan2mon'
108+
109+
# These testcases come from freebsd/head/tools/regression/net80211/ccmp/test_ccmp.c
110+
if TESTCASE == 1:
111+
plain = bytes([
112+
0x08, 0x48, 0xc3, 0x2c, 0x0f, 0xd2, 0xe1, 0x28, # /* 802.11 Header */
113+
0xa5, 0x7c, 0x50, 0x30, 0xf1, 0x84, 0x44, 0x08,
114+
0xab, 0xae, 0xa5, 0xb8, 0xfc, 0xba, 0x80, 0x33,
115+
116+
0xf8, 0xba, 0x1a, 0x55, 0xd0, 0x2f, 0x85, 0xae, # /* Plaintext Data */
117+
0x96, 0x7b, 0xb6, 0x2f, 0xb6, 0xcd, 0xa8, 0xeb,
118+
0x7e, 0x78, 0xa0, 0x50,
119+
])
120+
pn = 0xB5039776E70C
121+
key = bytes.fromhex("c9 7c 1f 67 ce 37 11 85 51 4a 8a 19 f2 bd d5 2f")
122+
key_id = 0
123+
expected = bytes([
124+
0x08, 0x48, 0xc3, 0x2c, 0x0f, 0xd2, 0xe1, 0x28,
125+
0xa5, 0x7c, 0x50, 0x30, 0xf1, 0x84, 0x44, 0x08,
126+
0xab, 0xae, 0xa5, 0xb8, 0xfc, 0xba, 0x80, 0x33,
127+
0x0c, 0xe7, 0x00, 0x20, 0x76, 0x97, 0x03, 0xb5,
128+
0xf3, 0xd0, 0xa2, 0xfe, 0x9a, 0x3d, 0xbf, 0x23,
129+
0x42, 0xa6, 0x43, 0xe4, 0x32, 0x46, 0xe8, 0x0c,
130+
0x3c, 0x04, 0xd0, 0x19, 0x78, 0x45, 0xce, 0x0b,
131+
0x16, 0xf9, 0x76, 0x23
132+
])
133+
elif TESTCASE == 4:
134+
plain = bytes([
135+
0xa8, 0xca, 0x3a, 0x11, 0x71, 0x2a, 0x9d, 0xdf, 0x11, 0xdb,
136+
0x8e, 0xf8, 0x22, 0x73, 0x47, 0x01, 0x59, 0x14, 0x0d, 0xd6,
137+
0x46, 0xa2, 0xc0, 0x2f, 0x67, 0xa5,
138+
0x4f, 0xad, 0x2b, 0x1c, 0x29, 0x0f, 0xa5, 0xeb, 0xd8, 0x72,
139+
0xfb, 0xc3, 0xf3, 0xa0, 0x74, 0x89, 0x8f, 0x8b, 0x2f, 0xbb,
140+
])
141+
pn = 0xF670A55A0FE3
142+
key = bytes.fromhex('8c 89 a2 eb c9 6c 76 02 70 7f cf 24 b3 2d 38 33')
143+
key_id = 0
144+
expected = bytes([
145+
0xa8, 0xca, 0x3a, 0x11, 0x71, 0x2a, 0x9d, 0xdf, 0x11, 0xdb,
146+
0x8e, 0xf8, 0x22, 0x73, 0x47, 0x01, 0x59, 0x14, 0x0d, 0xd6,
147+
0x46, 0xa2, 0xc0, 0x2f, 0x67, 0xa5, 0xe3, 0x0f, 0x00, 0x20,
148+
0x5a, 0xa5, 0x70, 0xf6, 0x9d, 0x59, 0xb1, 0x5f, 0x37, 0x14,
149+
0x48, 0xc2, 0x30, 0xf4, 0xd7, 0x39, 0x05, 0x2e, 0x13, 0xab,
150+
0x3b, 0x1a, 0x7b, 0x10, 0x31, 0xfc, 0x88, 0x00, 0x4f, 0x35,
151+
0xee, 0x3d,
152+
])
153+
elif TESTCASE == 7:
154+
plain = bytes([
155+
0x18, 0x79, 0x81, 0x46, 0x9b, 0x50, 0xf4, 0xfd, 0x56, 0xf6,
156+
0xef, 0xec, 0x95, 0x20, 0x16, 0x91, 0x83, 0x57, 0x0c, 0x4c,
157+
0xcd, 0xee, 0x20, 0xa0,
158+
0x98, 0xbe, 0xca, 0x86, 0xf4, 0xb3, 0x8d, 0xa2, 0x0c, 0xfd,
159+
0xf2, 0x47, 0x24, 0xc5, 0x8e, 0xb8, 0x35, 0x66, 0x53, 0x39,
160+
])
161+
pn = 0x5EEC4073E723
162+
key = bytes.fromhex('1b db 34 98 0e 03 81 24 a1 db 1a 89 2b ec 36 6a')
163+
key_id = 3
164+
expected = bytes([
165+
0x18, 0x79, 0x81, 0x46, 0x9b, 0x50, 0xf4, 0xfd, 0x56, 0xf6,
166+
0xef, 0xec, 0x95, 0x20, 0x16, 0x91, 0x83, 0x57, 0x0c, 0x4c,
167+
0xcd, 0xee, 0x20, 0xa0, 0x23, 0xe7, 0x00, 0xe0, 0x73, 0x40,
168+
0xec, 0x5e, 0x12, 0xc5, 0x37, 0xeb, 0xf3, 0xab, 0x58, 0x4e,
169+
0xf1, 0xfe, 0xf9, 0xa1, 0xf3, 0x54, 0x7a, 0x8c, 0x13, 0xb3,
170+
0x22, 0x5a, 0x2d, 0x09, 0x57, 0xec, 0xfa, 0xbe, 0x95, 0xb9,
171+
])
172+
elif TESTCASE == 10: # custom testcase for 4 address mode
173+
plain = bytes([
174+
0x08, 0x48, 0xc3, 0x2c, 0x0f, 0xd2, 0xe1, 0x28, # /* 802.11 Header */
175+
0xa5, 0x7c, 0x50, 0x30, 0xf1, 0x84, 0x44, 0x08,
176+
0xab, 0xae, 0xa5, 0xb8, 0xfc, 0xba, 0x80, 0x33,
177+
0xf8, 0xba, 0x1a, 0x55, 0xd0, 0x2f, 0x85, 0xae, # /* Plaintext Data */
178+
0x96, 0x7b, 0xb6, 0x2f, 0xb6, 0xcd, 0xa8, 0xeb,
179+
0x7e, 0x78, 0xa0, 0x50,
180+
])
181+
pn = 0xB5039776E70C
182+
key = bytes.fromhex("c9 7c 1f 67 ce 37 11 85 51 4a 8a 19 f2 bd d5 2f")
183+
key_id = 0
184+
parsed = Dot11(plain)
185+
setattr(parsed.FCfield, 'to-DS', True)
186+
setattr(parsed.FCfield, 'from-DS', True)
187+
parsed.addr4 = '00:23:45:67:89:ab'
188+
plain = parsed.build()
189+
expected = None
190+
elif TESTCASE == 11:
191+
# encrypt with custom mac addr
192+
plain = bytes([
193+
0x08, 0x48, 0xc3, 0x2c, 0x0f, 0xd2, 0xe1, 0x28, # /* 802.11 Header */
194+
0xa5, 0x7c, 0x50, 0x30, 0xf1, 0x84, 0x44, 0x08,
195+
0xab, 0xae, 0xa5, 0xb8, 0xfc, 0xba, 0x80, 0x33,
196+
0xf8, 0xba, 0x1a, 0x55, 0xd0, 0x2f, 0x85, 0xae, # /* Plaintext Data */
197+
0x96, 0x7b, 0xb6, 0x2f, 0xb6, 0xcd, 0xa8, 0xeb,
198+
0x7e, 0x78, 0xa0, 0x50,
199+
])
200+
pn = 0
201+
key = bytes.fromhex("c9 7c 1f 67 ce 37 11 85 51 4a 8a 19 f2 bd d5 2f")
202+
key_id = 0
203+
parsed = Dot11(plain)
204+
205+
broadcast = 'ff:ff:ff:ff:ff:ff'
206+
ra = '00:23:45:67:89:ab'
207+
bssid = 'f0:ae:a5:b8:fc:ba'
208+
unrelated = 'ae:25:aa:63:1c:8e'
209+
210+
setattr(parsed.FCfield, 'from-DS', False)
211+
setattr(parsed.FCfield, 'to-DS', True)
212+
213+
parsed.addr1 = broadcast
214+
parsed.addr2 = unrelated
215+
parsed.addr3 = unrelated
216+
# parsed.addr4 = unrelated
217+
# parsed.addr4 = bssid
218+
parsed.FCfield.retry = False
219+
220+
plain = parsed.build()
221+
expected = None
222+
else:
223+
assert False, 'Testcase not found'
224+
225+
dot11 = Dot11(plain)
226+
print(dot11)
227+
# Sanity check on scapy library
228+
assert (dot11.build() == plain)
229+
assert dot11.proto == 0, "Only PV0 supported, no 802.11ah"
230+
231+
232+
dot11_copy = dot11.copy()
233+
mac_1 = bytes.fromhex(dot11.addr1.replace(':', ''))
234+
mac_2 = bytes.fromhex(dot11.addr2.replace(':', ''))
235+
mac_3 = bytes.fromhex(dot11.addr3.replace(':', ''))
236+
mac_4 = None if dot11.addr4 is None else bytes.fromhex(dot11.addr4.replace(':', ''))
237+
238+
plaintext_data = dot11.payload.build()
239+
240+
print("Original packet:")
241+
hexdump(dot11)
242+
243+
# mask out the bits that should be masked out
244+
dot11.subtype &= 0b1000
245+
dot11.FCfield.retry = False
246+
setattr(dot11.FCfield, 'pw-mgt', False)
247+
dot11.FCfield.MD = False
248+
dot11.FCfield.protected = 1
249+
250+
assert Dot11QoS not in dot11, "QoS not implemented; see page 2493 (CCMP AAD) of 80211-2020.pdf"
251+
252+
frame = dot11.build()
253+
254+
# this bytes.fromhex("00 00") assumes the fragment number is 0
255+
assert dot11.SC & 0b1111 == 0, "Fragment number != 0 not implemented"
256+
aad = frame[:2] + mac_1 + mac_2 + mac_3 + bytes.fromhex("00 00") + (bytes() if mac_4 is None else mac_4)
257+
258+
print('AAD:')
259+
hexdump(aad)
260+
261+
pn_packed = struct.pack("<Q", pn)[:6]
262+
263+
print("Packed PN:")
264+
hexdump(pn_packed)
265+
266+
ccmp_header = bytes([
267+
pn_packed[0],
268+
pn_packed[1],
269+
0, # reserved
270+
(1<<5 | key_id << 6),
271+
pn_packed[2],
272+
pn_packed[3],
273+
pn_packed[4],
274+
pn_packed[5],
275+
])
276+
277+
print("CCMP header:")
278+
hexdump(ccmp_header)
279+
280+
# bytes.fromhex("00") assumes [Priority Management PV1 Zeros] are all zero
281+
nonce = bytes.fromhex("00") + mac_2 + struct.pack(">Q", pn)[-6:]
282+
283+
print("CCM nonce")
284+
hexdump(nonce)
285+
286+
cipher = AES.new(key, AES.MODE_CCM, nonce, mac_len=8)
287+
cipher.update(aad)
288+
msg = nonce, aad, cipher.encrypt(plaintext_data), cipher.digest()
289+
290+
291+
calculated = dot11_copy.build()[:(24 if dot11.addr4 is None else 24+6)] + ccmp_header + msg[2] + msg[3]
292+
293+
print("on ESP32:")
294+
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))))
295+
296+
297+
if transmit_if is not None:
298+
dot11 = Dot11(calculated)
299+
dot11.FCfield.protected = True
300+
sendp(RadioTap() / dot11, iface=transmit_if)
301+
302+
if expected is None:
303+
# wrpcap("out.pcap", [Dot11(calculated)])
304+
pass
305+
else:
306+
if calculated == expected:
307+
print("Calculated frame matches expectation")
308+
hexdump(calculated)
309+
else:
310+
print("Calculated does not match expected")
311+
print("Calculated:")
312+
hexdump(calculated)
313+
print("Expected")
314+
hexdump(expected)
315+
```

0 commit comments

Comments
 (0)