diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 68f1c18072..2587d13b9e 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -25,6 +25,7 @@ from esphome.const import ( CONF_OTA, CONF_PASSWORD, CONF_PORT, + CONF_TYPE, CONF_USERNAME, CONF_VERSION, CONF_WEB_SERVER, @@ -44,6 +45,9 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["json", "web_server_base"] +AUTH_TYPE_BASIC = "basic" +AUTH_TYPE_DIGEST = "digest" + CONF_SORTING_GROUP_ID = "sorting_group_id" CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" @@ -85,6 +89,19 @@ def validate_version_deprecated(config: ConfigType) -> ConfigType: return config +def validate_auth_type_deprecated(auth: ConfigType) -> ConfigType: + # Remove before 2027.1.0: the default auth scheme changes from basic to digest. + if CONF_TYPE not in auth: + _LOGGER.warning( + "The 'web_server' 'auth' scheme currently defaults to 'basic', which sends the " + "password over the network in an easily reversible form. The default will change " + "to 'digest' in ESPHome 2027.1.0. To keep using basic authentication, set " + "'type: basic' under 'auth:' explicitly; otherwise set 'type: digest' now to " + "adopt the more secure scheme." + ) + return auth + + def validate_local(config: ConfigType) -> ConfigType: if CONF_LOCAL in config and config[CONF_VERSION] == 1: raise cv.Invalid("'local' is not supported in version 1") @@ -242,15 +259,21 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ALLOWED_ORIGINS): cv.All( cv.ensure_list(validate_origin), cv.Length(min=1) ), - cv.Optional(CONF_AUTH): cv.Schema( - { - cv.Required(CONF_USERNAME): cv.All( - cv.string_strict, cv.Length(min=1) - ), - cv.Required(CONF_PASSWORD): cv.sensitive( - cv.All(cv.string_strict, cv.Length(min=1)) - ), - } + cv.Optional(CONF_AUTH): cv.All( + cv.Schema( + { + cv.Required(CONF_USERNAME): cv.All( + cv.string_strict, cv.Length(min=1) + ), + cv.Required(CONF_PASSWORD): cv.sensitive( + cv.All(cv.string_strict, cv.Length(min=1)) + ), + cv.Optional(CONF_TYPE): cv.one_of( + AUTH_TYPE_BASIC, AUTH_TYPE_DIGEST, lower=True + ), + } + ), + validate_auth_type_deprecated, ), cv.GenerateID(CONF_WEB_SERVER_BASE_ID): cv.use_id( web_server_base.WebServerBase @@ -378,10 +401,15 @@ async def to_code(config): if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") cg.add(var.set_allowed_origins(allowed_origins)) - if CONF_AUTH in config: + if (auth := config.get(CONF_AUTH)) is not None: cg.add_define("USE_WEBSERVER_AUTH") - cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) - cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD])) + # The scheme is fixed at build time so the unused Basic/Digest code path is compiled + # out. Basic is the current default (the absence of this define); an explicit + # 'type: digest' opts in early. Default changes to digest in 2027.1.0. + if auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST: + cg.add_define("USE_WEBSERVER_AUTH_DIGEST") + cg.add(paren.set_auth_username(auth[CONF_USERNAME])) + cg.add(paren.set_auth_password(auth[CONF_PASSWORD])) if CONF_CSS_INCLUDE in config: cg.add_define("USE_WEBSERVER_CSS_INCLUDE") path = CORE.relative_config_path(config[CONF_CSS_INCLUDE]) diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 19c2185fb9..9657853a73 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -59,7 +59,15 @@ class AuthMiddlewareHandler : public MiddlewareHandler { bool check_auth(AsyncWebServerRequest *request) { bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str()); if (!success) { + // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is + // compiled out. On ESP32 our own server picks the scheme internally. +#if USE_ESP32 request->requestAuthentication(); +#elif defined(USE_WEBSERVER_AUTH_DIGEST) + request->requestAuthentication(nullptr, true); +#else + request->requestAuthentication(nullptr, false); +#endif } return success; } diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 46a389f359..bf5a8666dc 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -16,6 +16,11 @@ #include "utils.h" #include "web_server_idf.h" +#ifdef USE_WEBSERVER_AUTH_DIGEST +#include +#include +#endif + #ifdef USE_WEBSERVER_OTA #include #include "multipart.h" // For parse_multipart_boundary and other utils @@ -372,6 +377,135 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code } #ifdef USE_WEBSERVER_AUTH + +#ifdef USE_WEBSERVER_AUTH_DIGEST +namespace { + +// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated. +void bytes_to_hex(const uint8_t *data, size_t len, char *out) { + static const char HEX[] = "0123456789abcdef"; + for (size_t i = 0; i < len; i++) { + out[i * 2] = HEX[data[i] >> 4]; + out[i * 2 + 1] = HEX[data[i] & 0x0f]; + } + out[len * 2] = '\0'; +} + +// Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated +// parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent. +// Only whole parameter names match, so "nc" does not match inside "cnonce". +StringRef digest_param(StringRef params, const char *key) { + size_t key_len = strlen(key); + const char *base = params.c_str(); + size_t n = params.size(); + size_t i = 0; + while (i < n) { + while (i < n && (base[i] == ' ' || base[i] == ',')) + i++; + size_t name_start = i; + while (i < n && base[i] != '=' && base[i] != ',') + i++; + if (i >= n || base[i] == ',') + continue; // token without a '=', skip it + size_t name_len = i - name_start; + while (name_len > 0 && base[name_start + name_len - 1] == ' ') + name_len--; + i++; // consume '=' + const char *val_start; + size_t val_len; + if (i < n && base[i] == '"') { + i++; + val_start = base + i; + while (i < n && base[i] != '"') + i++; + val_len = (base + i) - val_start; + if (i < n) + i++; // consume closing quote + } else { + val_start = base + i; + while (i < n && base[i] != ',') + i++; + val_len = (base + i) - val_start; + } + if (name_len == key_len && memcmp(base + name_start, key, key_len) == 0) + return StringRef(val_start, val_len); + while (i < n && base[i] != ',') + i++; + } + return StringRef(); +} + +// Verify an RFC 2617 Digest response. Stateless (the nonce we issued is not tracked), which +// matches the ESPAsyncWebServer backend used on the Arduino platforms. +bool check_digest_auth(const char *username, const char *password, const std::string &header, const char *method) { + const size_t prefix_len = sizeof("Digest ") - 1; + StringRef params(header.c_str() + prefix_len, header.size() - prefix_len); + + if (digest_param(params, "username") != username) + return false; + + StringRef realm = digest_param(params, "realm"); + StringRef nonce = digest_param(params, "nonce"); + StringRef uri = digest_param(params, "uri"); + StringRef qop = digest_param(params, "qop"); + StringRef nc = digest_param(params, "nc"); + StringRef cnonce = digest_param(params, "cnonce"); + StringRef response = digest_param(params, "response"); + if (response.size() != 32) + return false; + + // Compute the three MD5 hashes by streaming the pieces straight into the ROM MD5 engine, so + // nothing is concatenated on the heap. Each hash is emitted as 32 lowercase hex characters. + md5_context_t ctx; + uint8_t digest[16]; + + // HA1 = MD5(username:realm:password) -- uses the realm the client echoed back. + char ha1[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, username, strlen(username)); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, realm.c_str(), realm.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, password, strlen(password)); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), ha1); + + // HA2 = MD5(method:uri) -- uses the uri the client echoed back. + char ha2[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, method, strlen(method)); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, uri.c_str(), uri.size()); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), ha2); + + // expected = MD5(HA1:nonce:nc:cnonce:qop:HA2) + char expected[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, ha1, 32); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, nonce.c_str(), nonce.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, nc.c_str(), nc.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, cnonce.c_str(), cnonce.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, qop.c_str(), qop.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, ha2, 32); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), expected); + + // Constant-time comparison of the two 32-char hex digests. + uint8_t result = 0; + for (size_t i = 0; i < 32; i++) + result |= static_cast(expected[i] ^ response[i]); + return result == 0; +} + +} // namespace +#endif // USE_WEBSERVER_AUTH_DIGEST + bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const { if (username == nullptr || password == nullptr || *username == 0) { return true; @@ -383,9 +517,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw auto *auth_str = auth.value().c_str(); +#ifdef USE_WEBSERVER_AUTH_DIGEST + // The build fixed the scheme to Digest, so the Basic path is compiled out entirely. + const auto auth_prefix_len = sizeof("Digest ") - 1; + if (strncmp("Digest ", auth_str, auth_prefix_len) != 0) { + ESP_LOGW(TAG, "Only Digest authorization supported"); + return false; + } + return check_digest_auth(username, password, auth.value(), http_method_str(this->method())); +#else const auto auth_prefix_len = sizeof("Basic ") - 1; if (strncmp("Basic ", auth_str, auth_prefix_len) != 0) { - ESP_LOGW(TAG, "Only Basic authorization supported yet"); + ESP_LOGW(TAG, "Only Basic authorization supported"); return false; } @@ -434,16 +577,33 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw result |= static_cast(digest[i] ^ provided_ch); } return result == 0; +#endif // USE_WEBSERVER_AUTH_DIGEST } -void AsyncWebServerRequest::requestAuthentication(const char *realm) const { +void AsyncWebServerRequest::requestAuthentication() const { httpd_resp_set_hdr(*this, "Connection", "keep-alive"); - // Note: realm is never configured in ESPHome, always nullptr -> "Login Required" - (void) realm; // Unused - always use default +#ifdef USE_WEBSERVER_AUTH_DIGEST + // Issue a fresh random nonce and opaque. The nonce is not stored, so this is stateless and + // does not defend against replay -- its purpose is to keep the password off the wire. + // The header value must stay alive until httpd_resp_send_err() below sends it, so the buffer + // lives on this stack frame (httpd_resp_set_hdr stores the pointer, it does not copy). + uint8_t random_bytes[16]; + char nonce[33]; + char opaque[33]; + char header[160]; + esp_fill_random(random_bytes, sizeof(random_bytes)); + bytes_to_hex(random_bytes, sizeof(random_bytes), nonce); + esp_fill_random(random_bytes, sizeof(random_bytes)); + bytes_to_hex(random_bytes, sizeof(random_bytes), opaque); + snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce, + opaque); + httpd_resp_set_hdr(*this, "WWW-Authenticate", header); +#else httpd_resp_set_hdr(*this, "WWW-Authenticate", "Basic realm=\"Login Required\""); +#endif // USE_WEBSERVER_AUTH_DIGEST httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr); } -#endif +#endif // USE_WEBSERVER_AUTH AsyncWebParameter *AsyncWebServerRequest::getParam(const char *name) { // Check cache first - only successful lookups are cached diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 8b5fd5b726..baa55898bb 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -129,7 +129,7 @@ class AsyncWebServerRequest { #ifdef USE_WEBSERVER_AUTH bool authenticate(const char *username, const char *password) const; // NOLINTNEXTLINE(readability-identifier-naming) - void requestAuthentication(const char *realm = nullptr) const; + void requestAuthentication() const; #endif void redirect(const std::string &url); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 78f7769cf6..5c5fc5e8b9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -298,6 +298,7 @@ #define USE_VOICE_ASSISTANT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP @@ -408,6 +409,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -438,6 +440,7 @@ #define USE_LWIP_FAST_SELECT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/tests/component_tests/web_server/test_web_server_auth.py b/tests/component_tests/web_server/test_web_server_auth.py new file mode 100644 index 0000000000..82635b26da --- /dev/null +++ b/tests/component_tests/web_server/test_web_server_auth.py @@ -0,0 +1,65 @@ +"""Tests for web_server authentication codegen.""" + +from collections.abc import Callable + +import pytest + +from esphome.core import CORE + +_DEFAULT_CHANGE_WARNING = "default will change to 'digest' in ESPHome 2027.1.0" + + +def _has_define(name: str) -> bool: + return any(d.name == name for d in CORE.defines) + + +def test_web_server_auth_default_is_basic_with_deprecation_warning( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth without an explicit type builds Basic and warns about the upcoming default change.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_default.yaml" + ) + + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING in caplog.text + + +def test_web_server_auth_explicit_basic_no_warning( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth type basic builds Basic and does not warn.""" + generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml") + + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text + + +def test_web_server_auth_explicit_digest( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth type digest builds Digest and does not warn.""" + generate_main("tests/component_tests/web_server/web_server_auth_digest.yaml") + + assert _has_define("USE_WEBSERVER_AUTH") + assert _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text + + +def test_web_server_without_auth( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Without an auth block, no auth is compiled in and no warning is emitted.""" + generate_main("tests/component_tests/web_server/web_server_no_auth.yaml") + + assert not _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text diff --git a/tests/component_tests/web_server/web_server_auth_basic.yaml b/tests/component_tests/web_server/web_server_auth_basic.yaml new file mode 100644 index 0000000000..70180f9fbc --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_basic.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/component_tests/web_server/web_server_auth_default.yaml b/tests/component_tests/web_server/web_server_auth_default.yaml new file mode 100644 index 0000000000..076180ab79 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_default.yaml @@ -0,0 +1,17 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password diff --git a/tests/component_tests/web_server/web_server_auth_digest.yaml b/tests/component_tests/web_server/web_server_auth_digest.yaml new file mode 100644 index 0000000000..f413787601 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_digest.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/component_tests/web_server/web_server_no_auth.yaml b/tests/component_tests/web_server/web_server_no_auth.yaml new file mode 100644 index 0000000000..1c7823b4ae --- /dev/null +++ b/tests/component_tests/web_server/web_server_no_auth.yaml @@ -0,0 +1,14 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 858e3b0190..2e091b905a 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -5,3 +5,4 @@ web_server: auth: username: admin password: password + type: digest diff --git a/tests/components/web_server/test.esp8266-ard.yaml b/tests/components/web_server/test.esp8266-ard.yaml index 11ad5456ef..2e091b905a 100644 --- a/tests/components/web_server/test.esp8266-ard.yaml +++ b/tests/components/web_server/test.esp8266-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index 11ad5456ef..e4d50d7776 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -1,2 +1,8 @@ packages: web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/components/web_server/validate.esp32-idf.yaml b/tests/components/web_server/validate.esp32-idf.yaml new file mode 100644 index 0000000000..e4d50d7776 --- /dev/null +++ b/tests/components/web_server/validate.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: basic