6
6
# Modified since 2015-09-18 from Pascal Gollor (https://github.com/pgollor)
7
7
# Modified since 2015-11-09 from Hristo Gochkov (https://github.com/me-no-dev)
8
8
# Modified since 2016-01-03 from Matthew O'Gorman (https://githumb.com/mogorman)
9
+ # Modified since 2025-09-04 from Lucas Saavedra Vaz (https://github.com/lucasssvaz)
9
10
#
10
11
# This script will push an OTA update to the ESP
11
12
# use it like:
36
37
# - Incorporated exception handling to catch and handle potential errors.
37
38
# - Made variable names more descriptive for better readability.
38
39
# - Introduced constants for better code maintainability.
40
+ #
41
+ # Changes
42
+ # 2025-09-04:
43
+ # - Changed authentication to use PBKDF2-HMAC-SHA256 for challenge/response
44
+ #
45
+ # Changes
46
+ # 2025-09-18:
47
+ # - Fixed authentication when using old images with MD5 passwords
48
+ #
49
+ # Changes
50
+ # 2025-10-07:
51
+ # - Fixed authentication when images might use old MD5 hashes stored in the firmware
52
+
39
53
40
54
from __future__ import print_function
41
55
import socket
@@ -81,7 +95,7 @@ def update_progress(progress):
81
95
sys .stderr .flush ()
82
96
83
97
84
- def send_invitation_and_get_auth_challenge (remote_addr , remote_port , message , md5_target ):
98
+ def send_invitation_and_get_auth_challenge (remote_addr , remote_port , message ):
85
99
"""
86
100
Send invitation to ESP device and get authentication challenge.
87
101
Returns (success, auth_data, error_message) tuple.
@@ -107,10 +121,9 @@ def send_invitation_and_get_auth_challenge(remote_addr, remote_port, message, md
107
121
108
122
sock2 .settimeout (TIMEOUT )
109
123
try :
110
- if md5_target :
111
- data = sock2 .recv (37 ).decode () # "AUTH " + 32-char MD5 nonce
112
- else :
113
- data = sock2 .recv (69 ).decode () # "AUTH " + 64-char SHA256 nonce
124
+ # Try to read up to 69 bytes for new protocol (SHA256)
125
+ # If device sends less (37 bytes), it's using old MD5 protocol
126
+ data = sock2 .recv (69 ).decode ()
114
127
sock2 .close ()
115
128
break
116
129
except : # noqa: E722
@@ -127,34 +140,49 @@ def send_invitation_and_get_auth_challenge(remote_addr, remote_port, message, md
127
140
return True , data , None
128
141
129
142
130
- def authenticate (remote_addr , remote_port , password , md5_target , filename , content_size , file_md5 , nonce ):
143
+ def authenticate (
144
+ remote_addr , remote_port , password , use_md5_password , use_old_protocol , filename , content_size , file_md5 , nonce
145
+ ):
131
146
"""
132
- Perform authentication with the ESP device using either MD5 or SHA256 method.
147
+ Perform authentication with the ESP device.
148
+
149
+ Args:
150
+ use_md5_password: If True, hash password with MD5 instead of SHA256
151
+ use_old_protocol: If True, use old MD5 challenge/response protocol (pre-3.3.1)
152
+
133
153
Returns (success, error_message) tuple.
134
154
"""
135
155
cnonce_text = "%s%u%s%s" % (filename , content_size , file_md5 , remote_addr )
136
156
remote_address = (remote_addr , int (remote_port ))
137
157
138
- if md5_target :
158
+ if use_old_protocol :
139
159
# Generate client nonce (cnonce)
140
160
cnonce = hashlib .md5 (cnonce_text .encode ()).hexdigest ()
141
161
142
- # MD5 challenge/response protocol (insecure, use only for compatibility with old firmwares )
143
- # 1. Hash the password with MD5 (to match ESP32 storage)
162
+ # Old MD5 challenge/response protocol (pre-3.3.1 )
163
+ # 1. Hash the password with MD5
144
164
password_hash = hashlib .md5 (password .encode ()).hexdigest ()
145
165
146
166
# 2. Create challenge response
147
167
challenge = "%s:%s:%s" % (password_hash , nonce , cnonce )
148
168
response = hashlib .md5 (challenge .encode ()).hexdigest ()
149
169
expected_response_length = 32
150
170
else :
151
- # Generate client nonce (cnonce)
171
+ # Generate client nonce (cnonce) using SHA256 for new protocol
152
172
cnonce = hashlib .sha256 (cnonce_text .encode ()).hexdigest ()
153
173
154
- # PBKDF2-HMAC-SHA256 challenge/response protocol
155
- # The ESP32 stores the password as SHA256 hash, so we need to hash the password first
156
- # 1. Hash the password with SHA256 (to match ESP32 storage)
157
- password_hash = hashlib .sha256 (password .encode ()).hexdigest ()
174
+ # New PBKDF2-HMAC-SHA256 challenge/response protocol (3.3.1+)
175
+ # The password can be hashed with either MD5 or SHA256
176
+ if use_md5_password :
177
+ # Use MD5 for password hash (for devices that stored MD5 hashes)
178
+ logging .warning (
179
+ "Using insecure MD5 hash for password due to legacy device support. "
180
+ "Please upgrade devices to ESP32 Arduino Core 3.3.1+ for improved security."
181
+ )
182
+ password_hash = hashlib .md5 (password .encode ()).hexdigest ()
183
+ else :
184
+ # Use SHA256 for password hash (recommended)
185
+ password_hash = hashlib .sha256 (password .encode ()).hexdigest ()
158
186
159
187
# 2. Derive key using PBKDF2-HMAC-SHA256 with the password hash
160
188
salt = nonce + ":" + cnonce
@@ -189,9 +217,9 @@ def authenticate(remote_addr, remote_port, password, md5_target, filename, conte
189
217
return False , str (e )
190
218
191
219
192
- def serve (
220
+ def serve ( # noqa: C901
193
221
remote_addr , local_addr , remote_port , local_port , password , md5_target , filename , command = FLASH
194
- ): # noqa: C901
222
+ ):
195
223
# Create a TCP/IP socket
196
224
sock = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
197
225
server_address = (local_addr , local_port )
@@ -210,58 +238,138 @@ def serve(
210
238
message = "%d %d %d %s\n " % (command , local_port , content_size , file_md5 )
211
239
212
240
# Send invitation and get authentication challenge
213
- success , data , error = send_invitation_and_get_auth_challenge (remote_addr , remote_port , message , md5_target )
241
+ success , data , error = send_invitation_and_get_auth_challenge (remote_addr , remote_port , message )
214
242
if not success :
215
243
logging .error (error )
216
244
return 1
217
245
218
246
if data != "OK" :
219
247
if data .startswith ("AUTH" ):
220
248
nonce = data .split ()[1 ]
249
+ nonce_length = len (nonce )
221
250
222
- # Try authentication with the specified method first
223
- sys .stderr .write ("Authenticating..." )
224
- sys .stderr .flush ()
225
- auth_success , auth_error = authenticate (
226
- remote_addr , remote_port , password , md5_target , filename , content_size , file_md5 , nonce
227
- )
251
+ # Detect protocol version based on nonce length:
252
+ # - 32 chars = Old MD5 protocol (pre-3.3.1)
253
+ # - 64 chars = New SHA256 protocol (3.3.1+)
254
+
255
+ if nonce_length == 32 :
256
+ # Scenario 1: Old device (pre-3.3.1) using MD5 protocol
257
+ logging .info ("Detected old MD5 protocol (pre-3.3.1)" )
258
+ sys .stderr .write ("Authenticating (MD5 protocol)..." )
259
+ sys .stderr .flush ()
260
+ auth_success , auth_error = authenticate (
261
+ remote_addr ,
262
+ remote_port ,
263
+ password ,
264
+ use_md5_password = True ,
265
+ use_old_protocol = True ,
266
+ filename = filename ,
267
+ content_size = content_size ,
268
+ file_md5 = file_md5 ,
269
+ nonce = nonce ,
270
+ )
228
271
229
- if not auth_success :
230
- # If authentication failed and we're not already using MD5, try with MD5
231
- if not md5_target :
272
+ if not auth_success :
232
273
sys .stderr .write ("FAIL\n " )
233
- logging .warning ("Authentication failed with SHA256, retrying with MD5: %s" , auth_error )
274
+ logging .error ("Authentication Failed: %s" , auth_error )
275
+ return 1
234
276
235
- # Restart the entire process with MD5 to get a fresh nonce
236
- success , data , error = send_invitation_and_get_auth_challenge (
237
- remote_addr , remote_port , message , True
277
+ sys .stderr .write ("OK\n " )
278
+ logging .warning ("====================================================================" )
279
+ logging .warning ("WARNING: Device is using old MD5 authentication protocol (pre-3.3.1)" )
280
+ logging .warning ("Please update to ESP32 Arduino Core 3.3.1+ for improved security." )
281
+ logging .warning ("======================================================================" )
282
+
283
+ elif nonce_length == 64 :
284
+ # New protocol (3.3.1+) - try SHA256 password first, then MD5 if it fails
285
+
286
+ # Scenario 2: Try SHA256 password hash first (recommended for new devices)
287
+ if md5_target :
288
+ # User explicitly requested MD5 password hash
289
+ logging .info ("Using MD5 password hash as requested" )
290
+ sys .stderr .write ("Authenticating (SHA256 protocol with MD5 password)..." )
291
+ sys .stderr .flush ()
292
+ auth_success , auth_error = authenticate (
293
+ remote_addr ,
294
+ remote_port ,
295
+ password ,
296
+ use_md5_password = True ,
297
+ use_old_protocol = False ,
298
+ filename = filename ,
299
+ content_size = content_size ,
300
+ file_md5 = file_md5 ,
301
+ nonce = nonce ,
302
+ )
303
+ else :
304
+ # Try SHA256 password hash first
305
+ sys .stderr .write ("Authenticating..." )
306
+ sys .stderr .flush ()
307
+ auth_success , auth_error = authenticate (
308
+ remote_addr ,
309
+ remote_port ,
310
+ password ,
311
+ use_md5_password = False ,
312
+ use_old_protocol = False ,
313
+ filename = filename ,
314
+ content_size = content_size ,
315
+ file_md5 = file_md5 ,
316
+ nonce = nonce ,
238
317
)
239
- if not success :
240
- logging .error ("Failed to re-establish connection for MD5 retry: %s" , error )
241
- return 1
242
318
243
- if data .startswith ("AUTH" ):
244
- nonce = data .split ()[1 ]
245
- sys .stderr .write ("Retrying with MD5..." )
319
+ # Scenario 3: If SHA256 fails, try MD5 password hash (for devices with stored MD5 passwords)
320
+ if not auth_success :
321
+ logging .info ("SHA256 password failed, trying MD5 password hash" )
322
+ sys .stderr .write ("Retrying with MD5 password..." )
246
323
sys .stderr .flush ()
324
+
325
+ # Device is back in OTA_IDLE after auth failure, need to send new invitation
326
+ success , data , error = send_invitation_and_get_auth_challenge (remote_addr , remote_port , message )
327
+ if not success :
328
+ sys .stderr .write ("FAIL\n " )
329
+ logging .error ("Failed to get new challenge for MD5 retry: %s" , error )
330
+ return 1
331
+
332
+ if not data .startswith ("AUTH" ):
333
+ sys .stderr .write ("FAIL\n " )
334
+ logging .error ("Expected AUTH challenge for MD5 retry, got: %s" , data )
335
+ return 1
336
+
337
+ # Get new nonce for second attempt
338
+ nonce = data .split ()[1 ]
339
+
247
340
auth_success , auth_error = authenticate (
248
- remote_addr , remote_port , password , True , filename , content_size , file_md5 , nonce
341
+ remote_addr ,
342
+ remote_port ,
343
+ password ,
344
+ use_md5_password = True ,
345
+ use_old_protocol = False ,
346
+ filename = filename ,
347
+ content_size = content_size ,
348
+ file_md5 = file_md5 ,
349
+ nonce = nonce ,
249
350
)
250
- else :
251
- auth_success = False
252
- auth_error = "Expected AUTH challenge for MD5 retry, got: " + data
253
351
254
- if not auth_success :
255
- sys .stderr .write ("FAIL\n " )
256
- logging .error ("Authentication failed with both SHA256 and MD5: %s" , auth_error )
257
- return 1
258
- else :
259
- # Already tried MD5 and it failed
352
+ if auth_success :
353
+ logging .warning ("====================================================================" )
354
+ logging .warning ("WARNING: Device authenticated with MD5 password hash (deprecated)" )
355
+ logging .warning ("MD5 is cryptographically broken and should not be used." )
356
+ logging .warning (
357
+ "Please update your sketch to use either setPassword() or setPasswordHash()"
358
+ )
359
+ logging .warning (
360
+ "with SHA256, then upload again to migrate to the new secure SHA256 protocol."
361
+ )
362
+ logging .warning ("======================================================================" )
363
+
364
+ if not auth_success :
260
365
sys .stderr .write ("FAIL\n " )
261
- logging .error ("Authentication failed : %s" , auth_error )
366
+ logging .error ("Authentication Failed : %s" , auth_error )
262
367
return 1
263
368
264
- sys .stderr .write ("OK\n " )
369
+ sys .stderr .write ("OK\n " )
370
+ else :
371
+ logging .error ("Invalid nonce length: %d (expected 32 or 64)" , nonce_length )
372
+ return 1
265
373
else :
266
374
logging .error ("Bad Answer: %s" , data )
267
375
return 1
@@ -381,7 +489,10 @@ def parse_args(unparsed_args):
381
489
"-m" ,
382
490
"--md5-target" ,
383
491
dest = "md5_target" ,
384
- help = "Target device is using MD5 checksum. This is insecure, use only for compatibility with old firmwares." ,
492
+ help = (
493
+ "Use MD5 for password hashing (for devices with stored MD5 passwords). "
494
+ "By default, SHA256 is tried first, then MD5 as fallback."
495
+ ),
385
496
action = "store_true" ,
386
497
default = False ,
387
498
)
0 commit comments