Bind Serial Transports To Web Sessions

This commit is contained in:
2026-09-05 18:01:39 +02:00
parent 93eef0e676
commit a62a655ac1
18 changed files with 528 additions and 35 deletions
+16 -1
View File
@@ -4,6 +4,8 @@ From the repository root, run:
```sh
python3 tests/web_session_store/run.py
python3 tests/web_session_store/run.py --serial
python3 tests/web_session_store/run.py --serial --sanitize
```
Requires Python 3 and `cc`. Builds the actual `src/web_session_store.c` against
@@ -18,8 +20,21 @@ 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,
The default store 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.
`--serial` also compiles the production serial transport into the test translation
unit to step its private ticket/admission/input/currentness/cleanup boundaries.
It links the real session store separately and uses minimal HTTPD/broker/task
doubles, not real sockets or a running scheduler. Unused firmware functions are
linker-garbage-collected. Tests cover matching-session single-use tickets, every
principal field, same-account session isolation, account-wide ticket/socket/store
cleanup, serial-init failure isolation, close-queue failure/retry, slot reuse,
expiry and database failure without notification, logout during mint/admission,
stale periodic work, and Basic operation with the store disabled. No secrets are
printed. `--sanitize` enables compiler AddressSanitizer and UndefinedBehaviorSanitizer.
Real HTTPD task ordering, queued sends, target timing/stack/heap and browser cookie
policy remain outside this harness; cookie routes do not exist in 8D.2.
+11 -1
View File
@@ -4,6 +4,9 @@ import os
import pathlib
import subprocess
import tempfile
import sys
sys.dont_write_bytecode = True
# Compiler wrappers must not write caches outside the temporary build directory.
os.environ["CCACHE_DISABLE"] = "1"
@@ -35,6 +38,10 @@ HEADERS = {
def run():
serial = "--serial" in sys.argv
if serial:
from serial_headers import SERIAL_HEADERS
HEADERS.update(SERIAL_HEADERS)
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")
@@ -50,9 +57,12 @@ def run():
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)
sanitizer = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] if "--sanitize" in sys.argv else []
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
*sanitizer,
"-I" + str(tmp), "-I" + str(ROOT / "src"),
str(HERE / "test.c"), str(source), *crypto,
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
str(HERE / ("serial_test.c" if serial else "test.c")), str(source), *crypto,
"-o", str(tmp / "test")], check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=10)
+50
View File
@@ -0,0 +1,50 @@
"""Minimal IDF declarations for the focused serial/session integration harness."""
SERIAL_HEADERS = {
"sdkconfig.h": "#define CONFIG_HTTPD_WS_SUPPORT 1\n",
"driver/uart.h": "typedef int uart_config_t;\n",
"freertos/task.h": """#pragma once
#include <stdint.h>
#include <stddef.h>
typedef void *TaskHandle_t;
typedef uint32_t TickType_t;
typedef uint32_t StackType_t;
typedef int StaticTask_t;
#define pdMS_TO_TICKS(x) (x)
#define pdTRUE 1
#define ESP_ERR_TIMEOUT 0x107
void xTaskNotifyGive(TaskHandle_t);
uint32_t ulTaskNotifyTake(int, TickType_t);
void vTaskDelay(TickType_t);
TaskHandle_t xTaskCreateStatic(void (*)(void *), const char *, uint32_t, void *,
unsigned, StackType_t *, StaticTask_t *);
""",
"esp_http_server.h": """#pragma once
#include <stdbool.h>
#include <stddef.h>
#include "esp_err.h"
typedef void *httpd_handle_t;
typedef struct { httpd_handle_t handle; void *sess_ctx; void (*free_ctx)(void *);
int method; size_t content_len; } httpd_req_t;
typedef enum { HTTPD_WS_TYPE_CONTINUE, HTTPD_WS_TYPE_TEXT, HTTPD_WS_TYPE_BINARY } httpd_ws_type_t;
typedef enum { HTTPD_WS_CLIENT_HTTP, HTTPD_WS_CLIENT_WEBSOCKET } httpd_ws_client_info_t;
typedef struct { bool final, fragmented; httpd_ws_type_t type; unsigned char *payload;
size_t len; } httpd_ws_frame_t;
#define HTTP_POST 1
size_t httpd_req_get_url_query_len(httpd_req_t *);
esp_err_t httpd_req_get_url_query_str(httpd_req_t *, char *, size_t);
esp_err_t httpd_req_get_hdr_value_str(httpd_req_t *, const char *, char *, size_t);
size_t httpd_req_get_hdr_value_len(httpd_req_t *, const char *);
int httpd_req_to_sockfd(httpd_req_t *);
esp_err_t httpd_resp_set_status(httpd_req_t *, const char *);
esp_err_t httpd_resp_set_type(httpd_req_t *, const char *);
esp_err_t httpd_resp_set_hdr(httpd_req_t *, const char *, const char *);
esp_err_t httpd_resp_send(httpd_req_t *, const char *, int);
httpd_ws_client_info_t httpd_ws_get_fd_info(httpd_handle_t, int);
esp_err_t httpd_ws_recv_frame(httpd_req_t *, httpd_ws_frame_t *, size_t);
esp_err_t httpd_ws_send_frame_async(httpd_handle_t, int, httpd_ws_frame_t *);
void *httpd_sess_get_ctx(httpd_handle_t, int);
esp_err_t httpd_sess_update_lru_counter(httpd_handle_t, int);
esp_err_t httpd_sess_trigger_close(httpd_handle_t, int);
esp_err_t httpd_queue_work(httpd_handle_t, void (*)(void *), void *);
""",
}
+158
View File
@@ -0,0 +1,158 @@
/* Exercise production private admission/owner steps with deterministic IDF doubles.
* Store remains a separately compiled production module, not a session mock. */
#define main store_tests
#include "test.c"
#undef main
#include "../../src/web_serial_transport.c"
static char query[48];
static unsigned broker_connections, broker_disconnects, writes, closes;
static esp_err_t close_result = ESP_OK;
static httpd_req_t request = { .handle = (void *)1 };
static void (*connect_hook)(void);
void xTaskNotifyGive(TaskHandle_t task) { (void)task; assert(!host_lock_depth); }
size_t httpd_req_get_url_query_len(httpd_req_t *r) { (void)r; return strlen(query); }
esp_err_t httpd_req_get_url_query_str(httpd_req_t *r, char *out, size_t n) {
(void)r; assert(strlen(query) < n); strcpy(out, query); return ESP_OK;
}
esp_err_t httpd_req_get_hdr_value_str(httpd_req_t *r, const char *key, char *out, size_t n) {
(void)r; (void)key; (void)out; (void)n; return ESP_ERR_NOT_FOUND;
}
size_t httpd_req_get_hdr_value_len(httpd_req_t *r, const char *key) { (void)r; (void)key; return 0; }
int httpd_req_to_sockfd(httpd_req_t *r) { (void)r; return 10; }
httpd_ws_client_info_t httpd_ws_get_fd_info(httpd_handle_t h, int fd) {
(void)h; (void)fd; return HTTPD_WS_CLIENT_WEBSOCKET;
}
esp_err_t httpd_ws_recv_frame(httpd_req_t *r, httpd_ws_frame_t *f, size_t n) {
(void)r; f->final = true; f->type = HTTPD_WS_TYPE_BINARY; f->len = 1;
if (n) f->payload[0] = 0;
return ESP_OK;
}
bool serial_service_is_running(void) { return true; }
esp_err_t serial_service_start(void) { return ESP_OK; }
esp_err_t session_broker_connect(session_broker_client_type_t t, const char *name,
session_broker_client_id_t *id) {
(void)t; (void)name; assert(!host_lock_depth); *id = ++broker_connections;
if (connect_hook) { void (*hook)(void) = connect_hook; connect_hook = NULL; hook(); }
return ESP_OK;
}
esp_err_t session_broker_disconnect(session_broker_client_id_t id) {
(void)id; assert(!host_lock_depth); ++broker_disconnects; return ESP_OK;
}
esp_err_t session_broker_request_writer(session_broker_client_id_t id) { (void)id; return ESP_OK; }
esp_err_t session_broker_release_writer(session_broker_client_id_t id) { (void)id; return ESP_OK; }
esp_err_t session_broker_write(session_broker_client_id_t id, const uint8_t *p, size_t n, size_t *a) {
(void)id; (void)p; ++writes; *a = n; return ESP_OK;
}
esp_err_t httpd_sess_trigger_close(httpd_handle_t h, int fd) {
(void)h; (void)fd; assert(!host_lock_depth); ++closes; return close_result;
}
void *httpd_sess_get_ctx(httpd_handle_t h, int fd) { (void)h; (void)fd; return request.sess_ctx; }
static void serial_reset(void) {
reset(); memset(s_slots, 0, sizeof(s_slots)); memset(s_tickets, 0, sizeof(s_tickets));
s_initialized = true; s_server = request.handle; request.sess_ctx = NULL;
close_result = ESP_OK; connect_hook = NULL;
}
static void ticket_for(const issued_t *s, char *ticket) {
assert(web_serial_transport_mint_ticket(&s->view.principal, s->view.id,
ticket, 33) == ESP_OK);
}
static web_serial_slot_t *connect_session(const issued_t *s) {
char ticket[33]; ticket_for(s, ticket);
snprintf(query, sizeof(query), "ticket=%s", ticket);
assert(connect_websocket(&request, 10, s->view.id) == ESP_OK);
return request.sess_ctx;
}
static void logout_hook(void) { assert(web_serial_transport_revoke_web_session(hook_id) == ESP_OK); }
static void reuse_slot_hook(void) {
make_slot_free_locked(&s_slots[0]);
++s_slots[0].generation; s_slots[0].state = WEB_SERIAL_SLOT_ACTIVE;
s_slots[0].principal = bob; s_slots[0].web_session_id = replacement.view.id;
}
int main(void) {
assert(store_tests() == 0);
serial_reset(); issued_t a = mint(&alice), b = mint(&alice), c = mint(&bob);
char ta[33], tb[33], tc[33]; ticket_for(&a, ta); ticket_for(&b, tb); ticket_for(&c, tc);
user_principal_t p; bool consumed;
assert(consume_ticket(ta, b.view.id, &p, &consumed) == ESP_OK && !consumed);
assert(consume_ticket(ta, 0, &p, &consumed) == ESP_OK && !consumed);
assert(consume_ticket(ta, a.view.id, &p, &consumed) == ESP_OK && consumed);
assert(consume_ticket(ta, a.view.id, &p, &consumed) == ESP_OK && !consumed);
assert(web_serial_transport_mint_ticket(&bob, a.view.id, ta, sizeof(ta)) != ESP_OK);
web_serial_slot_t *sa = connect_session(&a), *sb = connect_session(&b);
ticket_for(&a, ta);
assert(web_serial_transport_revoke_web_session(a.view.id) == ESP_OK);
assert(sa->close_requested && !sb->close_requested); absent(&a); present(&b); present(&c);
assert(consume_ticket(ta, a.view.id, &p, &consumed) == ESP_OK && !consumed);
assert(consume_ticket(tb, b.view.id, &p, &consumed) == ESP_OK && consumed);
request.sess_ctx = sa; close_result = ESP_FAIL; process_close_request(sa);
assert(!sa->close_triggered && sa->close_requested); close_result = ESP_OK;
now += 100000; process_close_request(sa); assert(sa->close_triggered);
web_serial_session_free(sa); process_broker_disconnect(sa); assert(sa->state == WEB_SERIAL_SLOT_FREE);
issued_t d = mint(&alice); sa = connect_session(&d);
assert(web_serial_transport_revoke_web_session(a.view.id) == ESP_OK && !sa->close_requested);
ticket_for(&b, tb); ticket_for(&d, ta);
assert(web_serial_transport_revoke_user((const uint8_t *)"alice", 5) == ESP_OK);
assert(sa->close_requested && sb->close_requested); absent(&b); absent(&d); present(&c);
assert(consume_ticket(ta, d.view.id, &p, &consumed) == ESP_OK && !consumed);
assert(consume_ticket(tb, b.view.id, &p, &consumed) == ESP_OK && !consumed);
assert(consume_ticket(tc, c.view.id, &p, &consumed) == ESP_OK && consumed);
/* Lost notification: authoritative checks at input and periodic owner step. */
serial_reset(); a = mint(&alice); sa = connect_session(&a);
unsigned before = writes; web_session_store_invalidate(a.view.id);
assert(process_websocket_frame(&request) == ESP_FAIL && writes == before);
serial_reset(); a = mint(&alice); sa = connect_session(&a);
now = a.view.expires_at_us; process_principal_currentness(sa); assert(sa->close_requested);
serial_reset(); a = mint(&alice); sa = connect_session(&a);
stale_user = alice.user_id; now += WEB_SERIAL_CURRENTNESS_INTERVAL_US;
process_principal_currentness(sa); assert(sa->close_requested); absent(&a);
serial_reset(); a = mint(&alice); ticket_for(&a, ta); stale_user = alice.user_id;
(void)consume_ticket(ta, a.view.id, &p, &consumed); assert(!consumed);
serial_reset(); a = mint(&alice); sa = connect_session(&a); db_fail = true;
before = writes;
assert(process_websocket_frame(&request) == ESP_FAIL && writes == before);
/* In-flight mint/admission cannot publish after logout. */
serial_reset(); a = mint(&alice); hook_id = a.view.id; rng_hook = logout_hook;
assert(web_serial_transport_mint_ticket(&alice, a.view.id, ta, sizeof(ta)) != ESP_OK && !ta[0]);
serial_reset(); a = mint(&alice); ticket_for(&a, ta); hook_id = a.view.id;
snprintf(query, sizeof(query), "ticket=%s", ta); connect_hook = logout_hook;
before = broker_disconnects;
assert(connect_websocket(&request, 10, a.view.id) != ESP_OK);
assert(broker_disconnects == before + 1 && s_slots[0].state == WEB_SERIAL_SLOT_FREE);
serial_reset(); a = mint(&alice); sa = connect_session(&a); replacement = mint(&bob);
now += WEB_SERIAL_CURRENTNESS_INTERVAL_US; stale_user = alice.user_id; db_hook = reuse_slot_hook;
process_principal_currentness(sa); assert(!sa->close_requested);
/* Basic tickets still work with a disabled store, but never accept bound tickets. */
serial_reset(); web_session_store_stop();
assert(web_serial_transport_mint_ticket(&alice, 0, ta, sizeof(ta)) == ESP_OK);
snprintf(query, sizeof(query), "ticket=%s", ta);
assert(connect_websocket(&request, 10, 0) == ESP_OK);
before = writes; assert(process_websocket_frame(&request) == ESP_OK && writes == before + 1);
serial_reset(); a = mint(&alice); b = mint(&bob);
assert(web_serial_transport_revoke_sessions() == ESP_OK); absent(&a); absent(&b);
assert(snapshot().initialized); assert(!host_lock_depth && closes > 0);
serial_reset(); a = mint(&alice); b = mint(&bob); s_initialized = false;
assert(web_serial_transport_revoke_user((const uint8_t *)"alice", 5) == ESP_ERR_INVALID_STATE);
absent(&a); present(&b);
serial_reset(); a = mint(&alice);
for (unsigned field = 0; field < 6; ++field) {
p = alice;
switch (field) {
case 0: ++p.user_id; break;
case 1: ++p.auth_generation; break;
case 2: p.role = USER_ROLE_USER; break;
case 3: p.method = USER_AUTH_METHOD_SSH_PUBLIC_KEY; break;
case 4: --p.username_length; break;
case 5: p.username[0] = 'x'; break;
}
bool current = true;
assert(web_session_store_check_principal(a.view.id, &p, &current) != ESP_OK && !current);
present(&a);
}
puts("PASS: serial/session binding, isolation, cleanup, failure fallback, races, Basic regression");
return 0;
}
+3
View File
@@ -29,6 +29,9 @@ 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; }
bool user_database_username_valid(const uint8_t *name, size_t length) {
return name != NULL && length > 0 && length <= USER_DATABASE_USERNAME_CAPACITY;
}
void secure_wipe(void *data, size_t size) {
volatile unsigned char *p = data;
while (size--) *p++ = 0;