@@ -81,101 +81,182 @@ 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 (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
155
221
sys .stderr .write ("Authenticating..." )
156
222
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
+
173
256
sys .stderr .write ("OK\n " )
174
257
else :
175
258
logging .error ("Bad Answer: %s" , data )
176
- sock2 .close ()
177
259
return 1
178
- sock2 .close ()
179
260
180
261
logging .info ("Waiting for device..." )
181
262
@@ -207,7 +288,9 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename,
207
288
try :
208
289
connection .sendall (chunk )
209
290
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 )
211
294
except Exception as e :
212
295
sys .stderr .write ("\n " )
213
296
logging .error ("Error Uploading: %s" , str (e ))
@@ -222,26 +305,41 @@ def serve(remote_addr, local_addr, remote_port, local_port, password, filename,
222
305
sys .stderr .write ("\n " )
223
306
logging .info ("Waiting for result..." )
224
307
count = 0
225
- while count < 5 :
308
+ received_any_response = False
309
+ while count < 10 : # Increased from 5 to 10 attempts
226
310
count += 1
227
- connection .settimeout (60 )
311
+ connection .settimeout (30 ) # Reduced from 60s to 30s per attempt
228
312
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 )
231
316
232
317
if "OK" in data :
233
318
logging .info ("Success" )
234
319
connection .close ()
235
320
return 0
321
+ elif data : # Got some response but not OK
322
+ logging .warning ("Unexpected response from device: '%s'" , data )
236
323
324
+ except socket .timeout :
325
+ logging .debug ("Timeout waiting for result (attempt %d/10)" , count )
326
+ continue
237
327
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
245
343
except Exception as e : # noqa: E722
246
344
logging .error ("Error: %s" , str (e ))
247
345
finally :
@@ -269,6 +367,14 @@ def parse_args(unparsed_args):
269
367
270
368
# authentication
271
369
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
+ )
272
378
273
379
# image
274
380
parser .add_argument ("-f" , "--file" , dest = "image" , help = "Image file." , metavar = "FILE" , default = None )
@@ -335,7 +441,14 @@ def main(args):
335
441
command = SPIFFS
336
442
337
443
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
339
452
)
340
453
341
454
0 commit comments