Skip to content

Commit b9e597c

Browse files
lucasssvazgithub-actions[bot]pre-commit-ci-lite[bot]
authored
fix(ota): Add legacy option for devices using MD5 authentication (#11862)
* fix(ota): Add legacy option for devices using MD5 authentication * change(tools): Push generated binaries to PR * ci(pre-commit): Apply automatic fixes * change(tools): Push generated binaries to PR --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent c2e6a30 commit b9e597c

File tree

2 files changed

+196
-73
lines changed

2 files changed

+196
-73
lines changed

tools/espota.exe

608 Bytes
Binary file not shown.

tools/espota.py

Lines changed: 196 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)