Phase 8D.1 implemented and validated.
This commit is contained in:
@@ -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.
|
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://<Host>`; 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.
|
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://<Host>`; 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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
**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}`
|
- 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`
|
- 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
|
- 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
|
- 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`
|
- 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.
|
- 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.
|
- 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.
|
- Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
|
||||||
|
|
||||||
## SSH
|
## SSH
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do
|
|||||||
|
|
||||||
## Development state
|
## 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.
|
- **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`:
|
Based on checked-in source plus `README.md` and `docs/roadmap.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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ idf_component_register(
|
|||||||
"web_assets_data.c"
|
"web_assets_data.c"
|
||||||
"web_ui.c"
|
"web_ui.c"
|
||||||
"web_server.c"
|
"web_server.c"
|
||||||
|
"web_session_store.c"
|
||||||
"web_console.c"
|
"web_console.c"
|
||||||
"wifi_config.c"
|
"wifi_config.c"
|
||||||
"wifi_manager.c"
|
"wifi_manager.c"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
#include "esp_http_server.h"
|
#include "esp_http_server.h"
|
||||||
#include "esp_https_server.h"
|
#include "esp_https_server.h"
|
||||||
|
#include "esp_log.h"
|
||||||
#include "esp_netif_ip_addr.h"
|
#include "esp_netif_ip_addr.h"
|
||||||
#include "esp_timer.h"
|
#include "esp_timer.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
#include "user_database.h"
|
#include "user_database.h"
|
||||||
#include "web_security.h"
|
#include "web_security.h"
|
||||||
#include "web_serial_transport.h"
|
#include "web_serial_transport.h"
|
||||||
|
#include "web_session_store.h"
|
||||||
#include "web_ui.h"
|
#include "web_ui.h"
|
||||||
#include "wifi_manager.h"
|
#include "wifi_manager.h"
|
||||||
|
|
||||||
@@ -652,6 +654,13 @@ esp_err_t web_server_start(void)
|
|||||||
serial_transport_ready = s_serial_transport_initialized;
|
serial_transport_ready = s_serial_transport_initialized;
|
||||||
xSemaphoreGive(s_server_mutex);
|
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 certificate[WEB_SECURITY_CERTIFICATE_DER_CAPACITY] = {0};
|
||||||
uint8_t private_key[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY] = {0};
|
uint8_t private_key[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY] = {0};
|
||||||
size_t certificate_length = 0U;
|
size_t certificate_length = 0U;
|
||||||
@@ -694,6 +703,9 @@ esp_err_t web_server_start(void)
|
|||||||
attach_error = web_serial_transport_attach_server(server);
|
attach_error = web_serial_transport_attach_server(server);
|
||||||
serial_transport_attached = attach_error == ESP_OK;
|
serial_transport_attached = attach_error == ESP_OK;
|
||||||
}
|
}
|
||||||
|
if (error != ESP_OK) {
|
||||||
|
web_session_store_stop();
|
||||||
|
}
|
||||||
if (error != ESP_OK && server != NULL) {
|
if (error != ESP_OK && server != NULL) {
|
||||||
esp_err_t cleanup_error = httpd_ssl_stop(server);
|
esp_err_t cleanup_error = httpd_ssl_stop(server);
|
||||||
if (cleanup_error == ESP_OK) {
|
if (cleanup_error == ESP_OK) {
|
||||||
@@ -738,6 +750,7 @@ esp_err_t web_server_stop(void)
|
|||||||
s_transitioning = true;
|
s_transitioning = true;
|
||||||
xSemaphoreGive(s_server_mutex);
|
xSemaphoreGive(s_server_mutex);
|
||||||
|
|
||||||
|
web_session_store_stop();
|
||||||
if (serial_transport_attached) {
|
if (serial_transport_attached) {
|
||||||
esp_err_t detach_error = web_serial_transport_detach_server(server);
|
esp_err_t detach_error = web_serial_transport_detach_server(server);
|
||||||
if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) {
|
if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) {
|
||||||
|
|||||||
@@ -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, ¤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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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.
|
||||||
@@ -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 <assert.h>
|
||||||
|
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 <stdint.h>\nint64_t esp_timer_get_time(void);\n",
|
||||||
|
"mbedtls/sha256.h": "#pragma once\n#include <stddef.h>\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 <openssl/sha.h>\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()
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/* Public-API host tests; deliberately no access to private session records. */
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "web_session_store.h"
|
||||||
|
#include "secure_random.h"
|
||||||
|
#include "mbedtls/sha256.h"
|
||||||
|
#ifdef HOST_OPENSSL
|
||||||
|
#include <openssl/sha.h>
|
||||||
|
#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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user