Skip to content

Commit 8eb9ccc

Browse files
committed
fix(ota): Fix authentication when using stored MD5 hashes
1 parent c9a5d27 commit 8eb9ccc

File tree

2 files changed

+127
-48
lines changed

2 files changed

+127
-48
lines changed

libraries/ArduinoOTA/src/ArduinoOTA.cpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,27 @@ ArduinoOTAClass &ArduinoOTAClass::setPasswordHash(const char *password) {
8989
// Store the pre-hashed password directly
9090
_password.clear();
9191
_password = password;
92+
93+
size_t len = strlen(password);
94+
95+
if (len == 32) {
96+
// Check if it's a valid hex string (all chars are 0-9, a-f, A-F)
97+
bool is_hex = true;
98+
for (size_t i = 0; i < len; i++) {
99+
char c = password[i];
100+
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) {
101+
is_hex = false;
102+
break;
103+
}
104+
}
105+
106+
// Warn if MD5 hash is detected (32 hex characters)
107+
if (is_hex) {
108+
log_w("MD5 password hash detected. MD5 is deprecated and insecure.");
109+
log_w("Please use setPassword() with plain text or setPasswordHash() with SHA256 hash (64 chars).");
110+
log_w("To generate SHA256: echo -n 'yourpassword' | sha256sum");
111+
}
112+
}
92113
}
93114
return *this;
94115
}

tools/espota.py

Lines changed: 106 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# Modified since 2015-09-18 from Pascal Gollor (https://github.com/pgollor)
77
# Modified since 2015-11-09 from Hristo Gochkov (https://github.com/me-no-dev)
88
# Modified since 2016-01-03 from Matthew O'Gorman (https://githumb.com/mogorman)
9+
# Modified since 2025-09-04 from Lucas Saavedra Vaz (https://github.com/lucasssvaz)
910
#
1011
# This script will push an OTA update to the ESP
1112
# use it like:
@@ -36,6 +37,19 @@
3637
# - Incorporated exception handling to catch and handle potential errors.
3738
# - Made variable names more descriptive for better readability.
3839
# - Introduced constants for better code maintainability.
40+
#
41+
# Changes
42+
# 2025-09-04:
43+
# - Changed authentication to use PBKDF2-HMAC-SHA256 for challenge/response
44+
#
45+
# Changes
46+
# 2025-09-18:
47+
# - Fixed authentication when using old images with MD5 passwords
48+
#
49+
# Changes
50+
# 2025-10-07:
51+
# - Fixed authentication when images might use old MD5 hashes stored in the firmware
52+
3953

4054
from __future__ import print_function
4155
import socket
@@ -81,7 +95,7 @@ def update_progress(progress):
8195
sys.stderr.flush()
8296

8397

84-
def send_invitation_and_get_auth_challenge(remote_addr, remote_port, message, md5_target):
98+
def send_invitation_and_get_auth_challenge(remote_addr, remote_port, message):
8599
"""
86100
Send invitation to ESP device and get authentication challenge.
87101
Returns (success, auth_data, error_message) tuple.
@@ -107,10 +121,9 @@ def send_invitation_and_get_auth_challenge(remote_addr, remote_port, message, md
107121

108122
sock2.settimeout(TIMEOUT)
109123
try:
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
124+
# Try to read up to 69 bytes for new protocol (SHA256)
125+
# If device sends less (37 bytes), it's using old MD5 protocol
126+
data = sock2.recv(69).decode()
114127
sock2.close()
115128
break
116129
except: # noqa: E722
@@ -127,34 +140,43 @@ def send_invitation_and_get_auth_challenge(remote_addr, remote_port, message, md
127140
return True, data, None
128141

129142

130-
def authenticate(remote_addr, remote_port, password, md5_target, filename, content_size, file_md5, nonce):
143+
def authenticate(remote_addr, remote_port, password, use_md5_password, use_old_protocol, filename, content_size, file_md5, nonce):
131144
"""
132-
Perform authentication with the ESP device using either MD5 or SHA256 method.
145+
Perform authentication with the ESP device.
146+
147+
Args:
148+
use_md5_password: If True, hash password with MD5 instead of SHA256
149+
use_old_protocol: If True, use old MD5 challenge/response protocol (pre-3.3.1)
150+
133151
Returns (success, error_message) tuple.
134152
"""
135153
cnonce_text = "%s%u%s%s" % (filename, content_size, file_md5, remote_addr)
136154
remote_address = (remote_addr, int(remote_port))
137155

138-
if md5_target:
156+
if use_old_protocol:
139157
# Generate client nonce (cnonce)
140158
cnonce = hashlib.md5(cnonce_text.encode()).hexdigest()
141159

142-
# MD5 challenge/response protocol (insecure, use only for compatibility with old firmwares)
143-
# 1. Hash the password with MD5 (to match ESP32 storage)
160+
# Old MD5 challenge/response protocol (pre-3.3.1)
161+
# 1. Hash the password with MD5
144162
password_hash = hashlib.md5(password.encode()).hexdigest()
145163

146164
# 2. Create challenge response
147165
challenge = "%s:%s:%s" % (password_hash, nonce, cnonce)
148166
response = hashlib.md5(challenge.encode()).hexdigest()
149167
expected_response_length = 32
150168
else:
151-
# Generate client nonce (cnonce)
169+
# Generate client nonce (cnonce) using SHA256 for new protocol
152170
cnonce = hashlib.sha256(cnonce_text.encode()).hexdigest()
153171

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()
172+
# New PBKDF2-HMAC-SHA256 challenge/response protocol (3.3.1+)
173+
# The password can be hashed with either MD5 or SHA256
174+
if use_md5_password:
175+
# Use MD5 for password hash (for devices that stored MD5 hashes)
176+
password_hash = hashlib.md5(password.encode()).hexdigest()
177+
else:
178+
# Use SHA256 for password hash (recommended)
179+
password_hash = hashlib.sha256(password.encode()).hexdigest()
158180

159181
# 2. Derive key using PBKDF2-HMAC-SHA256 with the password hash
160182
salt = nonce + ":" + cnonce
@@ -210,58 +232,94 @@ def serve(
210232
message = "%d %d %d %s\n" % (command, local_port, content_size, file_md5)
211233

212234
# Send invitation and get authentication challenge
213-
success, data, error = send_invitation_and_get_auth_challenge(remote_addr, remote_port, message, md5_target)
235+
success, data, error = send_invitation_and_get_auth_challenge(remote_addr, remote_port, message)
214236
if not success:
215237
logging.error(error)
216238
return 1
217239

218240
if data != "OK":
219241
if data.startswith("AUTH"):
220242
nonce = data.split()[1]
243+
nonce_length = len(nonce)
221244

222-
# Try authentication with the specified method first
223-
sys.stderr.write("Authenticating...")
224-
sys.stderr.flush()
225-
auth_success, auth_error = authenticate(
226-
remote_addr, remote_port, password, md5_target, filename, content_size, file_md5, nonce
227-
)
245+
# Detect protocol version based on nonce length:
246+
# - 32 chars = Old MD5 protocol (pre-3.3.1)
247+
# - 64 chars = New SHA256 protocol (3.3.1+)
228248

229-
if not auth_success:
230-
# If authentication failed and we're not already using MD5, try with MD5
231-
if not md5_target:
249+
if nonce_length == 32:
250+
# Scenario 1: Old device (pre-3.3.1) using MD5 protocol
251+
logging.info("Detected old MD5 protocol (pre-3.3.1)")
252+
sys.stderr.write("Authenticating (MD5 protocol)...")
253+
sys.stderr.flush()
254+
auth_success, auth_error = authenticate(
255+
remote_addr, remote_port, password,
256+
use_md5_password=True, use_old_protocol=True,
257+
filename=filename, content_size=content_size, file_md5=file_md5, nonce=nonce
258+
)
259+
260+
if not auth_success:
232261
sys.stderr.write("FAIL\n")
233-
logging.warning("Authentication failed with SHA256, retrying with MD5: %s", auth_error)
262+
logging.error("Authentication Failed: %s", auth_error)
263+
return 1
234264

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
265+
sys.stderr.write("OK\n")
266+
logging.warning("====================================================================")
267+
logging.warning("WARNING: Device is using old MD5 authentication protocol (pre-3.3.1)")
268+
logging.warning("Please update to ESP32 Arduino Core 3.3.1+ for improved security.")
269+
logging.warning("======================================================================")
270+
271+
elif nonce_length == 64:
272+
# New protocol (3.3.1+) - try SHA256 password first, then MD5 if it fails
273+
274+
# Scenario 2: Try SHA256 password hash first (recommended for new devices)
275+
if md5_target:
276+
# User explicitly requested MD5 password hash
277+
logging.info("Using MD5 password hash as requested")
278+
sys.stderr.write("Authenticating (SHA256 protocol with MD5 password)...")
279+
sys.stderr.flush()
280+
auth_success, auth_error = authenticate(
281+
remote_addr, remote_port, password,
282+
use_md5_password=True, use_old_protocol=False,
283+
filename=filename, content_size=content_size, file_md5=file_md5, nonce=nonce
284+
)
285+
else:
286+
# Try SHA256 password hash first
287+
sys.stderr.write("Authenticating...")
288+
sys.stderr.flush()
289+
auth_success, auth_error = authenticate(
290+
remote_addr, remote_port, password,
291+
use_md5_password=False, use_old_protocol=False,
292+
filename=filename, content_size=content_size, file_md5=file_md5, nonce=nonce
238293
)
239-
if not success:
240-
logging.error("Failed to re-establish connection for MD5 retry: %s", error)
241-
return 1
242294

243-
if data.startswith("AUTH"):
244-
nonce = data.split()[1]
245-
sys.stderr.write("Retrying with MD5...")
295+
# Scenario 3: If SHA256 fails, try MD5 password hash (for devices with stored MD5 passwords)
296+
if not auth_success:
297+
logging.info("SHA256 password failed, trying MD5 password hash")
298+
sys.stderr.write("Retrying with MD5 password...")
246299
sys.stderr.flush()
247300
auth_success, auth_error = authenticate(
248-
remote_addr, remote_port, password, True, filename, content_size, file_md5, nonce
301+
remote_addr, remote_port, password,
302+
use_md5_password=True, use_old_protocol=False,
303+
filename=filename, content_size=content_size, file_md5=file_md5, nonce=nonce
249304
)
250-
else:
251-
auth_success = False
252-
auth_error = "Expected AUTH challenge for MD5 retry, got: " + data
253305

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
306+
if auth_success:
307+
logging.warning("====================================================================")
308+
logging.warning("WARNING: Device authenticated with MD5 password hash (deprecated)")
309+
logging.warning("MD5 is cryptographically broken and should not be used.")
310+
logging.warning("Please update your sketch to use setPassword() instead of")
311+
logging.warning("setPasswordHash() with MD5, then upload again to migrate to SHA256.")
312+
logging.warning("======================================================================")
313+
314+
if not auth_success:
260315
sys.stderr.write("FAIL\n")
261-
logging.error("Authentication failed: %s", auth_error)
316+
logging.error("Authentication Failed: %s", auth_error)
262317
return 1
263318

264-
sys.stderr.write("OK\n")
319+
sys.stderr.write("OK\n")
320+
else:
321+
logging.error("Invalid nonce length: %d (expected 32 or 64)", nonce_length)
322+
return 1
265323
else:
266324
logging.error("Bad Answer: %s", data)
267325
return 1
@@ -381,7 +439,7 @@ def parse_args(unparsed_args):
381439
"-m",
382440
"--md5-target",
383441
dest="md5_target",
384-
help="Target device is using MD5 checksum. This is insecure, use only for compatibility with old firmwares.",
442+
help="Use MD5 for password hashing (for devices with stored MD5 passwords). By default, SHA256 is tried first, then MD5 as fallback.",
385443
action="store_true",
386444
default=False,
387445
)

0 commit comments

Comments
 (0)