@@ -81,101 +81,182 @@ 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 (remote_addr , local_addr , remote_port , local_port , password , md5_target , filename , command = FLASH ): # noqa: C901
193+ # Create a TCP/IP socket
194+ sock = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
195+ server_address = (local_addr , local_port )
196+ logging .info ("Starting on %s:%s" , str (server_address [0 ]), str (server_address [1 ]))
197+ try :
198+ sock .bind (server_address )
199+ sock .listen (1 )
200+ except Exception as e :
201+ logging .error ("Listen Failed: %s" , str (e ))
202+ return 1
203+
204+ content_size = os .path .getsize (filename )
205+ with open (filename , "rb" ) as f :
206+ file_md5 = hashlib .md5 (f .read ()).hexdigest ()
207+ logging .info ("Upload size: %d" , content_size )
208+ message = "%d %d %d %s\n " % (command , local_port , content_size , file_md5 )
209+
210+ # Send invitation and get authentication challenge
211+ success , data , error = send_invitation_and_get_auth_challenge (remote_addr , remote_port , message , md5_target )
212+ if not success :
213+ logging .error (error )
214+ return 1
215+
216+ if data != "OK" :
217+ if data .startswith ("AUTH" ):
218+ nonce = data .split ()[1 ]
219+
220+ # Try authentication with the specified method first
155221 sys .stderr .write ("Authenticating..." )
156222 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
223+ auth_success , auth_error = authenticate (remote_addr , remote_port , password , md5_target , filename , content_size , file_md5 , nonce )
224+
225+ if not auth_success :
226+ # If authentication failed and we're not already using MD5, try with MD5
227+ if not md5_target :
228+ sys .stderr .write ("FAIL\n " )
229+ logging .warning ("Authentication failed with SHA256, retrying with MD5: %s" , auth_error )
230+
231+ # Restart the entire process with MD5 to get a fresh nonce
232+ success , data , error = send_invitation_and_get_auth_challenge (remote_addr , remote_port , message , True )
233+ if not success :
234+ logging .error ("Failed to re-establish connection for MD5 retry: %s" , error )
235+ return 1
236+
237+ if data .startswith ("AUTH" ):
238+ nonce = data .split ()[1 ]
239+ sys .stderr .write ("Retrying with MD5..." )
240+ sys .stderr .flush ()
241+ auth_success , auth_error = authenticate (remote_addr , remote_port , password , True , filename , content_size , file_md5 , nonce )
242+ else :
243+ auth_success = False
244+ auth_error = "Expected AUTH challenge for MD5 retry, got: " + data
245+
246+ if not auth_success :
247+ sys .stderr .write ("FAIL\n " )
248+ logging .error ("Authentication failed with both SHA256 and MD5: %s" , auth_error )
249+ return 1
250+ else :
251+ # Already tried MD5 and it failed
252+ sys .stderr .write ("FAIL\n " )
253+ logging .error ("Authentication failed: %s" , auth_error )
254+ return 1
255+
173256 sys .stderr .write ("OK\n " )
174257 else :
175258 logging .error ("Bad Answer: %s" , data )
176- sock2 .close ()
177259 return 1
178- sock2 .close ()
179260
180261 logging .info ("Waiting for device..." )
181262
@@ -207,7 +288,9 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename,
207288 try :
208289 connection .sendall (chunk )
209290 res = connection .recv (10 )
210- last_response_contained_ok = "OK" in res .decode ()
291+ response_text = res .decode ().strip ()
292+ last_response_contained_ok = "OK" in response_text
293+ logging .debug ("Chunk response: '%s'" , response_text )
211294 except Exception as e :
212295 sys .stderr .write ("\n " )
213296 logging .error ("Error Uploading: %s" , str (e ))
@@ -222,26 +305,41 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename,
222305 sys .stderr .write ("\n " )
223306 logging .info ("Waiting for result..." )
224307 count = 0
225- while count < 5 :
308+ received_any_response = False
309+ while count < 10 : # Increased from 5 to 10 attempts
226310 count += 1
227- connection .settimeout (60 )
311+ connection .settimeout (30 ) # Reduced from 60s to 30s per attempt
228312 try :
229- data = connection .recv (32 ).decode ()
230- logging .info ("Result: %s" , data )
313+ data = connection .recv (32 ).decode ().strip ()
314+ received_any_response = True
315+ logging .info ("Result attempt %d: '%s'" , count , data )
231316
232317 if "OK" in data :
233318 logging .info ("Success" )
234319 connection .close ()
235320 return 0
321+ elif data : # Got some response but not OK
322+ logging .warning ("Unexpected response from device: '%s'" , data )
236323
324+ except socket .timeout :
325+ logging .debug ("Timeout waiting for result (attempt %d/10)" , count )
326+ continue
237327 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
328+ logging .debug ("Error receiving result (attempt %d/10): %s" , count , str (e ))
329+ # Don't return error here, continue trying
330+ continue
331+
332+ # After all attempts, provide detailed error information
333+ if received_any_response :
334+ logging .warning ("Upload completed but device sent unexpected response(s). This may still be successful." )
335+ logging .warning ("Device might be rebooting to apply firmware - this is normal." )
336+ connection .close ()
337+ return 0 # Consider it successful if we got any response and upload completed
338+ else :
339+ logging .error ("No response from device after upload completion" )
340+ logging .error ("This could indicate device reboot (normal) or network issues" )
341+ connection .close ()
342+ return 1
245343 except Exception as e : # noqa: E722
246344 logging .error ("Error: %s" , str (e ))
247345 finally :
@@ -269,6 +367,14 @@ def parse_args(unparsed_args):
269367
270368 # authentication
271369 parser .add_argument ("-a" , "--auth" , dest = "auth" , help = "Set authentication password." , action = "store" , default = "" )
370+ parser .add_argument (
371+ "-m" ,
372+ "--md5-target" ,
373+ dest = "md5_target" ,
374+ help = "Target device is using MD5 checksum. This is insecure, use only for compatibility with old firmwares." ,
375+ action = "store_true" ,
376+ default = False ,
377+ )
272378
273379 # image
274380 parser .add_argument ("-f" , "--file" , dest = "image" , help = "Image file." , metavar = "FILE" , default = None )
@@ -335,7 +441,14 @@ def main(args):
335441 command = SPIFFS
336442
337443 return serve (
338- options .esp_ip , options .host_ip , options .esp_port , options .host_port , options .auth , options .image , command
444+ options .esp_ip ,
445+ options .host_ip ,
446+ options .esp_port ,
447+ options .host_port ,
448+ options .auth ,
449+ options .md5_target ,
450+ options .image ,
451+ command
339452 )
340453
341454
0 commit comments