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,43 @@ 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 (remote_addr , remote_port , password , use_md5_password , use_old_protocol , filename , content_size , file_md5 , nonce ):
131144 """
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+
133151 Returns (success, error_message) tuple.
134152 """
135153 cnonce_text = "%s%u%s%s" % (filename , content_size , file_md5 , remote_addr )
136154 remote_address = (remote_addr , int (remote_port ))
137155
138- if md5_target :
156+ if use_old_protocol :
139157 # Generate client nonce (cnonce)
140158 cnonce = hashlib .md5 (cnonce_text .encode ()).hexdigest ()
141159
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
144162 password_hash = hashlib .md5 (password .encode ()).hexdigest ()
145163
146164 # 2. Create challenge response
147165 challenge = "%s:%s:%s" % (password_hash , nonce , cnonce )
148166 response = hashlib .md5 (challenge .encode ()).hexdigest ()
149167 expected_response_length = 32
150168 else :
151- # Generate client nonce (cnonce)
169+ # Generate client nonce (cnonce) using SHA256 for new protocol
152170 cnonce = hashlib .sha256 (cnonce_text .encode ()).hexdigest ()
153171
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 ()
158180
159181 # 2. Derive key using PBKDF2-HMAC-SHA256 with the password hash
160182 salt = nonce + ":" + cnonce
@@ -210,58 +232,94 @@ def serve(
210232 message = "%d %d %d %s\n " % (command , local_port , content_size , file_md5 )
211233
212234 # 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 )
214236 if not success :
215237 logging .error (error )
216238 return 1
217239
218240 if data != "OK" :
219241 if data .startswith ("AUTH" ):
220242 nonce = data .split ()[1 ]
243+ nonce_length = len (nonce )
221244
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+)
228248
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 :
232261 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
234264
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
238293 )
239- if not success :
240- logging .error ("Failed to re-establish connection for MD5 retry: %s" , error )
241- return 1
242294
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..." )
246299 sys .stderr .flush ()
247300 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
249304 )
250- else :
251- auth_success = False
252- auth_error = "Expected AUTH challenge for MD5 retry, got: " + data
253305
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 :
260315 sys .stderr .write ("FAIL\n " )
261- logging .error ("Authentication failed : %s" , auth_error )
316+ logging .error ("Authentication Failed : %s" , auth_error )
262317 return 1
263318
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
265323 else :
266324 logging .error ("Bad Answer: %s" , data )
267325 return 1
@@ -381,7 +439,7 @@ def parse_args(unparsed_args):
381439 "-m" ,
382440 "--md5-target" ,
383441 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 ." ,
385443 action = "store_true" ,
386444 default = False ,
387445 )
0 commit comments