Skip to content

Commit dc4fa6c

Browse files
committed
fix(ota): Add legacy option for devices using MD5 authentication
1 parent c42facb commit dc4fa6c

File tree

1 file changed

+186
-73
lines changed

1 file changed

+186
-73
lines changed

tools/espota.py

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

Comments
 (0)