@@ -81,101 +81,190 @@ def update_progress(progress):
81
81
sys .stderr .flush ()
82
82
83
83
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 ))
103
90
inv_tries = 0
104
91
data = ""
92
+
105
93
msg = "Sending invitation to %s " % remote_addr
106
94
sys .stderr .write (msg )
107
95
sys .stderr .flush ()
96
+
108
97
while inv_tries < 10 :
109
98
inv_tries += 1
110
99
sock2 = socket .socket (socket .AF_INET , socket .SOCK_DGRAM )
111
- remote_address = (remote_addr , int (remote_port ))
112
100
try :
113
101
sent = sock2 .sendto (message .encode (), remote_address ) # noqa: F841
114
102
except : # noqa: E722
115
103
sys .stderr .write ("failed\n " )
116
104
sys .stderr .flush ()
117
105
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
+
120
108
sock2 .settimeout (TIMEOUT )
121
109
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 ()
123
115
break
124
116
except : # noqa: E722
125
117
sys .stderr .write ("." )
126
118
sys .stderr .flush ()
127
119
sock2 .close ()
120
+
128
121
sys .stderr .write ("\n " )
129
122
sys .stderr .flush ()
123
+
130
124
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
+
136
129
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 ))
140
137
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 ()
145
141
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 )
150
190
151
- # 3. Create challenge response
152
- challenge = derived_key_hex + ":" + nonce + ":" + cnonce
153
- response = hashlib .sha256 (challenge .encode ()).hexdigest ()
154
191
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
155
223
sys .stderr .write ("Authenticating..." )
156
224
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
+
173
264
sys .stderr .write ("OK\n " )
174
265
else :
175
266
logging .error ("Bad Answer: %s" , data )
176
- sock2 .close ()
177
267
return 1
178
- sock2 .close ()
179
268
180
269
logging .info ("Waiting for device..." )
181
270
@@ -207,7 +296,9 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename,
207
296
try :
208
297
connection .sendall (chunk )
209
298
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 )
211
302
except Exception as e :
212
303
sys .stderr .write ("\n " )
213
304
logging .error ("Error Uploading: %s" , str (e ))
@@ -222,26 +313,43 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename,
222
313
sys .stderr .write ("\n " )
223
314
logging .info ("Waiting for result..." )
224
315
count = 0
225
- while count < 5 :
316
+ received_any_response = False
317
+ while count < 10 : # Increased from 5 to 10 attempts
226
318
count += 1
227
- connection .settimeout (60 )
319
+ connection .settimeout (30 ) # Reduced from 60s to 30s per attempt
228
320
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 )
231
324
232
325
if "OK" in data :
233
326
logging .info ("Success" )
234
327
connection .close ()
235
328
return 0
329
+ elif data : # Got some response but not OK
330
+ logging .warning ("Unexpected response from device: '%s'" , data )
236
331
332
+ except socket .timeout :
333
+ logging .debug ("Timeout waiting for result (attempt %d/10)" , count )
334
+ continue
237
335
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
245
353
except Exception as e : # noqa: E722
246
354
logging .error ("Error: %s" , str (e ))
247
355
finally :
@@ -269,6 +377,14 @@ def parse_args(unparsed_args):
269
377
270
378
# authentication
271
379
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
+ )
272
388
273
389
# image
274
390
parser .add_argument ("-f" , "--file" , dest = "image" , help = "Image file." , metavar = "FILE" , default = None )
@@ -335,7 +451,14 @@ def main(args):
335
451
command = SPIFFS
336
452
337
453
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 ,
339
462
)
340
463
341
464
0 commit comments