Bind Serial Transports To Web Sessions
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 *);
|
||||
""",
|
||||
}
|
||||
@@ -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, ¤t) != ESP_OK && !current);
|
||||
present(&a);
|
||||
}
|
||||
puts("PASS: serial/session binding, isolation, cleanup, failure fallback, races, Basic regression");
|
||||
return 0;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user