From 35c386039c993852954130a591b40f10a28c6dd0 Mon Sep 17 00:00:00 2001 From: Mathieu Carbou Date: Wed, 4 Nov 2026 14:59:47 +0100 Subject: [PATCH 1/6] Digest Auth: add authenticateDIgest in the API to allow a nore precise digest auth validation --- src/ESPAsyncWebServer.h | 2 ++ src/WebRequest.cpp | 47 +++++++++++++++++++++++++---------------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/ESPAsyncWebServer.h b/src/ESPAsyncWebServer.h index 9e616eba9..be2090634 100644 --- a/src/ESPAsyncWebServer.h +++ b/src/ESPAsyncWebServer.h @@ -707,7 +707,9 @@ class AsyncWebServerRequest { // base64(user:pass) for basic or // user:realm:md5(user:realm:pass) for digest bool authenticate(const char *hash) const; + bool authenticateDigest(const char *hash) const; bool authenticate(const char *username, const char *credentials, const char *realm = NULL, bool isHash = false) const; + bool authenticateDigest(const char *username, const char *credentials, const char *realm = NULL, bool isHash = false, const char *nonce = NULL, const char *opaque = NULL, const char *uri = NULL) const; void requestAuthentication(const char *realm = nullptr, bool isDigest = true) { requestAuthentication(isDigest ? AsyncAuthType::AUTH_DIGEST : AsyncAuthType::AUTH_BASIC, realm); } diff --git a/src/WebRequest.cpp b/src/WebRequest.cpp index 15e3692ad..af60bbe86 100644 --- a/src/WebRequest.cpp +++ b/src/WebRequest.cpp @@ -1287,7 +1287,7 @@ void AsyncWebServerRequest::redirect(const char *url, int code) { bool AsyncWebServerRequest::authenticate(const char *username, const char *password, const char *realm, bool passwordIsHash) const { if (_authorization.length()) { if (_authMethod == AsyncAuthType::AUTH_DIGEST) { - return checkDigestAuthentication(_authorization.c_str(), methodToString(), username, password, realm, passwordIsHash, NULL, NULL, NULL); + return authenticateDigest(username, password, realm, passwordIsHash, NULL, NULL, NULL); } else if (!passwordIsHash) { return checkBasicAuthentication(_authorization.c_str(), username, password); } else { @@ -1297,32 +1297,43 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw return false; } +bool AsyncWebServerRequest::authenticateDigest(const char *username, const char *password, const char *realm, bool passwordIsHash, const char *nonce, const char *opaque, const char *uri) const { + return checkDigestAuthentication(_authorization.c_str(), methodToString(), username, password, realm, passwordIsHash, nonce, opaque, uri); +} + bool AsyncWebServerRequest::authenticate(const char *hash) const { - if (!_authorization.length() || hash == NULL) { - return false; + if (_authMethod == AsyncAuthType::AUTH_DIGEST) { + return authenticateDigest(hash); } - if (_authMethod == AsyncAuthType::AUTH_DIGEST) { - String hStr = String(hash); - int separator = hStr.indexOf(':'); - if (separator <= 0) { - return false; - } - String username = hStr.substring(0, separator); - hStr = hStr.substring(separator + 1); - separator = hStr.indexOf(':'); - if (separator <= 0) { - return false; - } - String realm = hStr.substring(0, separator); - hStr = hStr.substring(separator + 1); - return checkDigestAuthentication(_authorization.c_str(), methodToString(), username.c_str(), hStr.c_str(), realm.c_str(), true, NULL, NULL, NULL); + if (!_authorization.length() || hash == NULL) { + return false; } // Basic Auth, Bearer Auth, or other return (_authorization.equals(hash)); } +bool AsyncWebServerRequest::authenticateDigest(const char *hash) const { + if (!_authorization.length() || hash == NULL) { + return false; + } + String hStr = String(hash); + int separator = hStr.indexOf(':'); + if (separator <= 0) { + return false; + } + String username = hStr.substring(0, separator); + hStr = hStr.substring(separator + 1); + separator = hStr.indexOf(':'); + if (separator <= 0) { + return false; + } + String realm = hStr.substring(0, separator); + hStr = hStr.substring(separator + 1); + return checkDigestAuthentication(_authorization.c_str(), methodToString(), username.c_str(), hStr.c_str(), realm.c_str(), true, NULL, NULL, NULL); +} + void AsyncWebServerRequest::requestAuthentication(AsyncAuthType method, const char *realm, const char *_authFailMsg) { if (!realm) { realm = T_LOGIN_REQ; From 8e7793fabf86b356c09913f850d7c5fc0a2375a0 Mon Sep 17 00:00:00 2001 From: Mathieu Carbou Date: Wed, 4 Nov 2026 15:11:38 +0100 Subject: [PATCH 2/6] Fix missing locks Locks were missing for AsyncWebSocket::printf but were there for AsyncWebSocket::binary and AsyncWebSocket::text --- src/AsyncWebSocket.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/AsyncWebSocket.cpp b/src/AsyncWebSocket.cpp index 8c159bc02..f616811d4 100644 --- a/src/AsyncWebSocket.cpp +++ b/src/AsyncWebSocket.cpp @@ -1347,6 +1347,7 @@ AsyncWebSocket::SendStatus AsyncWebSocket::binaryAll(AsyncWebSocketSharedBuffer } size_t AsyncWebSocket::printf(uint32_t id, const char *format, ...) { + asyncsrv::lock_guard_type lock(_ws_clients_lock); AsyncWebSocketClient *c = client(id); if (c) { va_list arg; @@ -1385,6 +1386,7 @@ size_t AsyncWebSocket::printfAll(const char *format, ...) { #ifdef ESP8266 size_t AsyncWebSocket::printf_P(uint32_t id, PGM_P formatP, ...) { + asyncsrv::lock_guard_type lock(_ws_clients_lock); AsyncWebSocketClient *c = client(id); if (c != NULL) { va_list arg; From 3bd1b21ce16930db5227f5bf024d6596140ef844 Mon Sep 17 00:00:00 2001 From: Mathieu Carbou Date: Wed, 4 Nov 2026 16:34:30 +0100 Subject: [PATCH 3/6] update gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 1cd7c3adc..2a6f6f74c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ /site /venv /build +/examples/arduino/**/*idf_component.yml +/examples/arduino/**/*idf_component.yml.orig From b9209084bf8f2b5e6b9eebe79f8fe967e79bb398 Mon Sep 17 00:00:00 2001 From: Mathieu Carbou Date: Wed, 4 Nov 2026 18:58:06 +0100 Subject: [PATCH 4/6] Move T_hex_chars --- src/AsyncWebServerRequest.cpp | 18 ++++++++---------- src/WebResponses.cpp | 9 ++++----- src/literals.h | 2 ++ 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/AsyncWebServerRequest.cpp b/src/AsyncWebServerRequest.cpp index 09fcee866..698963d88 100644 --- a/src/AsyncWebServerRequest.cpp +++ b/src/AsyncWebServerRequest.cpp @@ -71,8 +71,6 @@ void AsyncWebServerRequest::send(FS &fs, const String &path, const char *content * @return true if the ETag was successfully generated, false otherwise (e.g., file too short or seek failed). */ bool AsyncWebServerRequest::_getEtag(File gzFile, char *etag) { - static constexpr char hexChars[] = "0123456789ABCDEF"; - if (!gzFile.seek(gzFile.size() - 8)) { return false; } @@ -81,14 +79,14 @@ bool AsyncWebServerRequest::_getEtag(File gzFile, char *etag) { gzFile.read(reinterpret_cast(&crc), sizeof(crc)); etag[0] = '"'; - etag[1] = hexChars[(crc >> 4) & 0x0F]; - etag[2] = hexChars[crc & 0x0F]; - etag[3] = hexChars[(crc >> 12) & 0x0F]; - etag[4] = hexChars[(crc >> 8) & 0x0F]; - etag[5] = hexChars[(crc >> 20) & 0x0F]; - etag[6] = hexChars[(crc >> 16) & 0x0F]; - etag[7] = hexChars[(crc >> 28)]; - etag[8] = hexChars[(crc >> 24) & 0x0F]; + etag[1] = asyncsrv::T_hex_chars[(crc >> 4) & 0x0F]; + etag[2] = asyncsrv::T_hex_chars[crc & 0x0F]; + etag[3] = asyncsrv::T_hex_chars[(crc >> 12) & 0x0F]; + etag[4] = asyncsrv::T_hex_chars[(crc >> 8) & 0x0F]; + etag[5] = asyncsrv::T_hex_chars[(crc >> 20) & 0x0F]; + etag[6] = asyncsrv::T_hex_chars[(crc >> 16) & 0x0F]; + etag[7] = asyncsrv::T_hex_chars[(crc >> 28)]; + etag[8] = asyncsrv::T_hex_chars[(crc >> 24) & 0x0F]; etag[9] = '"'; etag[10] = '\0'; diff --git a/src/WebResponses.cpp b/src/WebResponses.cpp index 4280f494b..aa0b3919e 100644 --- a/src/WebResponses.cpp +++ b/src/WebResponses.cpp @@ -454,11 +454,10 @@ size_t AsyncAbstractResponse::write_send_buffs(AsyncWebServerRequest *request, s _fillBufferAndProcessTemplates(_send_buffer->data() + 6, std::min(_send_buffer->size(), tcp_win) - 8); // reserve 8 bytes for chunk size data if (readLen != RESPONSE_TRY_AGAIN) { // Write 4 hex digits directly without null terminator - static constexpr char hexChars[] = "0123456789abcdef"; - _send_buffer->data()[0] = hexChars[(readLen >> 12) & 0xF]; - _send_buffer->data()[1] = hexChars[(readLen >> 8) & 0xF]; - _send_buffer->data()[2] = hexChars[(readLen >> 4) & 0xF]; - _send_buffer->data()[3] = hexChars[readLen & 0xF]; + _send_buffer->data()[0] = asyncsrv::T_hex_chars[(readLen >> 12) & 0xF]; + _send_buffer->data()[1] = asyncsrv::T_hex_chars[(readLen >> 8) & 0xF]; + _send_buffer->data()[2] = asyncsrv::T_hex_chars[(readLen >> 4) & 0xF]; + _send_buffer->data()[3] = asyncsrv::T_hex_chars[readLen & 0xF]; _send_buffer->data()[4] = '\r'; _send_buffer->data()[5] = '\n'; // data (readLen bytes) is already there diff --git a/src/literals.h b/src/literals.h index 85d04dfd6..5ae2d8e56 100644 --- a/src/literals.h +++ b/src/literals.h @@ -101,11 +101,13 @@ static constexpr const char T_nn[] = "\n\n"; static constexpr const char T_rn[] = "\r\n"; static constexpr const char T_rnrn[] = "\r\n\r\n"; static constexpr const char T_Server[] = "Server"; +static constexpr const char T_stale_true[] = ", stale=true"; static constexpr const char T_Transfer_Encoding[] = "Transfer-Encoding"; static constexpr const char T_TRUE[] = "true"; static constexpr const char T_UPGRADE[] = "Upgrade"; static constexpr const char T_uri[] = "uri"; static constexpr const char T_username[] = "username"; +static constexpr const char T_hex_chars[] = "0123456789abcdef"; static constexpr const char T_WS[] = "websocket"; static constexpr const char T_WWW_AUTH[] = "WWW-Authenticate"; static constexpr const char T_X_Expected_Entity_Length[] = "X-Expected-Entity-Length"; From a8ffa6a21f3c75d8f7f69cc37e87e26e5dca74b0 Mon Sep 17 00:00:00 2001 From: Mathieu Carbou Date: Wed, 4 Nov 2026 19:09:13 +0100 Subject: [PATCH 5/6] fix(auth): Digest authentication replay protection (stateless signed nonces) Digest challenges now include server-side anti-replay protections, with no server-side per-nonce storage (server stays stateless): - the challenge nonce embeds its issuing time and is signed with a per-instance secret (HMAC-MD5): forged, foreign or expired nonces are rejected; expiry issues a challenge with stale=true (RFC 7616 sec. 3.3) so compliant clients re-authenticate silently; - the opaque is derived from the instance secret and identifies the server; - the header uri field is bound to the effective request URI (RFC 7230 sec. 5.5 raw request-target, path + query), taken before decoding and rewrites: a captured header cannot be replayed on another endpoint nor with a different query string. AsyncWebServerRequest::authenticateDigest() keeps its signature: NULL nonce/opaque/uri arguments mean automatic (library) validation, values take precedence. authenticate() defaults to the full stateless check. New request API: rawUri() (effective request URI), authStale(). New server API: setDigestNonceTimeout(), setDigestSecret(). New build flag: ASYNCWEBSERVER_DIGEST_NONCE_TTL (default 60s). Consolidated hex digit tables in literals.h (T_hex_chars, lowercase). Example: examples/arduino/Auth gains endpoints for digest-with-hash and test_digest.sh which verifies replay protection live. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/configuration.md | 5 + docs/middleware.md | 46 +++++ examples/arduino/Auth/Auth.ino | 5 +- examples/arduino/Auth/test_digest.sh | 90 +++++++++ src/ESPAsyncWebServer.h | 98 +++++++++- src/WebAuthentication.cpp | 271 +++++++++++++++++++++++++++ src/WebAuthentication.h | 39 ++++ src/WebRequest.cpp | 90 +++++++-- src/WebServer.cpp | 7 + 9 files changed, 629 insertions(+), 22 deletions(-) create mode 100755 examples/arduino/Auth/test_digest.sh diff --git a/docs/configuration.md b/docs/configuration.md index 728d245a7..453e691d0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,6 +19,11 @@ Here are some recommendations to avoid them and build-time flags you can change. - `ASYNCWEBSERVER_USE_CHUNK_INFLIGHT`: inflight control for chunked responses. If you need to serve chunk requests with a really low buffer (which should be avoided), you can set `-D ASYNCWEBSERVER_USE_CHUNK_INFLIGHT=0` to disable the in-flight control. +- `ASYNCWEBSERVER_DIGEST_NONCE_TTL`: validity window, in seconds, of a Digest authentication nonce (default: 60). + Digest replay protection uses stateless signed nonces: beyond this window a nonce is stale + and clients silently re-authenticate via the `stale=true` challenge directive. Set to 0 to disable expiry + (not recommended: widens the replay window). Can also be tuned at runtime with `server.setDigestNonceTimeout()`. + > [!NOTE] > This relates to ESP32 only, ESP8266 uses different ESPAsyncTCP lib that does not has this build options diff --git a/docs/middleware.md b/docs/middleware.md index b829ffbbd..fe98cbd04 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -116,6 +116,52 @@ myHandler.addMiddleware(&authMiddleware); // add authentication to a specific ha See the [Auth example here](https://github.com/ESP32Async/ESPAsyncWebServer/blob/master/examples/arduino/Auth/Auth.ino). +### Digest authentication: replay protection + +Digest authentication includes server-side protections against replay attacks, using a **stateless** scheme +(no per-nonce storage, keeping the server's memory footprint constant): + +- **Signed nonce**: each challenge nonce embeds its issuing time and is signed with a per-server-instance + secret (`HMAC-MD5`). A nonce not issued by this server is rejected, and an expired nonce triggers a + new challenge with `stale=true` (RFC 7616 §3.3), so compliant clients (browsers, curl) re-authenticate + silently without prompting the user again. +- **URI binding**: the `uri` field of the `Authorization: Digest ...` header must match the effective + request URI (raw request-target: path **and** query string). A header captured on one endpoint cannot + be replayed against another endpoint, nor against the same endpoint with a different query string. +- **Opaque**: each challenge carries an opaque derived from the instance secret, identifying the server + instance that issued the nonce. + +The nonce validity window defaults to 60 seconds (`ASYNCWEBSERVER_DIGEST_NONCE_TTL` build flag) and can be +tuned at runtime: + +```c++ +server.setDigestNonceTimeout(300); // extend nonce lifetime to 5 minutes +``` + +Changing the instance secret instantly revokes every nonce in circulation (all clients get re-challenged): + +```c++ +uint8_t secret[16] = {/* 16 random bytes */}; +server.setDigestSecret(secret); // kill-switch: invalidates all outstanding nonces +``` + +> [!NOTE] +> As with any stateless scheme, a captured `Authorization: Digest ...` header remains valid for the +> remainder of the nonce TTL **on the exact same request URI only**. For one-shot semantics, keep the +> TTL short and serve Digest-protected endpoints over HTTPS. + +For custom validation of every digest parameter, `AsyncWebServerRequest::authenticateDigest()` accepts +optional `nonce`, `opaque` and `uri` arguments — a NULL argument lets the library validate it +automatically, while a non-NULL value is compared verbatim against the caller-supplied value: + +```c++ +// the library validates nonce, opaque and the effective request URI +request->authenticateDigest("admin", "admin", "MyApp"); + +// the caller takes over the uri check (nonce/opaque still validated by the library) +request->authenticateDigest("admin", "admin", "MyApp", false, NULL, NULL, "/my/custom/uri"); +``` + ### Migration to Middleware to improve performance and memory usage - `AsyncEventSource.authorizeConnect(...)` => do not use this method anymore: add a common `AsyncAuthorizationMiddleware` to the handler or server, and make sure to add it AFTER the `AsyncAuthenticationMiddleware` if you use authentication. diff --git a/examples/arduino/Auth/Auth.ino b/examples/arduino/Auth/Auth.ino index b2dfb89a1..e4d6724dd 100644 --- a/examples/arduino/Auth/Auth.ino +++ b/examples/arduino/Auth/Auth.ino @@ -157,12 +157,15 @@ void setup() { .addMiddleware(&digestAuth); // digest authentication with hash + // protected with a hash: MD5(user:realm:pass) so no plain password is stored in RAM + // this is the privileged endpoint used by test_digest.sh to prove that a header + // captured on /auth-digest cannot be replayed on another digest-protected URI // curl -v -u admin:admin --digest http://192.168.4.1/auth-digest-hash server .on( "/auth-digest-hash", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(200, "text/plain", "Hello, world!"); + request->send(200, "text/plain", "DIGEST-HASH-ONLY: sensitive action"); } ) .addMiddleware(&digestAuthHash); diff --git a/examples/arduino/Auth/test_digest.sh b/examples/arduino/Auth/test_digest.sh new file mode 100755 index 000000000..022a8980b --- /dev/null +++ b/examples/arduino/Auth/test_digest.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +# Digest authentication replay protection test +# Tests the digest endpoints defined in Auth.ino +# +# Usage: ./test_digest.sh [SERVER_IP] + +SERVER_IP="${1:-192.168.4.1}" +SERVER_PORT="${2:-80}" +BASE_URL="http://${SERVER_IP}:${SERVER_PORT}" +AUTH_USER="admin" +AUTH_PASS="admin" + +echo "Testing Digest auth replay protections at $BASE_URL" +echo "=====================================================" + +NB_PASS=0 +NB_FAIL=0 + +ok() { echo "✅ PASS ($1)"; ((NB_PASS++)); } +ko() { echo "❌ FAIL ($1)"; ((NB_FAIL++)); } + +# check(status, expected, description) +check() { + if [ "$1" = "$2" ]; then ok "$3: $1"; else ko "$3: expected $2, got $1"; fi +} + +# get_challenge(path): prints "nonce|opaque" of the last received challenge +get_challenge() { + curl -s -i "$BASE_URL$1" | grep -i "^WWW-Authenticate:" | cut -d' ' -f2- | tr -d '\r' +} + +extract() { + echo "$1" | grep -o "$2=\"[^\"]*\"" | cut -d'"' -f2 +} + +# build_auth(nonce, opaque, uri): prints a valid Authorization header for admin/admin +build_auth() { + local nonce="$1" opaque="$2" uri="$3" + local cnonce ha1 ha2 resp + cnonce=$(echo "$nonce$opaque" | md5 | cut -c1-16) + ha1=$(echo -n "$AUTH_USER:MyApp:$AUTH_PASS" | md5 | cut -d' ' -f1) + ha2=$(echo -n "GET:$uri" | md5 | cut -d' ' -f1) + resp=$(echo -n "$ha1:$nonce:00000001:$cnonce:auth:$ha2" | md5 | cut -d' ' -f1) + echo "Digest username=\"$AUTH_USER\", realm=\"MyApp\", nonce=\"$nonce\", uri=\"$uri\", response=\"$resp\", cnonce=\"$cnonce\", nc=00000001, qop=auth, opaque=\"$opaque\"" +} + +# 1. legitimate digest auth on both endpoints (curl --digest) +STATUS=$(curl -s -o /dev/null -w "%{http_code}" --digest -u ""$AUTH_USER:$AUTH_PASS"" "$BASE_URL/auth-digest") +check "$STATUS" "200" "1a. legit digest auth /auth-digest" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" --digest -u ""$AUTH_USER:$AUTH_PASS"" "$BASE_URL/auth-digest-hash") +check "$STATUS" "200" "1b. legit digest auth /auth-digest-hash" + +# 2. cross-endpoint replay: an Authorization header captured for /auth-digest +# (replayed verbatim: response hash still computed for uri="/auth-digest") +# must be rejected on /auth-digest-hash, which has the same credentials and realm. +CH=$(get_challenge /auth-digest) +NONCE=$(extract "$CH" nonce) +OPAQUE=$(extract "$CH" opaque) +AUTH=$(build_auth "$NONCE" "$OPAQUE" "/auth-digest") +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: $AUTH" "$BASE_URL/auth-digest-hash") +check "$STATUS" "401" "2. cross-endpoint verbatim replay rejected" + +# 3. forged nonce must be rejected even with valid credentials and matching uri +CH=$(get_challenge /auth-digest) +NONCE=$(extract "$CH" nonce) +OPAQUE=$(extract "$CH" opaque) +FORGED="deadbeef00000000000000000000000000000000000000000000000000000000" +AUTH=$(build_auth "$FORGED" "$OPAQUE" "/auth-digest") +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: $AUTH" "$BASE_URL/auth-digest") +check "$STATUS" "401" "3. forged nonce rejected" + +# 4. foreign opaque must be rejected +AUTH=$(build_auth "$NONCE" "00000000000000000000000000000000" "/auth-digest") +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: $AUTH" "$BASE_URL/auth-digest") +check "$STATUS" "401" "4. foreign opaque rejected" + +# 5. query string is part of the uri binding +STATUS=$(curl -s -o /dev/null -w "%{http_code}" --digest -u ""$AUTH_USER:$AUTH_PASS"" "$BASE_URL/auth-digest?a=1") +check "$STATUS" "200" "5a. legit digest auth with query string" +CH=$(get_challenge /auth-digest) +NONCE=$(extract "$CH" nonce) +OPAQUE=$(extract "$CH" opaque) +AUTH=$(build_auth "$NONCE" "$OPAQUE" "/auth-digest") +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: $AUTH" "$BASE_URL/auth-digest?a=1") +check "$STATUS" "401" "5b. replay with different query string rejected" + +echo "" +echo "Results: $NB_PASS passed, $NB_FAIL failed" +[ "$NB_FAIL" -eq 0 ] \ No newline at end of file diff --git a/src/ESPAsyncWebServer.h b/src/ESPAsyncWebServer.h index be2090634..b7ffc7cc0 100644 --- a/src/ESPAsyncWebServer.h +++ b/src/ESPAsyncWebServer.h @@ -58,8 +58,17 @@ #endif #endif +// Validity window, in seconds, of a Digest authentication nonce. +// Statelessness relies on the nonce embedding its issuing time: beyond this window +// the nonce is stale and clients must re-authenticate (silently, via stale=true). +// Set to 0 to disable nonce expiry (not recommended: widens the replay window). +#if !defined(ASYNCWEBSERVER_DIGEST_NONCE_TTL) +#define ASYNCWEBSERVER_DIGEST_NONCE_TTL 60 +#endif + #include "AsyncWebServerVersion.h" #define ASYNCWEBSERVER_FORK_ESP32Async +#include "WebAuthentication.h" #ifdef ASYNCWEBSERVER_REGEX #include @@ -549,8 +558,15 @@ class AsyncWebServerRequest { String _contentType; String _boundary; String _authorization; + // Effective request URI (RFC 7230 §5.5): raw request-target — path + query string, + // percent-encoded form as received on the request line, before any rewriting. + // Used to validate the "uri" parameter of Digest authentication. + String _rawUri; RequestedConnectionType _reqconntype; AsyncAuthType _authMethod = AsyncAuthType::AUTH_NONE; + // set by authenticateDigest() when the client nonce was valid but expired (RFC 7616 §3.3): + // the next challenge must carry stale=true so compliant clients silently re-authenticate + mutable bool _authStale = false; bool _isMultipart; bool _isPlainPost; bool _expectingContinue; @@ -658,6 +674,13 @@ class AsyncWebServerRequest { const String &url() const { return _url; } + // Effective request URI (RFC 7230 §5.5): raw request-target — path + query string + // in the exact percent-encoded form received on the request line, before URL decoding + // and before any rewrite is applied. Useful to build canonical links, audit raw input, + // or implement authentication schemes binding to the request target (e.g. HTTP Digest). + const String &rawUri() const { + return _rawUri; + } const String &host() const { return _host; } @@ -709,7 +732,40 @@ class AsyncWebServerRequest { bool authenticate(const char *hash) const; bool authenticateDigest(const char *hash) const; bool authenticate(const char *username, const char *credentials, const char *realm = NULL, bool isHash = false) const; - bool authenticateDigest(const char *username, const char *credentials, const char *realm = NULL, bool isHash = false, const char *nonce = NULL, const char *opaque = NULL, const char *uri = NULL) const; + + /** + * @brief Digest authentication with server-side anti-replay protection. + * + * Beyond the standard digest verification, this call validates that: + * - the nonce was issued by this server and is not expired (stateless signed nonce); + * - the opaque is the one bound to this server instance; + * - the header "uri" matches the actual request-target (path + query string). + * + * A NULL nonce/opaque/uri argument means "let the library validate it automatically"; + * passing a value means the caller takes over that specific check. + * + * @param username user name + * @param credentials password or pre-computed digest hash (see isHash) + * @param realm authentication realm (NULL: not checked) + * @param isHash true if credentials is md5(user:realm:password) instead of the raw password + * @param nonce NULL to validate the server-issued signed nonce, or the exact value to compare against + * @param opaque NULL to validate the instance opaque, or the exact value to compare against + * @param uri NULL to validate against the effective request URI, or the exact value to compare against + * @return true if the request is authenticated + */ + bool authenticateDigest( + const char *username, const char *credentials, const char *realm = NULL, bool isHash = false, const char *nonce = NULL, const char *opaque = NULL, + const char *uri = NULL + ) const; + /** + * @brief true if the last authenticateDigest() call failed only because the nonce expired. + * + * When set, requestAuthentication() issues the next challenge with stale=true (RFC 7616 §3.3), + * so compliant clients re-authenticate silently without prompting the user again. + */ + bool authStale() const { + return _authStale; + } void requestAuthentication(const char *realm = nullptr, bool isDigest = true) { requestAuthentication(isDigest ? AsyncAuthType::AUTH_DIGEST : AsyncAuthType::AUTH_BASIC, realm); } @@ -1786,6 +1842,8 @@ typedef AsyncCallbackJsonWebHandler AsyncCallbackMessagePackWebHandler; class AsyncWebServer : public AsyncMiddlewareChain { protected: + friend class AsyncWebServerRequest; + AsyncServer _server; std::list> _rewrites; // Unified handler list: each entry is an asyncsrv::MaybeOwned. @@ -1796,6 +1854,13 @@ class AsyncWebServer : public AsyncMiddlewareChain { // A catch-all handler is always installed (never nullptr). asyncsrv::MaybeOwned _catchAllHandler; + // Stateless Digest auth anti-replay: the nonce embeds + // its issuing time and is HMAC-signed with this per-instance secret, so no + // per-nonce storage is required. The opaque is derived from the same secret. + uint8_t _digestSecret[16] = {0}; + uint32_t _digestNonceTtl = ASYNCWEBSERVER_DIGEST_NONCE_TTL * 1000; // milliseconds + String _digestOpaque; + public: /** * @brief Construct a server with a default internal catch-all handler. @@ -2034,6 +2099,37 @@ class AsyncWebServer : public AsyncMiddlewareChain { ArBodyHandlerFunction onBody = nullptr ); + /** + * @brief Set the validity window of Digest nonces issued by this server (stateless scheme). + * + * Nonces embed their issuing time and are signed with a per-instance secret; no + * server-side storage is kept. 0 disables expiry (not recommended). + * @param seconds nonce lifetime in seconds (default: ASYNCWEBSERVER_DIGEST_NONCE_TTL, 60s) + */ + void setDigestNonceTimeout(uint32_t seconds) { + _digestNonceTtl = seconds * 1000; + } + + /** + * @brief Set a custom secret used to sign Digest nonces and derive the opaque value. + * + * Changing the secret instantly revokes every nonce in circulation (clients will + * be re-challenged and silently re-authenticate). If never called, a random + * per-instance secret is generated at startup. + * @param secret pointer to 16 raw bytes + */ + void setDigestSecret(const uint8_t secret[16]) { + if (secret) { + memcpy(_digestSecret, secret, sizeof(_digestSecret)); + _digestOpaque = generateDigestOpaque(_digestSecret); + } + } + + /** @brief Digest opaque value derived from the instance secret. */ + const String &digestOpaque() const { + return _digestOpaque; + } + #if ASYNC_JSON_SUPPORT == 1 AsyncCallbackJsonWebHandler &on(AsyncURIMatcher uri, WebRequestMethodComposite method, ArJsonRequestHandlerFunction onBody); #endif diff --git a/src/WebAuthentication.cpp b/src/WebAuthentication.cpp index a7805dc8b..8f2d75a83 100644 --- a/src/WebAuthentication.cpp +++ b/src/WebAuthentication.cpp @@ -5,6 +5,14 @@ #include "AsyncWebServerLogging.h" #include +#include + +#if defined(ESP32) +#include +#elif defined(ESP8266) +#include +#endif + #if defined(ESP32) || defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) || defined(HOST) #include #else @@ -97,6 +105,215 @@ String genRandomMD5() { return res; } +void generateSecret(uint8_t secret[16]) { +#if defined(ESP32) + for (uint8_t i = 0; i < 16; i++) { + secret[i] = (uint8_t)esp_random(); + } +#elif defined(ESP8266) + uint32_t r[4] = {RANDOM_REG32, RANDOM_REG32, RANDOM_REG32, RANDOM_REG32}; + memcpy(secret, r, 16); +#else + // HOST is POSIX: read from /dev/urandom, falling back to rand() + FILE *f = fopen("/dev/urandom", "rb"); + if (f) { + size_t read = fread(secret, 1, 16, f); + fclose(f); + if (read == 16) { + return; + } + } + for (uint8_t i = 0; i < 16; i++) { + secret[i] = (uint8_t)rand(); + } +#endif +} + +// Adds a buffer to a MD5Builder, working around the pre-3.x core where add() +// takes a non-const uint8_t* although it never modifies the data. +static inline void md5Add(MD5Builder &md5, const uint8_t *data, size_t len) { +#if defined(ESP32) && ESP_ARDUINO_VERSION_MAJOR < 3 + md5.add(const_cast(data), len); +#else + md5.add(data, len); +#endif +} + +// HMAC-MD5, RFC 2104 +void hmacMD5(const uint8_t *key, size_t keyLen, const uint8_t *data, size_t dataLen, uint8_t out[16]) { + uint8_t k0[64]; + memset(k0, 0, sizeof(k0)); + if (keyLen > 64) { + // keys longer than block size are hashed first +#if defined(ESP32) || defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) || defined(HOST) + MD5Builder md5; + md5.begin(); + md5Add(md5, key, keyLen); + md5.calculate(); + md5.getBytes(k0); // writes exactly 16 bytes +#else + md5_context_t ctx; + MD5Init(&ctx); + MD5Update(&ctx, key, keyLen); + MD5Final(k0, &ctx); +#endif + } else { + memcpy(k0, key, keyLen); + } + + uint8_t inner[64], outer[64]; + for (uint8_t i = 0; i < 64; i++) { + inner[i] = k0[i] ^ 0x36; + outer[i] = k0[i] ^ 0x5c; + } + + uint8_t digest[16]; +#if defined(ESP32) || defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) || defined(HOST) + MD5Builder md5; + md5.begin(); + md5Add(md5, inner, 64); + md5Add(md5, data, dataLen); + md5.calculate(); + md5.getBytes(digest); // writes exactly 16 bytes + md5 = MD5Builder(); + md5.begin(); + md5Add(md5, outer, 64); + md5Add(md5, digest, 16); + md5.calculate(); + md5.getBytes(out); // writes exactly 16 bytes +#else + md5_context_t ctx; + MD5Init(&ctx); + MD5Update(&ctx, inner, 64); + MD5Update(&ctx, data, dataLen); + MD5Final(digest, &ctx); + MD5Init(&ctx); + MD5Update(&ctx, outer, 64); + MD5Update(&ctx, digest, 16); + MD5Final(out, &ctx); +#endif +} + +// Lowercase hex encoding of a buffer (output length is 2 * len + 1). +static String toHexDigest(const uint8_t *buf, size_t len) { + String out; + if (!out.reserve(len * 2)) { + return asyncsrv::emptyString; + } + for (size_t i = 0; i < len; i++) { + out += T_hex_chars[buf[i] >> 4]; + out += T_hex_chars[buf[i] & 0x0f]; + } + return out; +} + +// nonce = hex(millis) + hex(HMAC-MD5(secret, hex(millis))) : 8 + 32 = 40 chars +String generateDigestNonce(const uint8_t secret[16], uint32_t now) { + char ts[9]; + snprintf(ts, sizeof(ts), "%08x", now); + uint8_t mac[16]; + hmacMD5(secret, 16, (const uint8_t *)ts, 8, mac); + String macHex = toHexDigest(mac, 16); + if (!macHex.length()) { + async_ws_log_e("Failed to allocate"); + return asyncsrv::emptyString; + } + String nonce; + if (!nonce.reserve(40)) { + async_ws_log_e("Failed to allocate"); + return asyncsrv::emptyString; + } + nonce.concat(ts, 8); + nonce.concat(macHex); + return nonce; +} + +String generateDigestOpaque(const uint8_t secret[16]) { + uint8_t opaqueMac[16]; + hmacMD5(secret, 16, (const uint8_t *)T_opaque, strlen(T_opaque), opaqueMac); + String opaque = toHexDigest(opaqueMac, 16); + if (!opaque.length()) { + async_ws_log_e("Failed to allocate"); + } + return opaque; +} + +bool validateDigestNonce(const char *nonce, const uint8_t secret[16], uint32_t now, uint32_t ttl, bool &stale) { + stale = false; + if (nonce == NULL || strlen(nonce) != 40) { + return false; + } + char ts[9]; + memcpy(ts, nonce, 8); + ts[8] = '\0'; + uint32_t issued; + if (sscanf(ts, "%08x", &issued) != 1) { + return false; + } + // recompute the MAC part and compare in constant time, without any allocation + uint8_t mac[16]; + hmacMD5(secret, 16, (const uint8_t *)ts, 8, mac); + const char *got = nonce + 8; + uint8_t diff = 0; + for (uint8_t i = 0; i < 16; i++) { + diff |= (uint8_t)T_hex_chars[mac[i] >> 4] ^ (uint8_t)got[i * 2]; + diff |= (uint8_t)T_hex_chars[mac[i] & 0x0f] ^ (uint8_t)got[i * 2 + 1]; + } + if (diff) { + return false; + } + // modular arithmetic on the monotonic counters: safe across millis() wrap-around + if (ttl && (uint32_t)(now - issued) > ttl) { + stale = true; // issued by this server (MAC valid) but expired + } + return true; +} + +// Extracts one parameter value from a Digest header (e.g. getDigestParam(header, "nonce", out)). +// Quote-aware: values in quoted form are returned without their quotes. +// Returns false when the parameter is absent; an empty value yields an empty out string. +bool getDigestParam(const char *header, const char *name, String &out) { + out = asyncsrv::emptyString; + if (header == NULL || name == NULL) { + return false; + } + const size_t nameLen = strlen(name); + const char *p = header; + const char *end = header + strlen(header); + while (p < end) { + // skip leading separators + while (p < end && (*p == ',' || *p == ' ')) { + p++; + } + if (p >= end) { + return false; + } + // match the parameter name at this boundary, followed by '=' + if ((size_t)(end - p) > nameLen && memcmp(p, name, nameLen) == 0 && p[nameLen] == '=') { + const char *val = p + nameLen + 1; + if (*val == '"') { + val++; + const char *quote = strchr(val, '"'); + if (!quote) { + return false; // unterminated quoted value + } + return out.concat(val, quote - val); + } + const char *valEnd = val; + while (valEnd < end && *valEnd != ',') { + valEnd++; + } + return out.concat(val, valEnd - val); + } + // not this parameter: skip to the next separator + p = strchr(p, ','); + if (!p) { + return false; + } + } + return false; +} + static String stringMD5(const String &in) { char *out = (char *)malloc(33); if (out == NULL || !getMD5((uint8_t *)(in.c_str()), in.length(), out)) { @@ -238,3 +455,57 @@ bool checkDigestAuthentication( // os_printf("AUTH FAIL: password\n"); return false; } + +// Full digest verification with stateless server-side checks. +// The nonce embeds its issuing time and is signed with the per-server secret, so no +// per-nonce storage is required: any nonce not signed by this server, or expired, is rejected. +// The uri field is validated against the effective request URI (raw request-target, path + query). +// Fails closed: on allocation failure the request is rejected. +DigestAuthStatus checkDigestAuthentication( + const char *header, const char *method, const char *username, const char *password, const char *realm, bool passwordIsHash, const char *rawUri, + const uint8_t digestSecret[16], uint32_t digestNonceTtl +) { + if (header == NULL || method == NULL || username == NULL || password == NULL || digestSecret == NULL) { + return DigestAuthStatus::FAILED; + } + + String myNonce; + String myOpaque; + if (!getDigestParam(header, T_nonce, myNonce) || !getDigestParam(header, T_opaque, myOpaque)) { + return DigestAuthStatus::FAILED; // malformed header: missing mandatory fields + } + + // opaque must be the one bound to this server instance (compare on the stack, no allocation) + uint8_t opaqueMac[16]; + hmacMD5(digestSecret, 16, (const uint8_t *)T_opaque, strlen(T_opaque), opaqueMac); + if (myOpaque.length() != 32) { + return DigestAuthStatus::FAILED; + } + uint8_t diff = 0; + const char *got = myOpaque.c_str(); + for (uint8_t i = 0; i < 16; i++) { + diff |= (uint8_t)T_hex_chars[opaqueMac[i] >> 4] ^ (uint8_t)got[i * 2]; + diff |= (uint8_t)T_hex_chars[opaqueMac[i] & 0x0f] ^ (uint8_t)got[i * 2 + 1]; + } + if (diff) { + async_ws_log_e("Digest auth: invalid opaque"); + return DigestAuthStatus::FAILED; + } + + // nonce must have been issued by this server, and must not be expired + bool stale = false; + if (!validateDigestNonce(myNonce.c_str(), digestSecret, millis(), digestNonceTtl, stale)) { + async_ws_log_e("Digest auth: invalid nonce"); + return DigestAuthStatus::FAILED; + } + if (stale) { + return DigestAuthStatus::STALE_NONCE; + } + + // credentials + response hash, with the uri bound to the actual request-target + if (!checkDigestAuthentication(header, method, username, password, realm, passwordIsHash, NULL, NULL, rawUri)) { + return DigestAuthStatus::FAILED; + } + + return DigestAuthStatus::SUCCESS; +} diff --git a/src/WebAuthentication.h b/src/WebAuthentication.h index a4819d60a..d32292aaf 100644 --- a/src/WebAuthentication.h +++ b/src/WebAuthentication.h @@ -5,6 +5,13 @@ #include "Arduino.h" +// Outcome of digest verification when server-side checks (nonce/opaque/uri) are applied. +enum class DigestAuthStatus : uint8_t { + FAILED = 0, // credentials or header rejected + SUCCESS, // fully verified + STALE_NONCE, // nonce was issued by this server but has expired: re-challenge with stale=true +}; + bool checkBasicAuthentication(const char *header, const char *username, const char *password); bool checkDigestAuthentication( @@ -12,6 +19,38 @@ bool checkDigestAuthentication( const char *opaque, const char *uri ); +// Full verification: parses the Digest header, checks credentials, and — using the +// stateless scheme — validates the server-issued signed nonce, the instance opaque and +// the effective request URI. No per-nonce storage is kept. +// now is expected to be a monotonic millisecond counter (e.g. millis()). +DigestAuthStatus checkDigestAuthentication( + const char *header, const char *method, const char *username, const char *password, const char *realm, bool passwordIsHash, const char *rawUri, + const uint8_t digestSecret[16], uint32_t digestNonceTtl +); + +// Extracts one parameter value from a Digest header (e.g. getDigestParam(header, "nonce", out)). +// Quote-aware: values in quoted form are returned without their quotes. +// Returns false when the parameter is absent; an empty value yields an empty out string. +bool getDigestParam(const char *header, const char *name, String &out); + +// Stateless signed nonce: hex(millis) + hex(HMAC-MD5(secret, hex(millis))) : 40 chars. +// Validity is re-computed from the embedded timestamp, without any server-side storage. +String generateDigestNonce(const uint8_t secret[16], uint32_t now); + +// Opaque value derived from the instance secret, identifying this server instance. +String generateDigestOpaque(const uint8_t secret[16]); + +// Validates a signed nonce. Returns false when the nonce was not issued from this +// secret. When true and the nonce has expired, stale is set: the caller should +// re-challenge with stale=true (RFC 7616 section 3.3) so compliant clients silently re-authenticate. +bool validateDigestNonce(const char *nonce, const uint8_t secret[16], uint32_t now, uint32_t ttl, bool &stale); + +// HMAC-MD5 (RFC 2104) +void hmacMD5(const uint8_t *key, size_t keyLen, const uint8_t *data, size_t dataLen, uint8_t out[16]); + +// Fill a 16-byte secret with best available platform entropy. +void generateSecret(uint8_t secret[16]); + // for storing hashed versions on the device that can be authenticated against String generateDigestHash(const char *username, const char *password, const char *realm); diff --git a/src/WebRequest.cpp b/src/WebRequest.cpp index af60bbe86..c653cc52d 100644 --- a/src/WebRequest.cpp +++ b/src/WebRequest.cpp @@ -38,7 +38,7 @@ enum { AsyncWebServerRequest::AsyncWebServerRequest(AsyncWebServer *s, AsyncClient *c) : _client(c), _server(s), _handler(NULL), _response(NULL), _onDisconnectfn(NULL), _temp(), _parseState(PARSE_REQ_START), _version(0), - _method(AsyncWebRequestMethod::HTTP_UNKNOWN), _url(), _host(), _contentType(), _boundary(), _authorization(), _reqconntype(RCT_HTTP), + _method(AsyncWebRequestMethod::HTTP_UNKNOWN), _url(), _host(), _contentType(), _boundary(), _authorization(), _rawUri(), _reqconntype(RCT_HTTP), _authMethod(AsyncAuthType::AUTH_NONE), _isMultipart(false), _isPlainPost(false), _expectingContinue(false), _contentLength(0), _parsedLength(0), _multiParseState(0), _boundaryPosition(0), _itemStartIndex(0), _itemSize(0), _itemName(), _itemFilename(), _itemType(), _itemValue(), _itemBuffer(0), _itemBufferIndex(0), _itemIsFile(false), _chunkStartIndex(0), _chunkOffset(0), _chunkSize(0), _chunkedParseState(CHUNK_NONE), _chunkedLastChar(0), @@ -327,6 +327,9 @@ bool AsyncWebServerRequest::_parseReqHead() { return false; } + // keep the effective request URI (RFC 7230 §5.5): raw request-target, path + query string + _rawUri = u; + String g; index = u.indexOf('?'); if (index > 0) { @@ -1297,8 +1300,28 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw return false; } -bool AsyncWebServerRequest::authenticateDigest(const char *username, const char *password, const char *realm, bool passwordIsHash, const char *nonce, const char *opaque, const char *uri) const { - return checkDigestAuthentication(_authorization.c_str(), methodToString(), username, password, realm, passwordIsHash, nonce, opaque, uri); +bool AsyncWebServerRequest::authenticateDigest( + const char *username, const char *password, const char *realm, bool passwordIsHash, const char *nonce, const char *opaque, const char *uri +) const { + _authStale = false; + if (!_authorization.length()) { + return false; + } + + // caller-provided values take precedence; NULL means the library validates it + if (nonce != NULL || opaque != NULL || uri != NULL) { + return checkDigestAuthentication(_authorization.c_str(), methodToString(), username, password, realm, passwordIsHash, nonce, opaque, uri); + } + + // stateless anti-replay validation + DigestAuthStatus status = checkDigestAuthentication( + _authorization.c_str(), methodToString(), username, password, realm, passwordIsHash, _rawUri.c_str(), _server->_digestSecret, _server->_digestNonceTtl + ); + if (status == DigestAuthStatus::STALE_NONCE) { + _authStale = true; + return false; + } + return status == DigestAuthStatus::SUCCESS; } bool AsyncWebServerRequest::authenticate(const char *hash) const { @@ -1315,6 +1338,7 @@ bool AsyncWebServerRequest::authenticate(const char *hash) const { } bool AsyncWebServerRequest::authenticateDigest(const char *hash) const { + _authStale = false; if (!_authorization.length() || hash == NULL) { return false; } @@ -1331,7 +1355,17 @@ bool AsyncWebServerRequest::authenticateDigest(const char *hash) const { } String realm = hStr.substring(0, separator); hStr = hStr.substring(separator + 1); - return checkDigestAuthentication(_authorization.c_str(), methodToString(), username.c_str(), hStr.c_str(), realm.c_str(), true, NULL, NULL, NULL); + + // stateless anti-replay validation + DigestAuthStatus status = checkDigestAuthentication( + _authorization.c_str(), methodToString(), username.c_str(), hStr.c_str(), realm.c_str(), true, _rawUri.c_str(), _server->_digestSecret, + _server->_digestNonceTtl + ); + if (status == DigestAuthStatus::STALE_NONCE) { + _authStale = true; + return false; + } + return status == DigestAuthStatus::SUCCESS; } void AsyncWebServerRequest::requestAuthentication(AsyncAuthType method, const char *realm, const char *_authFailMsg) { @@ -1361,27 +1395,43 @@ void AsyncWebServerRequest::requestAuthentication(AsyncAuthType method, const ch } case AsyncAuthType::AUTH_DIGEST: { - size_t len = sizeof(T_DIGEST_) - 1 + sizeof(T_realm__) - 1 + sizeof(T_auth_nonce) - 1 + 32 + sizeof(T__opaque) - 1 + 32 + 1; - String header; - if (header.reserve(len + strlen(realm))) { - const String nonce = genRandomMD5(); - const String opaque = genRandomMD5(); - if (nonce.length() && opaque.length()) { - header.concat(T_DIGEST_); - header.concat(T_realm__); - header.concat(realm); - header.concat(T_auth_nonce); - header.concat(nonce); - header.concat(T__opaque); - header.concat(opaque); - header.concat((char)0x22); // '"' - r->addHeader(T_WWW_AUTH, header.c_str()); - } else { + // stateless anti-replay scheme: the nonce embeds its + // issuing time, signed with the per-instance secret, so it can be validated + // later without any server-side storage. The opaque is derived from the + // same secret and identifies this server instance. + const String nonce = generateDigestNonce(_server->_digestSecret, millis()); + const String &opaque = _server->digestOpaque(); + if (nonce.length() && opaque.length()) { + // Digest realm="", qop="auth", nonce="", opaque=""[, stale=true] + size_t len = sizeof(T_DIGEST_) - 1 + sizeof(T_realm__) - 1 + strlen(realm) + sizeof(T_auth_nonce) - 1 + nonce.length() + sizeof(T__opaque) - 1 + + opaque.length() + 1; + if (_authStale) { + len += sizeof(T_stale_true) - 1; + } + String header; + if (!header.reserve(len)) { async_ws_log_e("Failed to allocate"); delete r; abort(); return; } + header.concat(T_DIGEST_); + header.concat(T_realm__); + header.concat(realm); + header.concat(T_auth_nonce); // ends with an opening quote for the nonce + header.concat(nonce); + header.concat(T__opaque); // starts with a closing quote for the nonce + header.concat(opaque); + header.concat((char)0x22); // '"' + if (_authStale) { + header.concat(T_stale_true); + } + r->addHeader(T_WWW_AUTH, header.c_str()); + } else { + async_ws_log_e("Failed to allocate"); + delete r; + abort(); + return; } break; } diff --git a/src/WebServer.cpp b/src/WebServer.cpp index d759083cc..a46ebc7c3 100644 --- a/src/WebServer.cpp +++ b/src/WebServer.cpp @@ -2,12 +2,15 @@ // Copyright 2016-2026 Hristo Gochkov, Mathieu Carbou, Emil Muratov, Will Miles #include "ESPAsyncWebServer.h" +#include "WebAuthentication.h" #include "WebHandlerImpl.h" #include #include #include +#include "./literals.h" + #if defined(ESP32) || defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) || defined(LIBRETINY) || defined(HOST) #include #elif defined(ESP8266) @@ -58,6 +61,10 @@ AsyncWebServer::AsyncWebServer(uint16_t port, AsyncCallbackWebHandler *catchAllH assert(catchAllHandler != nullptr); _catchAllHandler = MaybeOwned::adopt(catchAllHandler); _server.onClient(_onClientConnect, this); + // Stateless Digest auth anti-replay material: + // a per-instance random secret signs nonces and derives the opaque value + generateSecret(_digestSecret); + _digestOpaque = generateDigestOpaque(_digestSecret); } AsyncWebServer::AsyncWebServer(uint16_t port, AsyncCallbackWebHandler &catchAllHandler) : _server(port) { From df491a3f38d81c2377dd6ae58f76a70c5aa42764 Mon Sep 17 00:00:00 2001 From: Mathieu Carbou Date: Wed, 4 Nov 2026 19:25:31 +0100 Subject: [PATCH 6/6] fix(WebAuthentication): LibreTiny compatibility - LibreTiny has no MD5Builder class, only the C API from : classify platforms behind HAS_MD5_BUILDER (ESP32 arduino, RP2040, HOST) vs the md5_context_t C API path (ESP8266, LibreTiny), and gate md5Add() on it; - generateSecret(): use LibreTiny hardware entropy via ESP.random() (lt_rand_bytes) instead of falling into the HOST /dev/urandom branch; - use PRIx32/SCNx32 for uint32_t in the nonce timestamp formatting and parsing: unsigned long on LibreTiny/ARM, silences -Wformat warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/WebAuthentication.cpp | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/WebAuthentication.cpp b/src/WebAuthentication.cpp index 8f2d75a83..33244b360 100644 --- a/src/WebAuthentication.cpp +++ b/src/WebAuthentication.cpp @@ -5,6 +5,7 @@ #include "AsyncWebServerLogging.h" #include +#include #include #if defined(ESP32) @@ -13,14 +14,23 @@ #include #endif +#include "./literals.h" + +// platform classification for the MD5 backend used in this file: +// - HAS_MD5_BUILDER: the MD5Builder wrapper class exists (ESP32 arduino, RP2040, HOST) +// - everything else (ESP8266, LibreTiny): low-level md5_context_t C API from #if defined(ESP32) || defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) || defined(HOST) +#define HAS_MD5_BUILDER 1 +#else +#define HAS_MD5_BUILDER 0 +#endif + +#if HAS_MD5_BUILDER #include #else #include #endif -#include "./literals.h" - using namespace asyncsrv; // Basic Auth hash = base64("username:password") @@ -61,7 +71,7 @@ String generateBasicHash(const char *username, const char *password) { } static bool getMD5(uint8_t *data, uint16_t len, char *output) { // 33 bytes or more -#if defined(ESP32) || defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) || defined(HOST) +#if HAS_MD5_BUILDER MD5Builder md5; md5.begin(); md5.add(data, len); @@ -113,6 +123,8 @@ void generateSecret(uint8_t secret[16]) { #elif defined(ESP8266) uint32_t r[4] = {RANDOM_REG32, RANDOM_REG32, RANDOM_REG32, RANDOM_REG32}; memcpy(secret, r, 16); +#elif defined(LIBRETINY) + ESP.random(secret, 16); #else // HOST is POSIX: read from /dev/urandom, falling back to rand() FILE *f = fopen("/dev/urandom", "rb"); @@ -129,6 +141,7 @@ void generateSecret(uint8_t secret[16]) { #endif } +#if HAS_MD5_BUILDER // Adds a buffer to a MD5Builder, working around the pre-3.x core where add() // takes a non-const uint8_t* although it never modifies the data. static inline void md5Add(MD5Builder &md5, const uint8_t *data, size_t len) { @@ -138,6 +151,7 @@ static inline void md5Add(MD5Builder &md5, const uint8_t *data, size_t len) { md5.add(data, len); #endif } +#endif // HMAC-MD5, RFC 2104 void hmacMD5(const uint8_t *key, size_t keyLen, const uint8_t *data, size_t dataLen, uint8_t out[16]) { @@ -145,7 +159,7 @@ void hmacMD5(const uint8_t *key, size_t keyLen, const uint8_t *data, size_t data memset(k0, 0, sizeof(k0)); if (keyLen > 64) { // keys longer than block size are hashed first -#if defined(ESP32) || defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) || defined(HOST) +#if HAS_MD5_BUILDER MD5Builder md5; md5.begin(); md5Add(md5, key, keyLen); @@ -168,7 +182,7 @@ void hmacMD5(const uint8_t *key, size_t keyLen, const uint8_t *data, size_t data } uint8_t digest[16]; -#if defined(ESP32) || defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) || defined(HOST) +#if HAS_MD5_BUILDER MD5Builder md5; md5.begin(); md5Add(md5, inner, 64); @@ -210,7 +224,7 @@ static String toHexDigest(const uint8_t *buf, size_t len) { // nonce = hex(millis) + hex(HMAC-MD5(secret, hex(millis))) : 8 + 32 = 40 chars String generateDigestNonce(const uint8_t secret[16], uint32_t now) { char ts[9]; - snprintf(ts, sizeof(ts), "%08x", now); + snprintf(ts, sizeof(ts), "%08" PRIx32, now); uint8_t mac[16]; hmacMD5(secret, 16, (const uint8_t *)ts, 8, mac); String macHex = toHexDigest(mac, 16); @@ -247,7 +261,7 @@ bool validateDigestNonce(const char *nonce, const uint8_t secret[16], uint32_t n memcpy(ts, nonce, 8); ts[8] = '\0'; uint32_t issued; - if (sscanf(ts, "%08x", &issued) != 1) { + if (sscanf(ts, "%08" SCNx32, &issued) != 1) { return false; } // recompute the MAC part and compare in constant time, without any allocation