@@ -81,101 +81,190 @@ def update_progress(progress):
8181 sys .stderr .flush ()
8282
8383
84- def serve (remote_addr , local_addr , remote_port , local_port , password , filename , command = FLASH ): # noqa: C901
85- # Create a TCP/IP socket
86- sock = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
87- server_address = (local_addr , local_port )
88- logging .info ("Starting on %s:%s" , str (server_address [0 ]), str (server_address [1 ]))
89- try :
90- sock .bind (server_address )
91- sock .listen (1 )
92- except Exception as e :
93- logging .error ("Listen Failed: %s" , str (e ))
94- return 1
95-
96- content_size = os .path .getsize (filename )
97- with open (filename , "rb" ) as f :
98- file_md5 = hashlib .md5 (f .read ()).hexdigest ()
99- logging .info ("Upload size: %d" , content_size )
100- message = "%d %d %d %s\n " % (command , local_port , content_size , file_md5 )
101-
102- # Wait for a connection
84+ def send_invitation_and_get_auth_challenge (remote_addr , remote_port , message , md5_target ):
85+ """
86+ Send invitation to ESP device and get authentication challenge.
87+ Returns (success, auth_data, error_message) tuple.
88+ """
89+ remote_address = (remote_addr , int (remote_port ))
10390 inv_tries = 0
10491 data = ""
92+
10593 msg = "Sending invitation to %s " % remote_addr
10694 sys .stderr .write (msg )
10795 sys .stderr .flush ()
96+
10897 while inv_tries < 10 :
10998 inv_tries += 1
11099 sock2 = socket .socket (socket .AF_INET , socket .SOCK_DGRAM )
111- remote_address = (remote_addr , int (remote_port ))
112100 try :
113101 sent = sock2 .sendto (message .encode (), remote_address ) # noqa: F841
114102 except : # noqa: E722
115103 sys .stderr .write ("failed\n " )
116104 sys .stderr .flush ()
117105 sock2 .close ()
118- logging . error ( "Host %s Not Found" , remote_addr )
119- return 1
106+ return False , None , "Host %s Not Found" % remote_addr
107+
120108 sock2 .settimeout (TIMEOUT )
121109 try :
122- data = sock2 .recv (69 ).decode () # "AUTH " + 64-char SHA256 nonce
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
114+ sock2 .close ()
123115 break
124116 except : # noqa: E722
125117 sys .stderr .write ("." )
126118 sys .stderr .flush ()
127119 sock2 .close ()
120+
128121 sys .stderr .write ("\n " )
129122 sys .stderr .flush ()
123+
130124 if inv_tries == 10 :
131- logging .error ("No response from the ESP" )
132- return 1
133- if data != "OK" :
134- if data .startswith ("AUTH" ):
135- nonce = data .split ()[1 ]
125+ return False , None , "No response from the ESP"
126+
127+ return True , data , None
128+
136129
137- # Generate client nonce (cnonce)
138- cnonce_text = "%s%u%s%s" % (filename , content_size , file_md5 , remote_addr )
139- cnonce = hashlib .sha256 (cnonce_text .encode ()).hexdigest ()
130+ def authenticate (remote_addr , remote_port , password , md5_target , filename , content_size , file_md5 , nonce ):
131+ """
132+ Perform authentication with the ESP device using either MD5 or SHA256 method.
133+ Returns (success, error_message) tuple.
134+ """
135+ cnonce_text = "%s%u%s%s" % (filename , content_size , file_md5 , remote_addr )
136+ remote_address = (remote_addr , int (remote_port ))
140137
141- # PBKDF2-HMAC-SHA256 challenge/response protocol
142- # The ESP32 stores the password as SHA256 hash, so we need to hash the password first
143- # 1. Hash the password with SHA256 (to match ESP32 storage)
144- password_hash = hashlib .sha256 (password .encode ()).hexdigest ()
138+ if md5_target :
139+ # Generate client nonce (cnonce)
140+ cnonce = hashlib .md5 (cnonce_text .encode ()).hexdigest ()
145141
146- # 2. Derive key using PBKDF2-HMAC-SHA256 with the password hash
147- salt = nonce + ":" + cnonce
148- derived_key = hashlib .pbkdf2_hmac ("sha256" , password_hash .encode (), salt .encode (), 10000 )
149- derived_key_hex = derived_key .hex ()
142+ # MD5 challenge/response protocol (insecure, use only for compatibility with old firmwares)
143+ # 1. Hash the password with MD5 (to match ESP32 storage)
144+ password_hash = hashlib .md5 (password .encode ()).hexdigest ()
145+
146+ # 2. Create challenge response
147+ challenge = "%s:%s:%s" % (password_hash , nonce , cnonce )
148+ response = hashlib .md5 (challenge .encode ()).hexdigest ()
149+ expected_response_length = 32
150+ else :
151+ # Generate client nonce (cnonce)
152+ cnonce = hashlib .sha256 (cnonce_text .encode ()).hexdigest ()
153+
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 ()
158+
159+ # 2. Derive key using PBKDF2-HMAC-SHA256 with the password hash
160+ salt = nonce + ":" + cnonce
161+ derived_key = hashlib .pbkdf2_hmac ("sha256" , password_hash .encode (), salt .encode (), 10000 )
162+ derived_key_hex = derived_key .hex ()
163+
164+ # 3. Create challenge response
165+ challenge = derived_key_hex + ":" + nonce + ":" + cnonce
166+ response = hashlib .sha256 (challenge .encode ()).hexdigest ()
167+ expected_response_length = 64
168+
169+ # Send authentication response
170+ sock2 = socket .socket (socket .AF_INET , socket .SOCK_DGRAM )
171+ try :
172+ message = "%d %s %s\n " % (AUTH , cnonce , response )
173+ sock2 .sendto (message .encode (), remote_address )
174+ sock2 .settimeout (10 )
175+ try :
176+ data = sock2 .recv (expected_response_length ).decode ()
177+ except : # noqa: E722
178+ sock2 .close ()
179+ return False , "No Answer to our Authentication"
180+
181+ if data != "OK" :
182+ sock2 .close ()
183+ return False , data
184+
185+ sock2 .close ()
186+ return True , None
187+ except Exception as e :
188+ sock2 .close ()
189+ return False , str (e )
150190
151- # 3. Create challenge response
152- challenge = derived_key_hex + ":" + nonce + ":" + cnonce
153- response = hashlib .sha256 (challenge .encode ()).hexdigest ()
154191
192+ def serve (
193+ remote_addr , local_addr , remote_port , local_port , password , md5_target , filename , command = FLASH
194+ ): # noqa: C901
195+ # Create a TCP/IP socket
196+ sock = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
197+ server_address = (local_addr , local_port )
198+ logging .info ("Starting on %s:%s" , str (server_address [0 ]), str (server_address [1 ]))
199+ try :
200+ sock .bind (server_address )
201+ sock .listen (1 )
202+ except Exception as e :
203+ logging .error ("Listen Failed: %s" , str (e ))
204+ return 1
205+
206+ content_size = os .path .getsize (filename )
207+ with open (filename , "rb" ) as f :
208+ file_md5 = hashlib .md5 (f .read ()).hexdigest ()
209+ logging .info ("Upload size: %d" , content_size )
210+ message = "%d %d %d %s\n " % (command , local_port , content_size , file_md5 )
211+
212+ # Send invitation and get authentication challenge
213+ success , data , error = send_invitation_and_get_auth_challenge (remote_addr , remote_port , message , md5_target )
214+ if not success :
215+ logging .error (error )
216+ return 1
217+
218+ if data != "OK" :
219+ if data .startswith ("AUTH" ):
220+ nonce = data .split ()[1 ]
221+
222+ # Try authentication with the specified method first
155223 sys .stderr .write ("Authenticating..." )
156224 sys .stderr .flush ()
157- message = "%d %s %s\n " % (AUTH , cnonce , response )
158- sock2 .sendto (message .encode (), remote_address )
159- sock2 .settimeout (10 )
160- try :
161- data = sock2 .recv (64 ).decode () # SHA256 produces 64 character response
162- except : # noqa: E722
163- sys .stderr .write ("FAIL\n " )
164- logging .error ("No Answer to our Authentication" )
165- sock2 .close ()
166- return 1
167- if data != "OK" :
168- sys .stderr .write ("FAIL\n " )
169- logging .error ("%s" , data )
170- sock2 .close ()
171- sys .exit (1 )
172- return 1
225+ auth_success , auth_error = authenticate (
226+ remote_addr , remote_port , password , md5_target , filename , content_size , file_md5 , nonce
227+ )
228+
229+ if not auth_success :
230+ # If authentication failed and we're not already using MD5, try with MD5
231+ if not md5_target :
232+ sys .stderr .write ("FAIL\n " )
233+ logging .warning ("Authentication failed with SHA256, retrying with MD5: %s" , auth_error )
234+
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
238+ )
239+ if not success :
240+ logging .error ("Failed to re-establish connection for MD5 retry: %s" , error )
241+ return 1
242+
243+ if data .startswith ("AUTH" ):
244+ nonce = data .split ()[1 ]
245+ sys .stderr .write ("Retrying with MD5..." )
246+ sys .stderr .flush ()
247+ auth_success , auth_error = authenticate (
248+ remote_addr , remote_port , password , True , filename , content_size , file_md5 , nonce
249+ )
250+ else :
251+ auth_success = False
252+ auth_error = "Expected AUTH challenge for MD5 retry, got: " + data
253+
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
260+ sys .stderr .write ("FAIL\n " )
261+ logging .error ("Authentication failed: %s" , auth_error )
262+ return 1
263+
173264 sys .stderr .write ("OK\n " )
174265 else :
175266 logging .error ("Bad Answer: %s" , data )
176- sock2 .close ()
177267 return 1
178- sock2 .close ()
179268
180269 logging .info ("Waiting for device..." )
181270
@@ -207,7 +296,9 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename,
207296 try :
208297 connection .sendall (chunk )
209298 res = connection .recv (10 )
210- last_response_contained_ok = "OK" in res .decode ()
299+ response_text = res .decode ().strip ()
300+ last_response_contained_ok = "OK" in response_text
301+ logging .debug ("Chunk response: '%s'" , response_text )
211302 except Exception as e :
212303 sys .stderr .write ("\n " )
213304 logging .error ("Error Uploading: %s" , str (e ))
@@ -222,26 +313,43 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename,
222313 sys .stderr .write ("\n " )
223314 logging .info ("Waiting for result..." )
224315 count = 0
225- while count < 5 :
316+ received_any_response = False
317+ while count < 10 : # Increased from 5 to 10 attempts
226318 count += 1
227- connection .settimeout (60 )
319+ connection .settimeout (30 ) # Reduced from 60s to 30s per attempt
228320 try :
229- data = connection .recv (32 ).decode ()
230- logging .info ("Result: %s" , data )
321+ data = connection .recv (32 ).decode ().strip ()
322+ received_any_response = True
323+ logging .info ("Result attempt %d: '%s'" , count , data )
231324
232325 if "OK" in data :
233326 logging .info ("Success" )
234327 connection .close ()
235328 return 0
329+ elif data : # Got some response but not OK
330+ logging .warning ("Unexpected response from device: '%s'" , data )
236331
332+ except socket .timeout :
333+ logging .debug ("Timeout waiting for result (attempt %d/10)" , count )
334+ continue
237335 except Exception as e :
238- logging .error ("Error receiving result: %s" , str (e ))
239- connection .close ()
240- return 1
241-
242- logging .error ("Error response from device" )
243- connection .close ()
244- return 1
336+ logging .debug ("Error receiving result (attempt %d/10): %s" , count , str (e ))
337+ # Don't return error here, continue trying
338+ continue
339+
340+ # After all attempts, provide detailed error information
341+ if received_any_response :
342+ logging .warning (
343+ "Upload completed but device sent unexpected response(s). This may still be successful."
344+ )
345+ logging .warning ("Device might be rebooting to apply firmware - this is normal." )
346+ connection .close ()
347+ return 0 # Consider it successful if we got any response and upload completed
348+ else :
349+ logging .error ("No response from device after upload completion" )
350+ logging .error ("This could indicate device reboot (normal) or network issues" )
351+ connection .close ()
352+ return 1
245353 except Exception as e : # noqa: E722
246354 logging .error ("Error: %s" , str (e ))
247355 finally :
@@ -269,6 +377,14 @@ def parse_args(unparsed_args):
269377
270378 # authentication
271379 parser .add_argument ("-a" , "--auth" , dest = "auth" , help = "Set authentication password." , action = "store" , default = "" )
380+ parser .add_argument (
381+ "-m" ,
382+ "--md5-target" ,
383+ dest = "md5_target" ,
384+ help = "Target device is using MD5 checksum. This is insecure, use only for compatibility with old firmwares." ,
385+ action = "store_true" ,
386+ default = False ,
387+ )
272388
273389 # image
274390 parser .add_argument ("-f" , "--file" , dest = "image" , help = "Image file." , metavar = "FILE" , default = None )
@@ -335,7 +451,14 @@ def main(args):
335451 command = SPIFFS
336452
337453 return serve (
338- options .esp_ip , options .host_ip , options .esp_port , options .host_port , options .auth , options .image , command
454+ options .esp_ip ,
455+ options .host_ip ,
456+ options .esp_port ,
457+ options .host_port ,
458+ options .auth ,
459+ options .md5_target ,
460+ options .image ,
461+ command ,
339462 )
340463
341464
0 commit comments