66# Modified since 2015-09-18 from Pascal Gollor (https://github.com/pgollor)
77# Modified since 2015-11-09 from Hristo Gochkov (https://github.com/me-no-dev)
88# 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)
910#
1011# This script will push an OTA update to the ESP
1112# use it like:
3637# - Incorporated exception handling to catch and handle potential errors.
3738# - Made variable names more descriptive for better readability.
3839# - 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+
3953
4054from __future__ import print_function
4155import socket
@@ -81,7 +95,7 @@ def update_progress(progress):
8195 sys .stderr .flush ()
8296
8397
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 ):
8599 """
86100 Send invitation to ESP device and get authentication challenge.
87101 Returns (success, auth_data, error_message) tuple.
@@ -107,10 +121,9 @@ def send_invitation_and_get_auth_challenge(remote_addr, remote_port, message, md
107121
108122 sock2 .settimeout (TIMEOUT )
109123 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 ()
114127 sock2 .close ()
115128 break
116129 except : # noqa: E722
@@ -127,34 +140,49 @@ def send_invitation_and_get_auth_challenge(remote_addr, remote_port, message, md
127140 return True , data , None
128141
129142
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+ ):
131146 """
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+
133153 Returns (success, error_message) tuple.
134154 """
135155 cnonce_text = "%s%u%s%s" % (filename , content_size , file_md5 , remote_addr )
136156 remote_address = (remote_addr , int (remote_port ))
137157
138- if md5_target :
158+ if use_old_protocol :
139159 # Generate client nonce (cnonce)
140160 cnonce = hashlib .md5 (cnonce_text .encode ()).hexdigest ()
141161
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
144164 password_hash = hashlib .md5 (password .encode ()).hexdigest ()
145165
146166 # 2. Create challenge response
147167 challenge = "%s:%s:%s" % (password_hash , nonce , cnonce )
148168 response = hashlib .md5 (challenge .encode ()).hexdigest ()
149169 expected_response_length = 32
150170 else :
151- # Generate client nonce (cnonce)
171+ # Generate client nonce (cnonce) using SHA256 for new protocol
152172 cnonce = hashlib .sha256 (cnonce_text .encode ()).hexdigest ()
153173
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 ()
158186
159187 # 2. Derive key using PBKDF2-HMAC-SHA256 with the password hash
160188 salt = nonce + ":" + cnonce
@@ -189,9 +217,9 @@ def authenticate(remote_addr, remote_port, password, md5_target, filename, conte
189217 return False , str (e )
190218
191219
192- def serve (
220+ def serve ( # noqa: C901
193221 remote_addr , local_addr , remote_port , local_port , password , md5_target , filename , command = FLASH
194- ): # noqa: C901
222+ ):
195223 # Create a TCP/IP socket
196224 sock = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
197225 server_address = (local_addr , local_port )
@@ -210,58 +238,138 @@ def serve(
210238 message = "%d %d %d %s\n " % (command , local_port , content_size , file_md5 )
211239
212240 # 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 )
214242 if not success :
215243 logging .error (error )
216244 return 1
217245
218246 if data != "OK" :
219247 if data .startswith ("AUTH" ):
220248 nonce = data .split ()[1 ]
249+ nonce_length = len (nonce )
221250
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+ )
228271
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 :
232273 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
234276
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 ,
238317 )
239- if not success :
240- logging .error ("Failed to re-establish connection for MD5 retry: %s" , error )
241- return 1
242318
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..." )
246323 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+
247340 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 ,
249350 )
250- else :
251- auth_success = False
252- auth_error = "Expected AUTH challenge for MD5 retry, got: " + data
253351
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 :
260365 sys .stderr .write ("FAIL\n " )
261- logging .error ("Authentication failed : %s" , auth_error )
366+ logging .error ("Authentication Failed : %s" , auth_error )
262367 return 1
263368
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
265373 else :
266374 logging .error ("Bad Answer: %s" , data )
267375 return 1
@@ -381,7 +489,10 @@ def parse_args(unparsed_args):
381489 "-m" ,
382490 "--md5-target" ,
383491 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+ ),
385496 action = "store_true" ,
386497 default = False ,
387498 )
0 commit comments