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