/* SPDX-License-Identifier: GPL-3.0-only */ #include "web_cookie_auth.h" #include #include #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "mbedtls/sha256.h" #include "secure_random.h" #include "web_auth_parse.h" #include "web_httpd_adapter.h" #include "web_login_ui.h" #include "web_serial_transport.h" #define SESSION_COOKIE "__Host-sak-session" #define PRELOGIN_COOKIE "__Host-sak-prelogin" #define COOKIE_FLAGS "; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=" #define CHALLENGE_US 120000000LL #define WINDOW_US 60000000LL typedef struct { int64_t expiry; uint8_t token_digest[32]; uint8_t origin_digest[32]; char csrf[65]; } challenge_t; static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; static challenge_t s_challenges[4]; static bool s_ready; static uint64_t s_epoch; static int64_t s_window; static unsigned s_attempts; static web_cookie_auth_snapshot_t s_counts; static bool equal(const void *a, const void *b, size_t size) { const uint8_t *x = a, *y = b; unsigned difference = 0; for (size_t i = 0; i < size; ++i) difference |= x[i] ^ y[i]; return difference == 0; } static bool header(httpd_req_t *r, const char *name, char *out, size_t size) { size_t length = httpd_req_get_hdr_value_len(r, name); out[0] = 0; return length < size && httpd_req_get_hdr_value_str(r, name, out, size) == ESP_OK; } static bool origin(httpd_req_t *r, bool required, char canonical[129]) { char host[129] = {0}, supplied[137] = {0}, site[16] = {0}; if (!header(r, "Host", host, sizeof(host))) return false; if (header(r, "Sec-Fetch-Site", site, sizeof(site))) { if (strcmp(site, "same-origin") && strcmp(site, "none")) return false; } else if (httpd_req_get_hdr_value_len(r, "Sec-Fetch-Site")) return false; if (!header(r, "Origin", supplied, sizeof(supplied))) { if (required || httpd_req_get_hdr_value_len(r, "Origin")) return false; int length = snprintf(supplied, sizeof(supplied), "https://%s", host); if (length < 0 || (size_t)length >= sizeof(supplied)) return false; } return web_auth_parse_origin(host, strlen(host), supplied, strlen(supplied), canonical); } static bool cookie(httpd_req_t *r, const char *name, char token[65]) { char cookies[1025] = {0}; bool valid = header(r, "Cookie", cookies, sizeof(cookies)) && web_auth_parse_cookie(cookies, strlen(cookies), name, token); secure_wipe(cookies, sizeof(cookies)); return valid; } static bool cookies_valid(httpd_req_t *r) { char cookies[1025] = {0}, token[65] = {0}; bool present; esp_err_t error = httpd_req_get_hdr_value_str(r, "Cookie", cookies, sizeof(cookies)); bool valid = error == ESP_ERR_NOT_FOUND || (error == ESP_OK && httpd_req_get_hdr_value_len(r, "Cookie") < sizeof(cookies) && web_auth_parse_optional_cookie(cookies, strlen(cookies), SESSION_COOKIE, token, &present) && web_auth_parse_optional_cookie(cookies, strlen(cookies), PRELOGIN_COOKIE, token, &present)); secure_wipe(cookies, sizeof(cookies)); secure_wipe(token, sizeof(token)); return valid; } static esp_err_t response(httpd_req_t *r, const char *status, const char *body) { esp_err_t error = httpd_resp_set_status(r, status); if (error == ESP_OK) error = httpd_resp_set_type(r, "application/json; charset=utf-8"); if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Cache-Control", "no-store"); if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Content-Type-Options", "nosniff"); if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Referrer-Policy", "no-referrer"); if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Frame-Options", "DENY"); if (error == ESP_OK) error = httpd_resp_sendstr(r, body); /* Never let HTTPD drain an attacker-controlled rejected request body. */ return web_httpd_unread_body(r) ? ESP_FAIL : error; } static esp_err_t failure(httpd_req_t *r, const char *status, const char *code) { bool security = !strcmp(status, "403 Forbidden"); bool credentials = !strcmp(code, "invalid_credentials"); bool throttled = !strcmp(code, "throttled"); bool full = !strcmp(code, "capacity"); taskENTER_CRITICAL(&s_lock); s_counts.security_rejections += security; s_counts.login_failures += credentials; s_counts.throttled += throttled; s_counts.capacity_rejections += full; taskEXIT_CRITICAL(&s_lock); char body[80]; snprintf(body, sizeof(body), "{\"error\":\"%s\"}", code); return response(r, status, body); } static esp_err_t capacity(httpd_req_t *r) { if (httpd_resp_set_hdr(r, "Retry-After", "5") != ESP_OK) return ESP_FAIL; return failure(r, "503 Service Unavailable", "capacity"); } esp_err_t web_cookie_auth_start(void) { esp_err_t error = web_session_store_init(); taskENTER_CRITICAL(&s_lock); if (error == ESP_OK && s_epoch != UINT64_MAX) { ++s_epoch; secure_wipe(s_challenges, sizeof(s_challenges)); s_window = 0; s_attempts = 0; s_ready = true; } else { s_ready = false; error = ESP_ERR_INVALID_STATE; } taskEXIT_CRITICAL(&s_lock); return error; } void web_cookie_auth_stop(void) { taskENTER_CRITICAL(&s_lock); s_ready = false; if (s_epoch != UINT64_MAX) ++s_epoch; secure_wipe(s_challenges, sizeof(s_challenges)); s_window = 0; s_attempts = 0; taskEXIT_CRITICAL(&s_lock); web_session_store_stop(); } void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot) { if (!snapshot) return; int64_t now = esp_timer_get_time(); taskENTER_CRITICAL(&s_lock); *snapshot = s_counts; snapshot->ready = s_ready; snapshot->active_challenges = 0; for (unsigned i = 0; i < 4; ++i) { if (s_challenges[i].expiry <= now) secure_wipe(&s_challenges[i], sizeof(s_challenges[i])); else ++snapshot->active_challenges; } taskEXIT_CRITICAL(&s_lock); } void web_cookie_auth_clear_counters(void) { taskENTER_CRITICAL(&s_lock); memset(&s_counts, 0, sizeof(s_counts)); taskEXIT_CRITICAL(&s_lock); } static esp_err_t require(httpd_req_t *r, bool mutation, bool upgrade, size_t body_limit, web_session_view_t *view, bool *allowed) { char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0}; *allowed = false; memset(view, 0, sizeof(*view)); if (!web_httpd_headers_valid(r) || !cookies_valid(r) || (!upgrade && strchr(r->uri, '?')) || r->content_len > body_limit || r->method != (mutation ? HTTP_POST : HTTP_GET)) return failure(r, "400 Bad Request", "invalid_request"); if (!origin(r, mutation || upgrade, canonical)) return failure(r, "403 Forbidden", "origin"); taskENTER_CRITICAL(&s_lock); bool ready = s_ready; taskEXIT_CRITICAL(&s_lock); if (!ready) return failure(r, "503 Service Unavailable", "unavailable"); esp_err_t error = ESP_ERR_NOT_FOUND; if (cookie(r, SESSION_COOKIE, token)) error = web_session_store_lookup(token, strlen(token), canonical, strlen(canonical), view); secure_wipe(token, sizeof(token)); if (error != ESP_OK) { if (error != ESP_ERR_NOT_FOUND) return failure(r, "503 Service Unavailable", "unavailable"); if (!strcmp(r->uri, "/")) { if (httpd_resp_set_hdr(r, "Location", "/login") != ESP_OK) return ESP_FAIL; return response(r, "303 See Other", ""); } return failure(r, "401 Unauthorized", "authentication_required"); } if (mutation && (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) || strlen(csrf) != 64U || !equal(csrf, view->csrf, 64U))) { secure_wipe(csrf, sizeof(csrf)); secure_wipe(view, sizeof(*view)); return failure(r, "403 Forbidden", "csrf"); } secure_wipe(csrf, sizeof(csrf)); *allowed = true; return ESP_OK; } esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade, web_session_view_t *view, bool *allowed) { return require(r, mutation, upgrade, 0, view, allowed); } esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit, web_session_view_t *view, bool *allowed) { return require(r, true, false, body_limit, view, allowed); } static bool secret(char out[65]) { uint8_t bytes[32]; bool ok = secure_random_fill(bytes, sizeof(bytes)) == ESP_OK; if (ok) { static const char hex[] = "0123456789abcdef"; for (size_t i = 0; i < sizeof(bytes); ++i) { out[2*i] = hex[bytes[i] >> 4]; out[2*i+1] = hex[bytes[i] & 15]; } out[64] = 0; } secure_wipe(bytes, sizeof(bytes)); return ok; } esp_err_t web_cookie_auth_handler(httpd_req_t *r) { bool login = !strcmp(r->uri, "/api/login"); bool bootstrap = !strcmp(r->uri, "/api/login-challenge"); bool logout = !strcmp(r->uri, "/api/logout"); bool document = !strcmp(r->uri, "/login"); web_session_view_t view = {0}; char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0}; char set_cookie[180] = {0}, body[513] = {0}; web_auth_credentials_t credentials = {0}; challenge_t candidate = {0}; uint8_t digest[32] = {0}, origin_digest[32] = {0}; esp_err_t result = ESP_FAIL; const char *status = "400 Bad Request", *code = "invalid_request"; bool consumed = false, allowed = false; bool challenge_published = false; uint64_t epoch; int selected = -1; int64_t now = esp_timer_get_time(); taskENTER_CRITICAL(&s_lock); bool ready = s_ready; epoch = s_epoch; taskEXIT_CRITICAL(&s_lock); if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; } if (!web_httpd_headers_valid(r) || !cookies_valid(r) || strchr(r->uri, '?') || r->method != ((login || logout) ? HTTP_POST : HTTP_GET) || (!login && r->content_len)) goto deny; if (document) { result = web_login_ui_send_response(r); goto cleanup; } if (!login && !bootstrap) { result = web_cookie_auth_require(r, logout, false, &view, &allowed); if (!allowed) goto cleanup; if (logout) { web_serial_transport_revoke_web_session(view.id); taskENTER_CRITICAL(&s_lock); ++s_counts.logouts; taskEXIT_CRITICAL(&s_lock); result = httpd_resp_set_hdr(r, "Set-Cookie", SESSION_COOKIE "=" COOKIE_FLAGS "0"); if (result != ESP_OK) goto cleanup; result = response(r, "204 No Content", ""); } else { /* Database usernames are restricted ASCII; encode nevertheless. */ char username[97] = {0}; size_t used = 0; for (size_t i = 0; i < view.principal.username_length && i < 16; ++i) used += (size_t)snprintf(username + used, sizeof(username) - used, "\\u%04x", (unsigned char)view.principal.username[i]); int64_t remaining = (view.expires_at_us - esp_timer_get_time()) / 1000000LL; snprintf(body, sizeof(body), "{\"username\":\"%s\",\"role\":\"%s\",\"csrf\":\"%s\",\"expires_in\":%lld}", username, view.principal.role == USER_ROLE_ADMIN ? "admin" : "user", view.csrf, (long long)(remaining > 0 ? remaining : 0)); result = response(r, "200 OK", body); } goto cleanup; } if (!origin(r, login, canonical)) { status = "403 Forbidden"; code = "origin"; goto deny; } if (mbedtls_sha256((const uint8_t *)canonical, strlen(canonical), origin_digest, 0)) goto deny; if (bootstrap) { char flag[2]; if (!header(r, "X-Login-Bootstrap", flag, sizeof(flag)) || strcmp(flag, "1")) { status = "403 Forbidden"; code = "csrf"; goto deny; } } else { char type[40]; if (!header(r, "Content-Type", type, sizeof(type)) || (strcmp(type, "application/json") && strcmp(type, "application/json; charset=utf-8"))) { status = "415 Unsupported Media Type"; code = "content_type"; goto deny; } if (!r->content_len || r->content_len > 512U) { status = "413 Payload Too Large"; code = "body_size"; goto deny; } if (cookie(r, SESSION_COOKIE, token) && web_session_store_lookup(token, 64, canonical, strlen(canonical), &view) == ESP_OK) { status = "409 Conflict"; code = "already_authenticated"; goto deny; } if (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) || strlen(csrf) != 64) { status = "403 Forbidden"; code = "csrf"; goto deny; } } bool has_cookie = cookie(r, PRELOGIN_COOKIE, token); if (has_cookie && mbedtls_sha256((const uint8_t *)token, 64, digest, 0)) goto deny; taskENTER_CRITICAL(&s_lock); for (int i = 0; i < 4; ++i) { challenge_t *entry = &s_challenges[i]; if (entry->expiry <= now) secure_wipe(entry, sizeof(*entry)); if (s_ready && epoch == s_epoch && entry->expiry && has_cookie && equal(entry->token_digest, digest, 32) && equal(entry->origin_digest, origin_digest, 32)) { if (bootstrap || equal(entry->csrf, csrf, 64)) { candidate = *entry; selected = i; if (login) { secure_wipe(entry, sizeof(*entry)); consumed = true; } } } } taskEXIT_CRITICAL(&s_lock); if (bootstrap) { bool fresh = selected < 0; if (fresh) { if (!secret(token) || !secret(candidate.csrf) || mbedtls_sha256((const uint8_t *)token, 64, candidate.token_digest, 0)) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; } memcpy(candidate.origin_digest, origin_digest, 32); candidate.expiry = now + CHALLENGE_US; taskENTER_CRITICAL(&s_lock); if (s_ready && epoch == s_epoch) for (int i = 0; i < 4; ++i) { if (!s_challenges[i].expiry) { s_challenges[i] = candidate; selected = i; challenge_published = true; break; } } taskEXIT_CRITICAL(&s_lock); if (selected < 0) { result = capacity(r); goto cleanup; } snprintf(set_cookie, sizeof(set_cookie), PRELOGIN_COOKIE "=%s" COOKIE_FLAGS "120", token); if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) goto cleanup; } snprintf(body, sizeof(body), "{\"csrf\":\"%s\",\"expires_in\":%lld}", candidate.csrf, (long long)((candidate.expiry - now) / 1000000LL)); result = response(r, "200 OK", body); goto cleanup; } if (!consumed) { status = "403 Forbidden"; code = "challenge_expired"; goto deny; } if (httpd_resp_set_hdr(r, "Set-Cookie", PRELOGIN_COOKIE "=" COOKIE_FLAGS "0") != ESP_OK) goto cleanup; size_t received = 0; int64_t deadline = now + 3000000LL; while (received < r->content_len && esp_timer_get_time() < deadline) { int count = httpd_req_recv(r, body + received, r->content_len - received); if (count <= 0) goto deny; received += (size_t)count; } if (received != r->content_len || !web_auth_parse_login(body, received, &credentials)) goto deny; now = esp_timer_get_time(); unsigned attempts; int64_t retry; taskENTER_CRITICAL(&s_lock); ready = s_ready && epoch == s_epoch; if (now - s_window >= WINDOW_US) { s_window = now; s_attempts = 0; } attempts = s_attempts; if (ready && attempts < 5) { ++s_attempts; ++s_counts.login_attempts; } retry = (s_window + WINDOW_US - now + 999999LL) / 1000000LL; taskEXIT_CRITICAL(&s_lock); if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; } if (attempts >= 5) { char seconds[16]; snprintf(seconds, sizeof(seconds), "%lld", (long long)retry); if (httpd_resp_set_hdr(r, "Retry-After", seconds) != ESP_OK) goto cleanup; result = failure(r, "429 Too Many Requests", "throttled"); goto cleanup; } bool authenticated = false; user_principal_t principal = {0}; esp_err_t error = user_database_authenticate_password(credentials.username, credentials.username_length, credentials.password, credentials.password_length, &principal, &authenticated); secure_wipe(body, sizeof(body)); secure_wipe(&credentials, sizeof(credentials)); if (error == ESP_OK && authenticated) error = web_session_store_issue(&principal, canonical, strlen(canonical), token, &view); secure_wipe(&principal, sizeof(principal)); if (error == ESP_ERR_NO_MEM) { result = capacity(r); goto cleanup; } if (error != ESP_OK) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; } if (!authenticated) { status = "401 Unauthorized"; code = "invalid_credentials"; goto deny; } snprintf(set_cookie, sizeof(set_cookie), SESSION_COOKIE "=%s" COOKIE_FLAGS "3600", token); if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) { web_session_store_invalidate(view.id); goto cleanup; } result = response(r, "200 OK", "{\"authenticated\":true}"); if (result != ESP_OK) web_session_store_invalidate(view.id); goto cleanup; deny: result = failure(r, status, code); cleanup: if (challenge_published && result != ESP_OK) { taskENTER_CRITICAL(&s_lock); if (epoch == s_epoch && selected >= 0 && equal(s_challenges[selected].token_digest, candidate.token_digest, 32)) secure_wipe(&s_challenges[selected], sizeof(s_challenges[selected])); taskEXIT_CRITICAL(&s_lock); } web_httpd_wipe_request(r, web_httpd_unread_body(r)); secure_wipe(&view, sizeof(view)); secure_wipe(token, sizeof(token)); secure_wipe(csrf, sizeof(csrf)); secure_wipe(set_cookie, sizeof(set_cookie)); secure_wipe(body, sizeof(body)); secure_wipe(&credentials, sizeof(credentials)); secure_wipe(&candidate, sizeof(candidate)); secure_wipe(digest, sizeof(digest)); return result; }