Phase 8D.1 implemented and validated.

This commit is contained in:
2026-09-05 17:15:22 +02:00
parent d4991658b1
commit 27c54c0a92
11 changed files with 894 additions and 2 deletions
+1
View File
@@ -34,6 +34,7 @@ idf_component_register(
"web_assets_data.c"
"web_ui.c"
"web_server.c"
"web_session_store.c"
"web_console.c"
"wifi_config.c"
"wifi_manager.c"
+13
View File
@@ -10,6 +10,7 @@
#include "esp_http_server.h"
#include "esp_https_server.h"
#include "esp_log.h"
#include "esp_netif_ip_addr.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
@@ -24,6 +25,7 @@
#include "user_database.h"
#include "web_security.h"
#include "web_serial_transport.h"
#include "web_session_store.h"
#include "web_ui.h"
#include "wifi_manager.h"
@@ -652,6 +654,13 @@ esp_err_t web_server_start(void)
serial_transport_ready = s_serial_transport_initialized;
xSemaphoreGive(s_server_mutex);
/* Initialize only after admission: a rejected start must not undo stop.
* Dormant Phase 8D primitives do not gate the existing Basic-auth service. */
esp_err_t session_error = web_session_store_init();
if (session_error != ESP_OK) {
ESP_LOGW("web_server", "Session store unavailable: %s", esp_err_to_name(session_error));
}
uint8_t certificate[WEB_SECURITY_CERTIFICATE_DER_CAPACITY] = {0};
uint8_t private_key[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY] = {0};
size_t certificate_length = 0U;
@@ -694,6 +703,9 @@ esp_err_t web_server_start(void)
attach_error = web_serial_transport_attach_server(server);
serial_transport_attached = attach_error == ESP_OK;
}
if (error != ESP_OK) {
web_session_store_stop();
}
if (error != ESP_OK && server != NULL) {
esp_err_t cleanup_error = httpd_ssl_stop(server);
if (cleanup_error == ESP_OK) {
@@ -738,6 +750,7 @@ esp_err_t web_server_stop(void)
s_transitioning = true;
xSemaphoreGive(s_server_mutex);
web_session_store_stop();
if (serial_transport_attached) {
esp_err_t detach_error = web_serial_transport_detach_server(server);
if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) {
+422
View File
@@ -0,0 +1,422 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_session_store.h"
#include <limits.h>
#include <string.h>
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "mbedtls/sha256.h"
#include "secure_random.h"
typedef struct {
web_session_id_t id;
int64_t expires_at_us;
user_principal_t principal;
uint8_t token_digest[WEB_SESSION_STORE_SECRET_BYTES];
uint8_t origin_digest[WEB_SESSION_STORE_SECRET_BYTES];
uint8_t csrf[WEB_SESSION_STORE_SECRET_BYTES];
} session_entry_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static struct {
session_entry_t entries[WEB_SESSION_STORE_CAPACITY];
uint64_t next_id;
uint64_t epoch;
bool ready;
bool initializing;
uint32_t issued;
uint32_t capacity_rejections;
uint32_t expired;
uint32_t invalidated;
uint32_t lookup_rejections;
uint32_t init_failures;
} s_state;
static bool equal_bytes(const uint8_t *a, const uint8_t *b, size_t length)
{
uint8_t difference = 0U;
for (size_t i = 0U; i < length; ++i) {
difference |= a[i] ^ b[i];
}
return difference == 0U;
}
static void encode_hex(const uint8_t *bytes, char *text)
{
static const char hex[] = "0123456789abcdef";
for (size_t i = 0U; i < WEB_SESSION_STORE_SECRET_BYTES; ++i) {
text[2U * i] = hex[bytes[i] >> 4U];
text[2U * i + 1U] = hex[bytes[i] & 15U];
}
text[WEB_SESSION_STORE_TOKEN_LENGTH] = '\0';
}
static esp_err_t digest(const void *input, size_t length, uint8_t *output)
{
return mbedtls_sha256(input, length, output, 0) == 0 ? ESP_OK : ESP_FAIL;
}
static esp_err_t origin_digest(const char *origin, size_t length, uint8_t *output)
{
if (origin == NULL || length <= 8U ||
length > WEB_SESSION_STORE_ORIGIN_MAX_LENGTH ||
memcmp(origin, "https://", 8U) != 0 || memchr(origin, '\0', length) != NULL) {
return ESP_ERR_INVALID_ARG;
}
return digest(origin, length, output);
}
static session_entry_t *find_locked(web_session_id_t id)
{
if (id != 0U) {
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
if (s_state.entries[i].id == id) {
return &s_state.entries[i];
}
}
}
return NULL;
}
static void retire_locked(session_entry_t *entry, bool expired)
{
if (entry->id != 0U) {
if (expired) {
++s_state.expired;
} else {
++s_state.invalidated;
}
secure_wipe(entry, sizeof(*entry));
}
}
static void expire_locked(int64_t now)
{
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
session_entry_t *entry = &s_state.entries[i];
if (entry->id != 0U && entry->expires_at_us <= now) {
retire_locked(entry, true);
}
}
}
/* Also cancels issuance already outside the lock, even if no record matched.
* Exhaustion is fail-closed rather than allowing an epoch/identity ABA. */
static void advance_epoch_locked(void)
{
if (s_state.epoch != UINT64_MAX) {
++s_state.epoch;
} else {
s_state.ready = false;
}
}
static void export_view(const session_entry_t *entry, web_session_view_t *view)
{
view->id = entry->id;
view->expires_at_us = entry->expires_at_us;
view->principal = entry->principal;
encode_hex(entry->csrf, view->csrf);
}
esp_err_t web_session_store_init(void)
{
taskENTER_CRITICAL(&s_lock);
if (s_state.ready) {
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
if (s_state.initializing || s_state.epoch == UINT64_MAX) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
s_state.initializing = true;
uint64_t epoch = s_state.epoch;
taskEXIT_CRITICAL(&s_lock);
uint8_t probe[WEB_SESSION_STORE_SECRET_BYTES] = {0};
esp_err_t error = secure_random_fill(probe, sizeof(probe));
secure_wipe(probe, sizeof(probe));
taskENTER_CRITICAL(&s_lock);
if (epoch != s_state.epoch) {
error = ESP_ERR_INVALID_STATE;
}
s_state.initializing = false;
s_state.ready = error == ESP_OK;
if (error != ESP_OK) {
++s_state.init_failures;
}
taskEXIT_CRITICAL(&s_lock);
return error;
}
void web_session_store_stop(void)
{
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
s_state.ready = false;
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
retire_locked(&s_state.entries[i], false);
}
taskEXIT_CRITICAL(&s_lock);
}
/* Never enter the database while holding our lock. On return, re-find the
* non-reused ID and deadline: logout/stop/slot reuse may have raced the call. */
static esp_err_t resolve(web_session_id_t id, web_session_view_t *view)
{
session_entry_t candidate = {0};
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
session_entry_t *entry = find_locked(id);
esp_err_t error = s_state.ready ? ESP_ERR_NOT_FOUND : ESP_ERR_INVALID_STATE;
if (s_state.ready && entry != NULL) {
candidate = *entry;
error = ESP_OK;
}
taskEXIT_CRITICAL(&s_lock);
if (error == ESP_OK) {
bool current = false;
error = user_database_principal_is_current(&candidate.principal, &current);
now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
entry = find_locked(id);
if (!s_state.ready || entry == NULL) {
error = ESP_ERR_NOT_FOUND;
} else if (error != ESP_OK || !current) {
retire_locked(entry, false);
if (error == ESP_OK) {
error = ESP_ERR_NOT_FOUND;
}
}
taskEXIT_CRITICAL(&s_lock);
}
if (error == ESP_OK && view != NULL) {
export_view(&candidate, view);
}
if (error != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
++s_state.lookup_rejections;
taskEXIT_CRITICAL(&s_lock);
}
secure_wipe(&candidate, sizeof(candidate));
return error;
}
void web_session_store_prune(void)
{
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
taskENTER_CRITICAL(&s_lock);
web_session_id_t id = s_state.entries[i].id;
taskEXIT_CRITICAL(&s_lock);
if (id != 0U) {
(void)resolve(id, NULL);
}
}
}
esp_err_t web_session_store_issue(
const user_principal_t *principal, const char *origin, size_t origin_length,
char token[WEB_SESSION_STORE_TOKEN_LENGTH + 1U], web_session_view_t *view)
{
if (token != NULL) {
secure_wipe(token, WEB_SESSION_STORE_TOKEN_LENGTH + 1U);
}
if (view != NULL) {
secure_wipe(view, sizeof(*view));
}
if (principal == NULL || token == NULL || view == NULL ||
principal->user_id == 0U || principal->auth_generation == 0U ||
principal->method != USER_AUTH_METHOD_PASSWORD ||
(principal->role != USER_ROLE_USER && principal->role != USER_ROLE_ADMIN) ||
principal->username_length == 0U ||
principal->username_length > USER_DATABASE_USERNAME_CAPACITY ||
principal->username[principal->username_length] != '\0') {
return ESP_ERR_INVALID_ARG;
}
taskENTER_CRITICAL(&s_lock);
bool ready = s_state.ready;
uint64_t epoch = s_state.epoch;
taskEXIT_CRITICAL(&s_lock);
if (!ready) {
return ESP_ERR_INVALID_STATE;
}
web_session_store_prune();
session_entry_t candidate = {0};
uint8_t random[2U * WEB_SESSION_STORE_SECRET_BYTES] = {0};
esp_err_t error = origin_digest(origin, origin_length, candidate.origin_digest);
if (error == ESP_OK) {
error = secure_random_fill(random, sizeof(random));
}
if (error == ESP_OK) {
encode_hex(random, token);
memcpy(candidate.csrf, random + WEB_SESSION_STORE_SECRET_BYTES,
sizeof(candidate.csrf));
error = digest(token, WEB_SESSION_STORE_TOKEN_LENGTH, candidate.token_digest);
}
bool current = false;
if (error == ESP_OK) {
candidate.principal = *principal;
error = user_database_principal_is_current(&candidate.principal, &current);
if (error == ESP_OK && !current) {
error = ESP_ERR_NOT_FOUND;
}
}
if (error == ESP_OK) {
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
session_entry_t *available = NULL;
bool duplicate = false;
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
session_entry_t *entry = &s_state.entries[i];
if (entry->id == 0U) {
if (available == NULL) {
available = entry;
}
} else if (equal_bytes(entry->token_digest, candidate.token_digest,
sizeof(entry->token_digest))) {
duplicate = true;
}
}
if (!s_state.ready || epoch != s_state.epoch ||
s_state.next_id == UINT64_MAX || now < 0 ||
now > INT64_MAX - WEB_SESSION_STORE_LIFETIME_US) {
error = ESP_ERR_INVALID_STATE;
} else if (duplicate) {
error = ESP_FAIL;
} else if (available == NULL) {
++s_state.capacity_rejections;
error = ESP_ERR_NO_MEM;
} else {
candidate.id = ++s_state.next_id;
candidate.expires_at_us = now + WEB_SESSION_STORE_LIFETIME_US;
*available = candidate;
++s_state.issued;
}
taskEXIT_CRITICAL(&s_lock);
}
if (error == ESP_OK) {
export_view(&candidate, view);
} else {
secure_wipe(token, WEB_SESSION_STORE_TOKEN_LENGTH + 1U);
}
secure_wipe(random, sizeof(random));
secure_wipe(&candidate, sizeof(candidate));
return error;
}
esp_err_t web_session_store_lookup(
const char *token, size_t token_length, const char *origin,
size_t origin_length, web_session_view_t *view)
{
if (view == NULL) {
return ESP_ERR_INVALID_ARG;
}
secure_wipe(view, sizeof(*view));
if (token == NULL || token_length != WEB_SESSION_STORE_TOKEN_LENGTH) {
return ESP_ERR_INVALID_ARG;
}
for (size_t i = 0U; i < token_length; ++i) {
if (!((token[i] >= '0' && token[i] <= '9') ||
(token[i] >= 'a' && token[i] <= 'f'))) {
return ESP_ERR_INVALID_ARG;
}
}
uint8_t token_hash[WEB_SESSION_STORE_SECRET_BYTES] = {0};
uint8_t origin_hash[WEB_SESSION_STORE_SECRET_BYTES] = {0};
esp_err_t error = origin_digest(origin, origin_length, origin_hash);
if (error == ESP_OK) {
error = digest(token, token_length, token_hash);
}
if (error == ESP_OK) {
web_session_id_t id = 0U;
taskENTER_CRITICAL(&s_lock);
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
const session_entry_t *entry = &s_state.entries[i];
bool matches = equal_bytes(entry->token_digest, token_hash, sizeof(token_hash));
matches &= equal_bytes(entry->origin_digest, origin_hash, sizeof(origin_hash));
if (entry->id != 0U && matches) {
id = entry->id;
}
}
taskEXIT_CRITICAL(&s_lock);
error = resolve(id, view);
}
secure_wipe(token_hash, sizeof(token_hash));
secure_wipe(origin_hash, sizeof(origin_hash));
return error;
}
esp_err_t web_session_store_is_current(web_session_id_t id, bool *current)
{
if (current == NULL) {
return ESP_ERR_INVALID_ARG;
}
*current = false;
esp_err_t error = resolve(id, NULL);
if (error == ESP_OK) {
*current = true;
}
return error;
}
void web_session_store_invalidate(web_session_id_t id)
{
if (id == 0U) {
return;
}
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
session_entry_t *entry = find_locked(id);
if (entry != NULL) {
retire_locked(entry, false);
}
taskEXIT_CRITICAL(&s_lock);
}
void web_session_store_invalidate_user(uint32_t user_id)
{
if (user_id == 0U) {
return;
}
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
if (s_state.entries[i].principal.user_id == user_id) {
retire_locked(&s_state.entries[i], false);
}
}
taskEXIT_CRITICAL(&s_lock);
}
esp_err_t web_session_store_get_snapshot(web_session_store_snapshot_t *snapshot)
{
if (snapshot == NULL) {
return ESP_ERR_INVALID_ARG;
}
memset(snapshot, 0, sizeof(*snapshot));
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
snapshot->initialized = s_state.ready;
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
snapshot->active += s_state.entries[i].id != 0U;
}
snapshot->issued = s_state.issued;
snapshot->capacity_rejections = s_state.capacity_rejections;
snapshot->expired = s_state.expired;
snapshot->invalidated = s_state.invalidated;
snapshot->lookup_rejections = s_state.lookup_rejections;
snapshot->init_failures = s_state.init_failures;
snapshot->storage_bytes = sizeof(s_state) + sizeof(s_lock);
snapshot->slot_bytes = sizeof(session_entry_t);
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
+69
View File
@@ -0,0 +1,69 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Internal session primitives; no HTTP authorization is enabled by this module. */
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#include "user_database.h"
#define WEB_SESSION_STORE_CAPACITY 4U
#define WEB_SESSION_STORE_SECRET_BYTES 32U
#define WEB_SESSION_STORE_TOKEN_LENGTH 64U
#define WEB_SESSION_STORE_ORIGIN_MAX_LENGTH 128U
#define WEB_SESSION_STORE_LIFETIME_US 3600000000LL
typedef uint64_t web_session_id_t;
/* Sensitive request-local result, NOT a routine snapshot. Wipe after use. */
typedef struct {
web_session_id_t id;
int64_t expires_at_us;
user_principal_t principal;
char csrf[WEB_SESSION_STORE_TOKEN_LENGTH + 1U];
} web_session_view_t;
typedef struct {
bool initialized;
uint32_t active;
uint32_t issued;
uint32_t capacity_rejections;
uint32_t expired;
uint32_t invalidated;
uint32_t lookup_rejections;
uint32_t init_failures;
size_t storage_bytes;
size_t slot_bytes;
} web_session_store_snapshot_t;
/* Idempotent; probes the already-seeded shared RNG, never seeds it here.
* Stop cancels in-flight initialization/issuance and wipes all records. IDs and
* invalidation epochs never reset within a boot, even across stop/init. */
esp_err_t web_session_store_init(void);
void web_session_store_stop(void);
/* Trusted callers only. principal must be a current password-authenticated
* principal. origin is the canonical, already HTTP-policy-validated HTTPS
* origin, not an unchecked Host header; this module only binds its digest.
* No live eviction. ESP_ERR_NO_MEM means fixed session capacity exhausted.
* Raw token is returned only by issue; both outputs must be wiped by caller.
* Output buffers must not alias inputs or each other. */
esp_err_t web_session_store_issue(
const user_principal_t *principal, const char *origin, size_t origin_length,
char token[WEB_SESSION_STORE_TOKEN_LENGTH + 1U], web_session_view_t *view);
esp_err_t web_session_store_lookup(
const char *token, size_t token_length, const char *origin,
size_t origin_length, web_session_view_t *view);
/* Trusted transport identity check, not a replacement for HTTP cookie/origin
* authorization. Every successful lookup/check revalidates the principal.
* No API result is a lease: recheck at later sensitive boundaries. */
esp_err_t web_session_store_is_current(web_session_id_t id, bool *current);
void web_session_store_invalidate(web_session_id_t id);
void web_session_store_invalidate_user(uint32_t user_id);
void web_session_store_prune(void);
/* Counts only: never token/digest/CSRF/principal material. Expired records are
* reclaimed here; stale principals are reclaimed by prune or lookup/check. */
esp_err_t web_session_store_get_snapshot(web_session_store_snapshot_t *snapshot);