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,43 @@ 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 (remote_addr , remote_port , password , use_md5_password , use_old_protocol , filename , content_size , file_md5 , nonce ):
131
144
"""
132
- Perform authentication with the ESP device using either MD5 or SHA256 method.
145
+ Perform authentication with the ESP device.
146
+
147
+ Args:
148
+ use_md5_password: If True, hash password with MD5 instead of SHA256
149
+ use_old_protocol: If True, use old MD5 challenge/response protocol (pre-3.3.1)
150
+
133
151
Returns (success, error_message) tuple.
134
152
"""
135
153
cnonce_text = "%s%u%s%s" % (filename , content_size , file_md5 , remote_addr )
136
154
remote_address = (remote_addr , int (remote_port ))
137
155
138
- if md5_target :
156
+ if use_old_protocol :
139
157
# Generate client nonce (cnonce)
140
158
cnonce = hashlib .md5 (cnonce_text .encode ()).hexdigest ()
141
159
142
- # MD5 challenge/response protocol (insecure, use only for compatibility with old firmwares )
143
- # 1. Hash the password with MD5 (to match ESP32 storage)
160
+ # Old MD5 challenge/response protocol (pre-3.3.1 )
161
+ # 1. Hash the password with MD5
144
162
password_hash = hashlib .md5 (password .encode ()).hexdigest ()
145
163
146
164
# 2. Create challenge response
147
165
challenge = "%s:%s:%s" % (password_hash , nonce , cnonce )
148
166
response = hashlib .md5 (challenge .encode ()).hexdigest ()
149
167
expected_response_length = 32
150
168
else :
151
- # Generate client nonce (cnonce)
169
+ # Generate client nonce (cnonce) using SHA256 for new protocol
152
170
cnonce = hashlib .sha256 (cnonce_text .encode ()).hexdigest ()
153
171
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 ()
172
+ # New PBKDF2-HMAC-SHA256 challenge/response protocol (3.3.1+)
173
+ # The password can be hashed with either MD5 or SHA256
174
+ if use_md5_password :
175
+ # Use MD5 for password hash (for devices that stored MD5 hashes)
176
+ password_hash = hashlib .md5 (password .encode ()).hexdigest ()
177
+ else :
178
+ # Use SHA256 for password hash (recommended)
179
+ password_hash = hashlib .sha256 (password .encode ()).hexdigest ()
158
180
159
181
# 2. Derive key using PBKDF2-HMAC-SHA256 with the password hash
160
182
salt = nonce + ":" + cnonce
@@ -210,58 +232,94 @@ def serve(
210
232
message = "%d %d %d %s\n " % (command , local_port , content_size , file_md5 )
211
233
212
234
# Send invitation and get authentication challenge
213
- success , data , error = send_invitation_and_get_auth_challenge (remote_addr , remote_port , message , md5_target )
235
+ success , data , error = send_invitation_and_get_auth_challenge (remote_addr , remote_port , message )
214
236
if not success :
215
237
logging .error (error )
216
238
return 1
217
239
218
240
if data != "OK" :
219
241
if data .startswith ("AUTH" ):
220
242
nonce = data .split ()[1 ]
243
+ nonce_length = len (nonce )
221
244
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
- )
245
+ # Detect protocol version based on nonce length:
246
+ # - 32 chars = Old MD5 protocol (pre-3.3.1)
247
+ # - 64 chars = New SHA256 protocol (3.3.1+)
228
248
229
- if not auth_success :
230
- # If authentication failed and we're not already using MD5, try with MD5
231
- if not md5_target :
249
+ if nonce_length == 32 :
250
+ # Scenario 1: Old device (pre-3.3.1) using MD5 protocol
251
+ logging .info ("Detected old MD5 protocol (pre-3.3.1)" )
252
+ sys .stderr .write ("Authenticating (MD5 protocol)..." )
253
+ sys .stderr .flush ()
254
+ auth_success , auth_error = authenticate (
255
+ remote_addr , remote_port , password ,
256
+ use_md5_password = True , use_old_protocol = True ,
257
+ filename = filename , content_size = content_size , file_md5 = file_md5 , nonce = nonce
258
+ )
259
+
260
+ if not auth_success :
232
261
sys .stderr .write ("FAIL\n " )
233
- logging .warning ("Authentication failed with SHA256, retrying with MD5: %s" , auth_error )
262
+ logging .error ("Authentication Failed: %s" , auth_error )
263
+ return 1
234
264
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
265
+ sys .stderr .write ("OK\n " )
266
+ logging .warning ("====================================================================" )
267
+ logging .warning ("WARNING: Device is using old MD5 authentication protocol (pre-3.3.1)" )
268
+ logging .warning ("Please update to ESP32 Arduino Core 3.3.1+ for improved security." )
269
+ logging .warning ("======================================================================" )
270
+
271
+ elif nonce_length == 64 :
272
+ # New protocol (3.3.1+) - try SHA256 password first, then MD5 if it fails
273
+
274
+ # Scenario 2: Try SHA256 password hash first (recommended for new devices)
275
+ if md5_target :
276
+ # User explicitly requested MD5 password hash
277
+ logging .info ("Using MD5 password hash as requested" )
278
+ sys .stderr .write ("Authenticating (SHA256 protocol with MD5 password)..." )
279
+ sys .stderr .flush ()
280
+ auth_success , auth_error = authenticate (
281
+ remote_addr , remote_port , password ,
282
+ use_md5_password = True , use_old_protocol = False ,
283
+ filename = filename , content_size = content_size , file_md5 = file_md5 , nonce = nonce
284
+ )
285
+ else :
286
+ # Try SHA256 password hash first
287
+ sys .stderr .write ("Authenticating..." )
288
+ sys .stderr .flush ()
289
+ auth_success , auth_error = authenticate (
290
+ remote_addr , remote_port , password ,
291
+ use_md5_password = False , use_old_protocol = False ,
292
+ filename = filename , content_size = content_size , file_md5 = file_md5 , nonce = nonce
238
293
)
239
- if not success :
240
- logging .error ("Failed to re-establish connection for MD5 retry: %s" , error )
241
- return 1
242
294
243
- if data .startswith ("AUTH" ):
244
- nonce = data .split ()[1 ]
245
- sys .stderr .write ("Retrying with MD5..." )
295
+ # Scenario 3: If SHA256 fails, try MD5 password hash (for devices with stored MD5 passwords)
296
+ if not auth_success :
297
+ logging .info ("SHA256 password failed, trying MD5 password hash" )
298
+ sys .stderr .write ("Retrying with MD5 password..." )
246
299
sys .stderr .flush ()
247
300
auth_success , auth_error = authenticate (
248
- remote_addr , remote_port , password , True , filename , content_size , file_md5 , nonce
301
+ remote_addr , remote_port , password ,
302
+ use_md5_password = True , use_old_protocol = False ,
303
+ filename = filename , content_size = content_size , file_md5 = file_md5 , nonce = nonce
249
304
)
250
- else :
251
- auth_success = False
252
- auth_error = "Expected AUTH challenge for MD5 retry, got: " + data
253
305
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
306
+ if auth_success :
307
+ logging .warning ("====================================================================" )
308
+ logging .warning ("WARNING: Device authenticated with MD5 password hash (deprecated)" )
309
+ logging .warning ("MD5 is cryptographically broken and should not be used." )
310
+ logging .warning ("Please update your sketch to use setPassword() instead of" )
311
+ logging .warning ("setPasswordHash() with MD5, then upload again to migrate to SHA256." )
312
+ logging .warning ("======================================================================" )
313
+
314
+ if not auth_success :
260
315
sys .stderr .write ("FAIL\n " )
261
- logging .error ("Authentication failed : %s" , auth_error )
316
+ logging .error ("Authentication Failed : %s" , auth_error )
262
317
return 1
263
318
264
- sys .stderr .write ("OK\n " )
319
+ sys .stderr .write ("OK\n " )
320
+ else :
321
+ logging .error ("Invalid nonce length: %d (expected 32 or 64)" , nonce_length )
322
+ return 1
265
323
else :
266
324
logging .error ("Bad Answer: %s" , data )
267
325
return 1
@@ -381,7 +439,7 @@ def parse_args(unparsed_args):
381
439
"-m" ,
382
440
"--md5-target" ,
383
441
dest = "md5_target" ,
384
- help = "Target device is using MD5 checksum. This is insecure, use only for compatibility with old firmwares ." ,
442
+ help = "Use MD5 for password hashing (for devices with stored MD5 passwords). By default, SHA256 is tried first, then MD5 as fallback ." ,
385
443
action = "store_true" ,
386
444
default = False ,
387
445
)
0 commit comments