From 27c54c0a92592b8b8ba385be9291084e4f505c89 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sat, 5 Sep 2026 17:15:22 +0200 Subject: [PATCH] Phase 8D.1 implemented and validated. --- docs/agent/architecture.md | 2 + docs/agent/code-map.md | 3 +- docs/agent/current-state.md | 2 + docs/phase8d_baseline.md | 20 +- src/CMakeLists.txt | 1 + src/web_server.c | 13 + src/web_session_store.c | 422 ++++++++++++++++++++++++++++++ src/web_session_store.h | 69 +++++ tests/web_session_store/README.md | 25 ++ tests/web_session_store/run.py | 61 +++++ tests/web_session_store/test.c | 278 ++++++++++++++++++++ 11 files changed, 894 insertions(+), 2 deletions(-) create mode 100644 src/web_session_store.c create mode 100644 src/web_session_store.h create mode 100644 tests/web_session_store/README.md create mode 100644 tests/web_session_store/run.py create mode 100644 tests/web_session_store/test.c diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 1b6c1ca..c9d1308 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -102,6 +102,8 @@ HTTP Basic authentication uses `user_database`. Before administrator bootstrap, The boot-local Basic-authentication cache has four RAM entries and a five-minute sliding lifetime. It stores a keyed digest of the complete `Authorization` header rather than the raw header, and every hit revalidates principal currentness. Its current lack of locking relies on the single-HTTPD-owner execution model. +Phase 8D.1 adds **dormant** `web_session_store` primitives alongside Basic auth: four static records with token/origin digests, copied principal, separate CSRF state, one-hour absolute expiry and non-reused 64-bit session IDs. No HTTP handler uses these sessions yet. A portMUX protects short state copies/mutations; database/RNG/SHA calls occur outside it. Resolution rechecks ID/expiry after database validation; issuance also checks an invalidation epoch. Stop wipes records without resetting IDs/epochs. Only admitted HTTPS starts initialize the store; failed starts and accepted stops disable it before cleanup. Store-init failure cannot fail existing Basic HTTPS. Session/user invalidation APIs do not yet close transports or receive account-mutation notifications (8D.2); HTTP Origin/CSRF enforcement is not implemented by these storage primitives. Sensitive views must be wiped by callers; snapshots contain only counts and storage sizes. Focused host checks live in `tests/web_session_store/`. + A WebSocket connection requires a one-time, principal-bound ticket with a maximum 30-second lifetime. Only four tickets can be outstanding; minting another evicts the live entry with the earliest expiry. Ticket issuance and upgrade also validate a supplied `Origin` against `https://`; absence of `Origin` is accepted for non-browser clients. Tickets are stored as digests, consumed before currentness validation, and are never persisted. An admitted session starts the serial service if necessary, creates a broker client, and opportunistically requests writer ownership. The web transport has two fixed session slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the web transport task mediates broker work through bounded scheduling. The browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect; after a user-paused disconnect it changes to Connect, which resumes connection attempts. Web serial initialization is failure-isolated from the base HTTPS service: if the transport cannot initialize, `web_server_init()` can still succeed and serve authenticated non-WebSocket routes. diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 801d793..371d297 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -61,7 +61,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the **Responsibility:** serve authenticated HTTPS UI/API, issue WebSocket tickets, and adapt browser serial sessions to broker clients. - Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}` -- Security files: `src/web_security.{h,c}` +- Security files: `src/web_security.{h,c}`; `src/web_session_store.{h,c}` contains dormant Phase 8D.1 cookie-session primitives, not active HTTP authentication. - Asset files: authored/generated boundary in `src/web_assets_data.{h,c}`, `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py` - Interfaces: web init/start/stop/snapshots; HTTP handlers; ticket mint/consume; attach/detach; targeted session revocation - Called by: startup, ESP-IDF HTTPS server, user administration revocation, console/local UI @@ -69,6 +69,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the - Flow: `browser -> HTTPS Basic auth -> ticket -> WebSocket -> web transport -> broker` - Ownership: HTTPD owns socket send/close work; transport task owns broker mediation; two fixed WebSocket slots and four outstanding tickets. - Security constraints: Basic-auth cache hits still revalidate principal currentness; the browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect until Connect is selected. Changes to the authored inline loader must update its hard-coded CSP hash in the same change. +- Session-store boundary: admitted HTTPS start initializes four static records; failed start/accepted stop disables and wipes them. No route/ticket/account-revocation binding yet. RNG/SHA/database calls run outside its short portMUX; ID/expiry/epoch checks reject stale work. Run focused host checks with `python3 tests/web_session_store/run.py`. - Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually. ## SSH diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index d55bc07..7b1f369 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **8D.1 target validation in progress (2026-09-05):** User supplied fresh-boot (UART stopped) and full-client-mix (115200 baud, SSH writer, admin SSH, USB and two web observers) samples, recorded separately in `docs/phase8d_baseline.md`. Loaded internal/DMA/PSRAM free bytes **41,420 / 33,664 / 8,162,824**, minima **19,384 / 11,628 / 8,115,028**; SSH stack minimum-free **16,288 B**. No reported SSH I/O or web failures; one cumulative SSH broker revocation retained without diagnosis. These supersede older statements below that no target samples exist, but are not full 8D.1 sign-off. Exact changed-firmware hash, lifecycle/HTTPS restart and soak/cleanup coverage remain pending. No continuation to 8D.2 requested. + - **8D.0 sign-off (2026-09-05):** User marked the baseline validated and identified tested source as latest checked-in revision `d8999cd4a96e477fabd392ced02d810c3cd22d0f`. Current samples and authoritative sign-off are in `docs/phase8d_baseline.md`; they supersede older baseline measurements/provenance notes below. User attributes SSH I/O errors to out-of-spec **460400-baud** testing, distinct from transcript UART configuration **460800**. Numeric reserve approval and 8D.1 post-change target validation remain pending; do not reopen 8D.0 solely for unrecorded checklist details or claim those checks were executed. Based on checked-in source plus `README.md` and `docs/roadmap.md`: diff --git a/docs/phase8d_baseline.md b/docs/phase8d_baseline.md index 41afa1a..18903b9 100644 --- a/docs/phase8d_baseline.md +++ b/docs/phase8d_baseline.md @@ -273,4 +273,22 @@ Target operator checklist (user-provided samples cover status/memory collection - **Dormant-code accounting:** All functions compile and are exercised by the host harness, but the firmware link currently retains only init/stop and their dependencies; unused issue/lookup/etc. code is garbage-collected. The static state is retained in full. Later production callers must account for the newly linked code and stack/crypto execution costs rather than treating this flash delta as the full M1 cost. - **Focused executable checks:** `python3 tests/web_session_store/run.py` passed using OpenSSL SHA-256 and deterministic RNG/database/time/FreeRTOS doubles. Coverage includes failed init/retry/idempotence, capacity/no eviction, token/origin mismatch, exact expiry/no renewal, per-session/account isolation, stale principals and DB failure, stop/reinit/stale IDs, invalidation during pruning and candidate issuance, replacement during lookup, expiry during revalidation, stop during init, and post-token SHA failure output wiping. RNG/SHA/database doubles assert that no store lock is held. This narrow harness is not a general firmware test suite. - **Review-only limits:** ID/epoch exhaustion, collision rejection and private record wiping were inspected, not forced by public-API tests. Callback races are deterministic interleavings, not real multicore scheduling. Host tests do not test device DRBG, mbedTLS integration, HTTPD lifecycle races, heap/stack margins or future dormant browser paths. Independent source review found and verified the fix for reinitialization during a rejected concurrent start. -- **Post-change target gate:** Boot this build through a user-controlled upload; check UART0 recovery, unchanged admin/user Basic login and assets/status, explicit serial Disconnect/Connect, USB, user/admin SSH and full-client-mix memory/status/counters against both pre-change samples. Run five serial lifecycle cycles per role; include HTTPS stop/start and observe no new failures. Record `ssh status` stack margin and the remaining soak/cleanup measurements above. No target run of this changed firmware has been supplied or performed. Stop at 8D.1; request 8D.2 only after the regression checkpoint or an explicit decision acknowledging it remains pending. +- **Post-change target gate:** Initial user-provided boot and full-client-mix samples are recorded below; target validation is in progress, not complete. Remaining regression coverage includes UART0 recovery, both-role Basic login/assets, five serial Disconnect/Connect cycles per role, HTTPS stop/start, and soak/cleanup measurements. Stop at 8D.1; request 8D.2 only after the regression checkpoint or an explicit decision acknowledging it remains pending. + +### 8D.1 target samples: boot and full client mix + +Supplied by the user on 2026-09-05 as **Phase 8D.1 validation**. Each transcript runs `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, `serial status`, `broker clients`, then `usb status`. These are sequential user observations, not atomic samples or agent-executed tests. Exact flashed revision/hash, boot settling interval, loaded duration, browser/version/origin and serial fixture were not supplied for this phase; the 8D.0 source sign-off does not identify this changed firmware. + +| 8D.1 workload | Internal free/min/largest | DMA free/min/largest | PSRAM free/min/largest | SSH stack minimum-free | +|---|---|---|---|---| +| Fresh boot, UART stopped, no broker clients | 69,564 / 67,312 / 31,744 B | 61,808 / 59,556 / 31,744 B | 8,223,100 / 8,216,900 / 8,126,464 B | 18,464 B | +| SSH writer + admin SSH + USB observer + two web observers; UART running at 115200 baud | 41,420 / 19,384 / 31,744 B | 33,664 / 11,628 / 31,744 B | 8,162,824 / 8,115,028 / 8,126,464 B | 16,288 B | + +- **Both samples:** HTTPS and SSH initialized/running, not transitioning, ports **443/22**, `last-error=ESP_OK`. HTTPS retains HTTP Basic via the user database, four users/two admins and unchanged endpoints. SSH retains role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. SSH owner core **1**, configured stack **20,480 B**. mDNS initialized/announced as `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. +- **Boot state:** SSH **0/2**, WebSocket **0/2**, web serial attached, zero tickets, no broker clients. UART stopped, RS-232 owner idle; configuration v1 **115200 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**, RX available/TX pending **0**. Phase 0 commands reported available, not executed. USB initialized/attached, host-open/DTR/RTS **no**, broker disconnected; reported host line coding **9600 baud, 8N1**. +- **Boot counters:** SSH starts **1**, every other supplied SSH counter **0**. Web starts **1**, start-failures/stops **0**; requests total/authenticated/status **9**, root/tickets/assets/auth-failures/response-errors **0**. All ticket, WebSocket session/RX/TX/control/failure counters **0**. Thus the boot sample includes authenticated status activity, not a no-HTTP-traffic idle state. +- **Loaded clients:** SSH **2/2**, public-key user session **5**, slot **0**, broker **8 writer**; public-key admin session **6**, slot **1**, admin-console route, no broker. Both active/authenticated, admin-command idle, zero admin output, no RX/TX pending or closing state. Web **2/2**, both password-authenticated normal-user observers for the same account: slot **0**, fd **55**, generation **1**, broker **10**; slot **1**, fd **56**, generation **1**, broker **11**. Zero tickets, neither web slot TX-pending or closing. +- **Loaded broker/USB/UART:** Exactly four broker clients: SSH **8 writer**, USB **9 observer**, web **10/11 observers**, all with zero pending bytes/events. USB initialized/attached, host-open/DTR/RTS **yes**, broker **9 observer**, reported host line coding **38400 baud, 8N1**. UART running, owner serial service, unchanged **115200-baud 8N1** configuration; RX available/TX pending **0**, modem **DCD=0, DSR=1, CTS=1, RI=0**, **VLD=1**. USB line coding does not reconfigure UART1. +- **Loaded SSH counters:** Starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, failures/disconnects **0**, writer requests/grants **1**, denials **0**, **broker revocations 1**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted **152 B**, rejected **0**, TX **20,012 B**, I/O failures/session revocations **0**. The broker revocation is preserved as an unexplained cumulative event, distinct from session revocation; the current snapshot confirms SSH writer ownership. +- **Loaded web counters:** Starts **1**, start-failures/stops **0**. Requests total/authenticated **119**, status **117**, tickets **2**, root/assets/auth-failures/response-errors **0**. Tickets issued/consumed **2**, rejected/expired **0**. WebSocket connects **2**, disconnects/connect failures/service-start failures/broker failures **0**. RX accepted/rejected frames/bytes **0**; TX **334** binary frames / **37,242 B**, **5** control frames / **398 B**. Writer requests **2**, grants **0**, denials **2**, releases/revocations **0**; send/queue/protocol failures and closes **0**. Denials are consistent with both browsers being observers. +- **Interpretation:** These samples establish post-change service startup, authenticated status requests and concurrent admission/traffic with the full client mix, without reported SSH I/O or web failures. They do not establish byte integrity, lifecycle/soak completion or target exercise of dormant cookie-session APIs. Boot UART is stopped; loaded UART is **115200 baud** and SSH is writer, unlike the **460800-baud browser-writer** 8D.0 full mix. No controlled incremental heap cost or memory improvement is inferred. DMA overlaps internal heap; lifetime minima and SSH stack low-water marks are not approved reserve floors. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 480689a..1418eac 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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" diff --git a/src/web_server.c b/src/web_server.c index 7069aa9..1e3b67a 100644 --- a/src/web_server.c +++ b/src/web_server.c @@ -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) { diff --git a/src/web_session_store.c b/src/web_session_store.c new file mode 100644 index 0000000..d38a87b --- /dev/null +++ b/src/web_session_store.c @@ -0,0 +1,422 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "web_session_store.h" + +#include +#include +#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, ¤t); + 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, ¤t); + 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; +} diff --git a/src/web_session_store.h b/src/web_session_store.h new file mode 100644 index 0000000..be0700b --- /dev/null +++ b/src/web_session_store.h @@ -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 +#include +#include +#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); diff --git a/tests/web_session_store/README.md b/tests/web_session_store/README.md new file mode 100644 index 0000000..5ab6d7c --- /dev/null +++ b/tests/web_session_store/README.md @@ -0,0 +1,25 @@ +# Focused web session store host tests + +From the repository root, run: + +```sh +python3 tests/web_session_store/run.py +``` + +Requires Python 3 and `cc`. Builds the actual `src/web_session_store.c` against +its production headers; generated dependency stubs and binaries live only in an +automatically removed temporary directory. Compilation and execution have finite +timeouts. No ESP-IDF build, device, network, or general test framework is used. + +The runner probes OpenSSL headers/linking and uses real SHA-256 when available. +Otherwise it explicitly reports a deterministic **non-cryptographic digest stub**; +that fallback verifies store behavior, not cryptography. RNG is always a deterministic +test double, never a test of secure randomness. RNG/SHA/DB callbacks assert they +run outside the simulated state lock. DB callbacks inject invalidate/user-invalidate/ +stop operations to exercise revalidation, but do not model real concurrent threads. + +Tests use only the public API: output wiping and record retirement are checked, +but private record bytes, allocator failures, hardware locking, and timing under +real concurrency are not inspected. Rejections without a specified error contract +are checked as non-success; absent tokens, readiness, and capacity use exact errors. +Counters are checked with deltas where lifecycle reset semantics are unspecified. diff --git a/tests/web_session_store/run.py b/tests/web_session_store/run.py new file mode 100644 index 0000000..a362517 --- /dev/null +++ b/tests/web_session_store/run.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Finite host build/run; all generated headers and binaries are temporary.""" +import os +import pathlib +import subprocess +import tempfile + +# Compiler wrappers must not write caches outside the temporary build directory. +os.environ["CCACHE_DISABLE"] = "1" +HERE = pathlib.Path(__file__).resolve().parent +ROOT = HERE.parents[1] +HEADERS = { + "esp_err.h": """#pragma once + typedef int esp_err_t; + #define ESP_OK 0 + #define ESP_FAIL -1 + #define ESP_ERR_NO_MEM 0x101 + #define ESP_ERR_INVALID_ARG 0x102 + #define ESP_ERR_INVALID_STATE 0x103 + #define ESP_ERR_NOT_FOUND 0x105 + #define ESP_ERR_NOT_SUPPORTED 0x106 + #define ESP_ERR_NOT_ALLOWED 0x10d + """, + "freertos/FreeRTOS.h": """#pragma once + #include + typedef int portMUX_TYPE; + #define portMUX_INITIALIZER_UNLOCKED 0 + extern int host_lock_depth; + #define taskENTER_CRITICAL(m) do { (void)(m); assert(host_lock_depth++ == 0); } while (0) + #define taskEXIT_CRITICAL(m) do { (void)(m); assert(--host_lock_depth == 0); } while (0) + """, + "esp_timer.h": "#pragma once\n#include \nint64_t esp_timer_get_time(void);\n", + "mbedtls/sha256.h": "#pragma once\n#include \nint mbedtls_sha256(const unsigned char *, size_t, unsigned char *, int);\n", +} + + +def run(): + source = ROOT / "src/web_session_store.c" + if not source.exists(): + raise SystemExit("Cannot run: src/web_session_store.c has not been created yet") + with tempfile.TemporaryDirectory(prefix="web-session-store-") as directory: + tmp = pathlib.Path(directory) + for name, text in HEADERS.items(): + path = tmp / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + probe = subprocess.run( + ["cc", "-x", "c", "-", "-lcrypto", "-o", str(tmp / "probe")], + input="#include \nint main(void) { unsigned char d[32]; return !SHA256(d, 0, d); }\n", + text=True, capture_output=True, timeout=20) + crypto = ["-DHOST_OPENSSL", "-lcrypto"] if probe.returncode == 0 else [] + print("SHA256: " + ("OpenSSL" if crypto else "NON-CRYPTOGRAPHIC STUB (not crypto verification)"), flush=True) + subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g", + "-I" + str(tmp), "-I" + str(ROOT / "src"), + str(HERE / "test.c"), str(source), *crypto, + "-o", str(tmp / "test")], check=True, timeout=30) + subprocess.run([str(tmp / "test")], check=True, timeout=10) + + +if __name__ == "__main__": + run() diff --git a/tests/web_session_store/test.c b/tests/web_session_store/test.c new file mode 100644 index 0000000..a99e815 --- /dev/null +++ b/tests/web_session_store/test.c @@ -0,0 +1,278 @@ +/* Public-API host tests; deliberately no access to private session records. */ +#include +#include +#include +#include "web_session_store.h" +#include "secure_random.h" +#include "mbedtls/sha256.h" +#ifdef HOST_OPENSSL +#include +#endif + +int host_lock_depth; +static int64_t now = 1000000; +static unsigned rng_sequence, rng_calls, sha_calls, sha_fail_at; +static const char *sha_expected_token; +static void (*rng_hook)(void); +static bool rng_fail, sha_fail, db_fail; +static uint32_t stale_user; +static void (*db_hook)(void); +static web_session_id_t hook_id; +static const char origin[] = "https://device.example"; +static user_principal_t alice = { .user_id = 1, .auth_generation = 7, + .role = USER_ROLE_ADMIN, .method = USER_AUTH_METHOD_PASSWORD, + .username_length = 5, .username = "alice" }; +static user_principal_t bob = { .user_id = 2, .auth_generation = 3, + .role = USER_ROLE_USER, .method = USER_AUTH_METHOD_PASSWORD, + .username_length = 3, .username = "bob" }; +typedef struct { char token[65]; web_session_view_t view; } issued_t; +static issued_t replacement; + +int64_t esp_timer_get_time(void) { return now; } +void secure_wipe(void *data, size_t size) { + volatile unsigned char *p = data; + while (size--) *p++ = 0; +} +esp_err_t secure_random_fill(void *output, size_t length) { + assert(!host_lock_depth); + ++rng_calls; + void (*hook)(void) = rng_hook; + rng_hook = NULL; + if (hook) hook(); + unsigned char *p = output; + /* Unique per call; partial dirty output on failure exercises caller cleanup. */ + unsigned seed = ++rng_sequence; + for (size_t i = 0; i < length; ++i) p[i] = (unsigned char)(seed + i * 17); + return rng_fail ? ESP_FAIL : ESP_OK; +} +int mbedtls_sha256(const unsigned char *data, size_t length, + unsigned char *out, int is224) { + assert(!host_lock_depth && !is224); + memset(out, 0xa5, 32); + ++sha_calls; + if (sha_fail_at && sha_calls == sha_fail_at) { + assert(data == (const unsigned char *)sha_expected_token); + assert(length == 64 && strlen(sha_expected_token) == 64); + return -1; + } + if (sha_fail) return -1; +#ifdef HOST_OPENSSL + return SHA256(data, length, out) ? 0 : -1; +#else + /* Only a deterministic digest double; NOT cryptographic verification. */ + memset(out, 0, 32); + for (size_t i = 0; i < length; ++i) + out[i % 32] = (unsigned char)((out[i % 32] * 33U) ^ data[i] ^ i); + return 0; +#endif +} +esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *current) { + assert(!host_lock_depth); + void (*hook)(void) = db_hook; + db_hook = NULL; + if (hook) hook(); + *current = !db_fail && p->user_id != stale_user; + return db_fail ? ESP_FAIL : ESP_OK; +} +static void zero(const void *data, size_t length) { + const unsigned char *p = data; + for (size_t i = 0; i < length; ++i) assert(p[i] == 0); +} +static web_session_store_snapshot_t snapshot(void) { + web_session_store_snapshot_t s; + assert(web_session_store_get_snapshot(&s) == ESP_OK); + return s; +} +static esp_err_t issue(const user_principal_t *p, issued_t *s) { + memset(s, 0xa5, sizeof(*s)); + return web_session_store_issue(p, origin, strlen(origin), s->token, &s->view); +} +static issued_t mint(const user_principal_t *p) { + issued_t s; + assert(issue(p, &s) == ESP_OK); + assert(s.view.id && strlen(s.token) == 64 && strlen(s.view.csrf) == 64); + assert(s.view.expires_at_us == now + WEB_SESSION_STORE_LIFETIME_US); + assert(s.view.principal.user_id == p->user_id); + assert(s.view.principal.auth_generation == p->auth_generation); + assert(s.view.principal.role == p->role && s.view.principal.method == p->method); + assert(s.view.principal.username_length == p->username_length); + assert(!strcmp(s.view.principal.username, p->username)); + return s; +} +static esp_err_t lookup(const issued_t *s, web_session_view_t *v) { + memset(v, 0xa5, sizeof(*v)); + return web_session_store_lookup(s->token, strlen(s->token), origin, strlen(origin), v); +} +static void present(const issued_t *s) { + web_session_view_t v; + assert(lookup(s, &v) == ESP_OK); + assert(v.id == s->view.id && v.expires_at_us == s->view.expires_at_us); + assert(!strcmp(v.csrf, s->view.csrf)); + assert(!memcmp(&v.principal, &s->view.principal, sizeof(v.principal))); + bool current = false; + assert(web_session_store_is_current(v.id, ¤t) == ESP_OK && current); +} +static void absent(const issued_t *s) { + web_session_view_t v; + assert(lookup(s, &v) == ESP_ERR_NOT_FOUND); + zero(&v, sizeof(v)); + bool current = true; + (void)web_session_store_is_current(s->view.id, ¤t); + assert(!current); +} +static void reset(void) { + web_session_store_stop(); + rng_fail = sha_fail = db_fail = false; + stale_user = 0; + db_hook = rng_hook = NULL; + sha_fail_at = 0; + sha_expected_token = NULL; + assert(web_session_store_init() == ESP_OK); + assert(snapshot().active == 0); +} +static void invalidate_hook(void) { web_session_store_invalidate(hook_id); } +static void user_hook(void) { web_session_store_invalidate_user(alice.user_id); } +static void stop_hook(void) { web_session_store_stop(); } +static void replace_hook(void) { + web_session_store_invalidate(hook_id); + replacement = mint(&bob); +} +static void expire_hook(void) { ++now; } + +int main(void) { + issued_t s, a, b, c, slots[4]; + web_session_view_t v; + bool current = true; + assert(issue(&alice, &s) == ESP_ERR_INVALID_STATE); + zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view)); + memset(&s, 0, sizeof(s)); memset(s.token, 'a', 64); + assert(lookup(&s, &v) == ESP_ERR_INVALID_STATE); zero(&v, sizeof(v)); + assert(web_session_store_is_current(1, ¤t) == ESP_ERR_INVALID_STATE && !current); + rng_fail = true; + assert(web_session_store_init() != ESP_OK); + assert(!snapshot().initialized && snapshot().init_failures == 1); + rng_fail = false; + assert(web_session_store_init() == ESP_OK); + a = mint(&alice); + unsigned calls = rng_calls; + rng_fail = true; + assert(web_session_store_init() == ESP_OK && calls == rng_calls); + rng_fail = false; present(&a); + + reset(); + web_session_store_snapshot_t before = snapshot(); + for (unsigned i = 0; i < 4; ++i) slots[i] = mint(&alice); + assert(issue(&bob, &s) == ESP_ERR_NO_MEM); + zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view)); + for (unsigned i = 0; i < 4; ++i) present(&slots[i]); + assert(snapshot().active == 4 && snapshot().issued == before.issued + 4); + assert(snapshot().capacity_rejections == before.capacity_rejections + 1); + assert(snapshot().storage_bytes >= 4 * snapshot().slot_bytes && snapshot().slot_bytes > 0); + a = slots[0]; + char bad[65]; strcpy(bad, a.token); bad[0] = bad[0] == 'a' ? 'b' : 'a'; + memset(&v, 0xa5, sizeof(v)); + assert(web_session_store_lookup(bad, 64, origin, strlen(origin), &v) == ESP_ERR_NOT_FOUND); + zero(&v, sizeof(v)); + strcpy(bad, a.token); bad[0] = '!'; + assert(web_session_store_lookup(bad, 64, origin, strlen(origin), &v) != ESP_OK); + assert(web_session_store_lookup(a.token, 63, origin, strlen(origin), &v) != ESP_OK); + assert(web_session_store_lookup(a.token, 64, "https://other", 13, &v) == ESP_ERR_NOT_FOUND); + zero(&v, sizeof(v)); present(&a); + now = a.view.expires_at_us - 1; present(&a); + now++; absent(&a); + assert(snapshot().active == 0 && snapshot().expired == before.expired + 4); + + reset(); a = mint(&alice); b = mint(&alice); c = mint(&bob); + before = snapshot(); + web_session_store_invalidate(a.view.id); absent(&a); present(&b); present(&c); + s = mint(&alice); assert(s.view.id != a.view.id); + web_session_store_invalidate(a.view.id); present(&s); + web_session_store_invalidate_user(alice.user_id); + absent(&b); absent(&s); present(&c); + assert(snapshot().invalidated == before.invalidated + 3); + web_session_store_stop(); assert(!snapshot().initialized && snapshot().active == 0); + assert(web_session_store_init() == ESP_OK); + s = mint(&bob); assert(s.view.id != c.view.id); absent(&c); present(&s); + + reset(); stale_user = alice.user_id; + assert(issue(&alice, &s) != ESP_OK); zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view)); + stale_user = 0; a = mint(&alice); b = mint(&bob); + stale_user = alice.user_id; + assert(lookup(&a, &v) != ESP_OK); zero(&v, sizeof(v)); + stale_user = 0; absent(&a); present(&b); + db_fail = true; + assert(lookup(&b, &v) != ESP_OK); zero(&v, sizeof(v)); + db_fail = false; absent(&b); assert(snapshot().active == 0); + a = mint(&alice); b = mint(&bob); stale_user = alice.user_id; + web_session_store_prune(); stale_user = 0; absent(&a); present(&b); + db_fail = true; web_session_store_prune(); db_fail = false; absent(&b); + + reset(); + for (unsigned mode = 0; mode < 2; ++mode) { + rng_fail = mode == 0; sha_fail = mode == 1; + assert(issue(&alice, &s) != ESP_OK); + zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view)); + assert(snapshot().active == 0); + rng_fail = sha_fail = false; + } + a = mint(&alice); sha_fail = true; + assert(lookup(&a, &v) != ESP_OK); zero(&v, sizeof(v)); + sha_fail = false; present(&a); + user_principal_t key = alice; key.method = USER_AUTH_METHOD_SSH_PUBLIC_KEY; + assert(issue(&key, &s) != ESP_OK); zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view)); + + reset(); a = mint(&alice); hook_id = a.view.id; db_hook = invalidate_hook; + assert(lookup(&a, &v) != ESP_OK); zero(&v, sizeof(v)); absent(&a); + a = mint(&alice); hook_id = a.view.id; db_hook = invalidate_hook; current = true; + (void)web_session_store_is_current(a.view.id, ¤t); assert(!current); + b = mint(&bob); db_hook = user_hook; + assert(issue(&alice, &s) != ESP_OK); zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view)); + present(&b); + db_hook = stop_hook; + assert(lookup(&b, &v) != ESP_OK); zero(&v, sizeof(v)); + reset(); db_hook = stop_hook; + assert(issue(&alice, &s) != ESP_OK); zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view)); + assert(!snapshot().initialized && snapshot().active == 0); + /* Empty prune cannot consume this hook: invalidate during candidate validation. */ + reset(); db_hook = user_hook; + assert(issue(&alice, &s) == ESP_ERR_INVALID_STATE && db_hook == NULL); + zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view)); + assert(snapshot().active == 0); + a = mint(&alice); present(&a); + + reset(); a = mint(&alice); hook_id = a.view.id; db_hook = replace_hook; + assert(lookup(&a, &v) == ESP_ERR_NOT_FOUND && db_hook == NULL); + zero(&v, sizeof(v)); + assert(replacement.view.id != a.view.id && snapshot().active == 1); + absent(&a); present(&replacement); + + for (unsigned mode = 0; mode < 2; ++mode) { + reset(); a = mint(&alice); before = snapshot(); + now = a.view.expires_at_us - 1; db_hook = expire_hook; + if (mode == 0) { + assert(lookup(&a, &v) == ESP_ERR_NOT_FOUND); zero(&v, sizeof(v)); + } else { + current = true; + assert(web_session_store_is_current(a.view.id, ¤t) == ESP_ERR_NOT_FOUND); + assert(!current); + } + assert(db_hook == NULL && now == a.view.expires_at_us); + assert(snapshot().expired == before.expired + 1); absent(&a); + } + + reset(); web_session_store_stop(); before = snapshot(); rng_hook = stop_hook; + assert(web_session_store_init() == ESP_ERR_INVALID_STATE && rng_hook == NULL); + assert(!snapshot().initialized && snapshot().active == 0); + assert(snapshot().init_failures == before.init_failures + 1); + assert(issue(&alice, &s) == ESP_ERR_INVALID_STATE); + assert(web_session_store_init() == ESP_OK); a = mint(&alice); present(&a); + + reset(); sha_calls = 0; sha_fail_at = 2; sha_expected_token = s.token; + assert(issue(&alice, &s) != ESP_OK && sha_calls == 2); + zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view)); + assert(snapshot().active == 0); + sha_fail_at = 0; sha_expected_token = NULL; a = mint(&alice); present(&a); + assert(!host_lock_depth); + puts("PASS: web_session_store public API, failure cleanup, and callback races"); + return 0; +}