feat: add bounded admin WebSocket backend (Phase 8D.5)

- Require current admin cookie sessions, Origin checks and single-use
  tickets
- Reuse the shared console with session-aware authorization and slot
  allocation
- Add HTTPD-owned I/O, bounded buffering and revocation cleanup
- Prevent LRU eviction of serial clients and stale admin socket closure
- Reject unsupported web-shell mutations before side effects
- Add host regressions, a smoke client and resource accounting

Validated by user sign-off after a 15-minute full-client soak at 230400
baud, with a few broker drops under heavy output. Browser UI remains
for Phase 8D.6; numeric memory reserves remain open.
This commit is contained in:
2026-09-06 14:41:41 +02:00
parent e5dce12ed4
commit aeb2043396
37 changed files with 3651 additions and 91 deletions
+87
View File
@@ -0,0 +1,87 @@
# Admin ticket store host checks
Run from the repository root:
```sh
python3 tests/web_admin_tickets/run.py
python3 tests/web_admin_tickets/run.py --sanitize
```
Requires a C11 `cc`, Python 3, OpenSSL development headers/libcrypto, and (for
`--sanitize`) ASan/UBSan runtimes. No firmware build, network, generated assets,
or persistent build output. The runner reuses the session-store harness's tiny
platform header fakes. `test.c` includes the **unmodified production C**, using
real project principal/session declarations and OpenSSL SHA-256. Inclusion gives
white-box access for wipe, saturation and exhaustion assertions without adding
production test hooks. RNG, time and session validation are deterministic fakes;
all external calls assert that the ticket critical section is not held.
## Exact test groups
1. Stopped/start/idempotent-start lifecycle; 64 hex output; SHA-256 digest-only
storage; success, replay denial and full record wipe.
2. Two-ticket capacity and no live eviction; exact counters; nested competing
issuance takes the last slot and the losing output is wiped.
3. Issue rejects user role, public-key method, mismatched generation, zero ID,
NULL principal/output, stale sessions and session-check errors.
4. Consume burns matches before denying wrong session, user role, public-key
method, generation, stale/check-error, zero ID or NULL principal; also rejects
a different session with the *same* account principal.
5. Empty/NULL/short/long/nonhex input; uppercase hex consumes the same secret.
6. Success one microsecond before expiry; rejection at expiry; stale reclaim on
issue/snapshot; snapshot expiry cleanup; signed deadline overflow rejection.
7. Revocation ID precedence, exact username length/name and global scope;
revocation never invalidates the fake sessions.
8. RNG/SHA failures, failed output wipe, consume SHA failure leaves the
unidentifiable ticket intact, duplicate live RNG/digest rejection.
9. Issuance RNG/SHA hooks exercise stop/restart, global and nonmatching revoke,
and session invalidation; currentness hook exercises stop/restart.
10. Consume SHA/postcheck hooks exercise stop/restart, global/nonmatching revoke,
stale sessions and expiry; nested competing consumes admit exactly once.
11. Prune check races replacement with the same ID, digest and deadline; the
non-reused record generation protects the replacement from stale cleanup.
12. Nonwrapping epoch and record generation exhaustion, permanent lifecycle
failure at exhaustion, saturated counters, NULL/count-only snapshots and
host structure sizes.
## Contract and limits
The public API is in `src/web_admin_tickets.h`. This module is inert until wired
by a later integration increment. It adds no routes, session invalidation,
transport, task, socket, queue, timer or heap allocation. Callers must authorize
HTTP cookie/Origin/CSRF, invalidate the authoritative session store **before**
calling revoke, wipe successful token outputs and recheck currentness at later
sensitive boundaries. A successful consume is not an authorization lease.
Two tickets, 32 RNG bytes each, 64 hex characters plus NUL, absolute 30-second
lifetime. Only SHA-256 of decoded secret bytes is retained with copied principal,
session ID, deadline and unique generation. Both hex cases are accepted. Live
digest collisions fail rather than creating ambiguous tickets. No retry loop
or live eviction. Pruning checks at most two copied records per invocation.
Every revoke advances the epoch even if no record matches, conservatively
cancelling unrelated in-flight issue/consume work. Start is idempotent while
ready. Stop/start never resets counters, epoch or record generation.
`issued` counts published tickets, `consumed` counts burned matches (including
subsequently denied admissions), `rejected` counts failed issue/consume calls;
`capacity_rejections` is a subset of rejected. All counters saturate at UINT32_MAX.
Snapshot prunes expired/stale records and exports counts, readiness and storage
size only. A capacity failure is ESP_ERR_NO_MEM; malformed input INVALID_ARG;
unauthorized/stale/lifecycle-raced work INVALID_STATE; no live consume match
NOT_FOUND; SHA failure ESP_FAIL; RNG errors propagate. Failed issue wipes all 65
output bytes when output is non-NULL. Output must not alias inputs.
Host measured sizes: ticket 104 B, two-ticket state 248 B, fake lock 4 B, snapshot
40 B; snapshot `storage_bytes` = 252 B. Estimated 32-bit target sizes: ticket
96 B, state 232 B, plus the target portMUX (typically 8 B), roughly **240 B static
RAM**. These are estimates, not target linker measurements. Issue plus nested
prune has 240 B of explicit ticket/random local payload on this host (about
224 B on a 32-bit target), excluding scalar/compiler frames and session/RNG/SHA
call stacks; caller also owns a 65 B token. No measured target stack/flash delta.
Hooks test deterministic interleavings, not true multicore scheduling or IDF
portMUX semantics. They do not validate the real DRBG, mbedTLS, session database,
HTTP admission, hardware, or full Phase 8D.5 integration. Post-check account
changes without notification are subject to the same no-lease boundary as the
session API. Combined hardware validation remains pending; no firmware build
or device operation is part of this increment.
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Compile production ticket C with deterministic boundary fakes; no firmware build."""
import os
import pathlib
import runpy
import subprocess
import sys
import tempfile
sys.dont_write_bytecode = True
os.environ["CCACHE_DISABLE"] = "1"
HERE = pathlib.Path(__file__).resolve().parent
ROOT = HERE.parents[1]
HEADERS = runpy.run_path(str(HERE.parent / "web_session_store/run.py"))["HEADERS"]
with tempfile.TemporaryDirectory(prefix="web-admin-tickets-") 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)
sanitize = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] if "--sanitize" in sys.argv else []
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
*sanitize, "-I" + str(tmp), "-I" + str(ROOT / "src"),
str(HERE / "test.c"), "-lcrypto", "-o", str(tmp / "test")],
check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=20)
+285
View File
@@ -0,0 +1,285 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <ctype.h>
#include <stdio.h>
#include <string.h>
#include <openssl/sha.h>
/* Include unmodified production C to inspect wipes, ABA and exhaustion without
* adding firmware-only test hooks. Platform/user/session headers remain real. */
#include "web_admin_tickets.c"
int host_lock_depth;
static int64_t clock_us;
static unsigned random_sequence;
static bool rng_fail, sha_fail, session_fail, live[4];
static user_principal_t principals[4];
static void (*rng_hook)(void), (*sha_hook)(void), (*check_hook)(void);
static unsigned check_calls, hook_at;
static unsigned tests;
int64_t esp_timer_get_time(void) { assert(!host_lock_depth); return clock_us; }
void secure_wipe(void *p, size_t n)
{
volatile unsigned char *v = p;
while (n--) *v++ = 0;
}
static void fire(void (**hook)(void))
{
void (*call)(void) = *hook;
*hook = NULL;
if (call) call();
}
esp_err_t secure_random_fill(void *p, size_t n)
{
assert(!host_lock_depth && n == 32);
memset(p, ++random_sequence, n);
fire(&rng_hook);
return rng_fail ? ESP_FAIL : ESP_OK;
}
int mbedtls_sha256(const unsigned char *p, size_t n, unsigned char *out, int mode)
{
assert(!host_lock_depth && n == 32 && mode == 0);
assert(SHA256(p, n, out));
fire(&sha_hook);
return sha_fail ? -1 : 0;
}
esp_err_t web_session_store_check_principal(web_session_id_t id,
const user_principal_t *p, bool *valid)
{
assert(!host_lock_depth);
++check_calls;
*valid = id < 4 && live[id] && same_principal(&principals[id], p);
if (check_calls == hook_at) fire(&check_hook);
/* Like the real session resolver, recheck liveness before returning;
* never upgrade an already-failed check after a slot replacement. */
*valid = *valid && id < 4 && live[id] && same_principal(&principals[id], p);
return session_fail ? ESP_FAIL : ESP_OK;
}
static void zero(const void *p, size_t n)
{
const unsigned char *v = p;
while (n--) assert(*v++ == 0);
}
static void reset(void)
{
/* Test isolation only; production never resets these generations. */
memset(&s_state, 0, sizeof(s_state));
clock_us = 100;
random_sequence = 0;
rng_fail = sha_fail = session_fail = false;
rng_hook = sha_hook = check_hook = NULL;
check_calls = hook_at = 0;
for (unsigned i = 1; i < 4; ++i) {
live[i] = true;
principals[i] = (user_principal_t) {
.user_id = i, .auth_generation = 1, .role = USER_ROLE_ADMIN,
.method = USER_AUTH_METHOD_PASSWORD, .username_length = 1,
.username = {(char)('a' + i - 1), 0},
};
}
web_admin_tickets_start();
}
static void passed(const char *name) { ++tests; printf("PASS %s\n", name); }
static void issue(unsigned id, char *token)
{
assert(web_admin_tickets_issue(id, &principals[id], token) == ESP_OK);
}
static void restart(void) { web_admin_tickets_stop(); web_admin_tickets_start(); }
static void revoke_all(void) { web_admin_tickets_revoke(0, NULL, 0); }
static void revoke_other(void) { web_admin_tickets_revoke(99, NULL, 0); }
static void stale(void) { live[1] = false; }
static void expire(void) { clock_us += WEB_ADMIN_TICKET_LIFETIME_US; }
static void fail_issue(void)
{
char token[65];
memset(token, 'x', sizeof(token));
assert(web_admin_tickets_issue(1, &principals[1], token) != ESP_OK);
zero(token, sizeof(token));
zero(s_state.tickets, sizeof(s_state.tickets));
}
static char replacement[65];
static void replace_stale(void)
{
revoke_all();
live[1] = true;
random_sequence = 0; /* Same digest, ID and deadline: only generation differs. */
issue(1, replacement);
}
static char nested_token[65];
static esp_err_t nested_result;
static void nested_issue(void)
{
issue(2, nested_token);
}
static void nested_consume(void)
{
nested_result = web_admin_tickets_consume(nested_token, 1, &principals[1]);
}
int main(void)
{
char a[65], b[65], c[65];
web_admin_tickets_snapshot_t snap;
reset();
web_admin_tickets_stop(); fail_issue();
web_admin_tickets_start(); issue(1, a);
uint64_t epoch = s_state.epoch;
web_admin_tickets_start(); assert(s_state.epoch == epoch);
assert(strlen(a) == 64);
for (unsigned i = 0; i < 64; ++i) assert(isxdigit((unsigned char)a[i]));
uint8_t raw[32]; memset(raw, 1, sizeof(raw));
uint8_t expected[32]; assert(SHA256(raw, sizeof(raw), expected));
assert(equal_digest(s_state.tickets[1].digest, expected));
assert(memcmp(s_state.tickets[1].digest, raw, 32));
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
zero(s_state.tickets, sizeof(s_state.tickets));
passed("lifecycle, hex/digest storage, single use and wipe");
reset(); issue(1, a); issue(2, b);
assert(web_admin_tickets_issue(3, &principals[3], c) == ESP_ERR_NO_MEM);
zero(c, sizeof(c)); web_admin_tickets_get_snapshot(&snap);
assert(snap.active == 2 && snap.issued == 2 && snap.rejected == 1 &&
snap.capacity_rejections == 1 && snap.ready);
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
assert(web_admin_tickets_consume(b, 2, &principals[2]) == ESP_OK);
reset(); issue(3, c); rng_hook = nested_issue;
assert(web_admin_tickets_issue(1, &principals[1], a) == ESP_ERR_NO_MEM);
zero(a, sizeof(a));
assert(web_admin_tickets_consume(nested_token, 2, &principals[2]) == ESP_OK);
assert(web_admin_tickets_consume(c, 3, &principals[3]) == ESP_OK);
passed("capacity rejects without live eviction, competing issue and exact counters");
reset(); principals[1].role = USER_ROLE_USER; fail_issue();
principals[1].role = USER_ROLE_ADMIN;
principals[1].method = USER_AUTH_METHOD_SSH_PUBLIC_KEY; fail_issue();
principals[1].method = USER_AUTH_METHOD_PASSWORD;
user_principal_t bad = principals[1]; bad.auth_generation++;
assert(web_admin_tickets_issue(1, &bad, a) == ESP_ERR_INVALID_STATE);
assert(web_admin_tickets_issue(0, &principals[1], a) == ESP_ERR_INVALID_ARG);
assert(web_admin_tickets_issue(1, NULL, a) == ESP_ERR_INVALID_ARG);
assert(web_admin_tickets_issue(1, &principals[1], NULL) == ESP_ERR_INVALID_ARG);
live[1] = false; fail_issue(); live[1] = true;
session_fail = true; fail_issue();
passed("issue role, password, session/principal binding and errors");
for (unsigned mode = 0; mode < 8; ++mode) {
reset(); issue(1, a); bad = principals[1];
unsigned id = 1;
if (mode == 0) id = 2;
if (mode == 1) bad.role = USER_ROLE_USER;
if (mode == 2) bad.method = USER_AUTH_METHOD_SSH_PUBLIC_KEY;
if (mode == 3) bad.auth_generation++;
if (mode == 4) live[1] = false;
if (mode == 5) session_fail = true;
if (mode == 6) id = 0;
assert(web_admin_tickets_consume(a, id, mode == 7 ? NULL : &bad) == ESP_ERR_INVALID_STATE);
zero(s_state.tickets, sizeof(s_state.tickets));
assert(s_state.consumed == 1);
}
reset(); principals[2] = principals[1]; issue(1, a);
assert(web_admin_tickets_consume(a, 2, &principals[2]) == ESP_ERR_INVALID_STATE);
zero(s_state.tickets, sizeof(s_state.tickets));
passed("consume burns before wrong identity/role/currentness results, same-account session binding");
reset(); random_sequence = 170; issue(1, a);
strcpy(b, a); b[63] = 0;
assert(web_admin_tickets_consume(b, 1, &principals[1]) == ESP_ERR_INVALID_ARG);
char long_token[66]; memcpy(long_token, a, 64); long_token[64] = 'a'; long_token[65] = 0;
assert(web_admin_tickets_consume(long_token, 1, &principals[1]) == ESP_ERR_INVALID_ARG);
assert(web_admin_tickets_consume("", 1, &principals[1]) == ESP_ERR_INVALID_ARG);
assert(web_admin_tickets_consume(NULL, 1, &principals[1]) == ESP_ERR_INVALID_ARG);
strcpy(b, a); b[30] = 'g';
assert(web_admin_tickets_consume(b, 1, &principals[1]) == ESP_ERR_INVALID_ARG);
for (unsigned i = 0; i < 64; ++i) a[i] = (char)toupper((unsigned char)a[i]);
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
passed("exact bounded hex validation and uppercase equivalence");
reset(); issue(1, a); clock_us += WEB_ADMIN_TICKET_LIFETIME_US - 1;
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
issue(1, a); expire();
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
issue(1, a); issue(2, b); live[1] = false; issue(3, c);
web_admin_tickets_get_snapshot(&snap); assert(snap.active == 2);
live[2] = false; web_admin_tickets_get_snapshot(&snap); assert(snap.active == 1);
expire(); web_admin_tickets_get_snapshot(&snap); assert(snap.active == 0);
clock_us = INT64_MAX - WEB_ADMIN_TICKET_LIFETIME_US + 1;
fail_issue();
passed("absolute expiry boundary, stale cleanup and time overflow");
reset(); issue(1, a); issue(2, b);
web_admin_tickets_revoke(1, (const uint8_t *)"b", 1);
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
assert(web_admin_tickets_consume(b, 2, &principals[2]) == ESP_OK);
issue(1, a); issue(2, b);
web_admin_tickets_revoke(0, (const uint8_t *)"a", 0);
web_admin_tickets_get_snapshot(&snap); assert(snap.active == 2);
web_admin_tickets_revoke(0, (const uint8_t *)"a", 1);
web_admin_tickets_get_snapshot(&snap); assert(snap.active == 1);
revoke_all(); zero(s_state.tickets, sizeof(s_state.tickets));
assert(live[1] && live[2]);
passed("revoke ID precedence, exact username, all; no session invalidation");
reset(); rng_fail = true; fail_issue();
reset(); sha_fail = true; fail_issue();
reset(); issue(1, a); sha_fail = true;
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_FAIL);
sha_fail = false;
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
reset(); issue(1, a); random_sequence = 0;
assert(web_admin_tickets_issue(2, &principals[2], b) == ESP_FAIL);
zero(b, sizeof(b));
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
passed("RNG/SHA failure, output wipe and live digest collision rejection");
void (*actions[])(void) = {restart, revoke_all, revoke_other, stale};
for (unsigned i = 0; i < sizeof(actions) / sizeof(actions[0]); ++i) {
reset(); rng_hook = actions[i]; fail_issue();
reset(); sha_hook = actions[i]; fail_issue();
}
reset(); hook_at = 1; check_hook = restart; fail_issue();
passed("issue stop/restart, revoke and stale races across RNG/SHA/currentness");
for (unsigned i = 0; i < sizeof(actions) / sizeof(actions[0]); ++i) {
reset(); issue(1, a); sha_hook = actions[i];
assert(web_admin_tickets_consume(a, 1, &principals[1]) != ESP_OK);
reset(); issue(1, a); hook_at = check_calls + 2; check_hook = actions[i];
assert(web_admin_tickets_consume(a, 1, &principals[1]) != ESP_OK);
assert(s_state.consumed == 1);
}
reset(); issue(1, a); sha_hook = expire;
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
reset(); issue(1, a); hook_at = check_calls + 2; check_hook = expire;
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_INVALID_STATE);
reset(); issue(1, nested_token); sha_hook = nested_consume;
assert(web_admin_tickets_consume(nested_token, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
assert(nested_result == ESP_OK && s_state.consumed == 1);
passed("consume crypto/postcheck lifecycle/expiry races and competing consume");
reset(); issue(1, a); live[1] = false;
hook_at = check_calls + 1; check_hook = replace_stale;
web_admin_tickets_get_snapshot(&snap); assert(snap.active == 1);
assert(web_admin_tickets_consume(replacement, 1, &principals[1]) == ESP_OK);
passed("stale-prune slot replacement ABA");
reset(); issue(1, a); s_state.epoch = UINT64_MAX - 1;
revoke_all(); web_admin_tickets_start();
assert(s_state.epoch == UINT64_MAX && !s_state.ready); fail_issue();
restart(); assert(s_state.epoch == UINT64_MAX && !s_state.ready);
reset(); s_state.generation = UINT64_MAX - 1; issue(1, a);
assert(web_admin_tickets_issue(1, &principals[1], b) == ESP_ERR_INVALID_STATE);
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
restart(); assert(!s_state.ready);
reset(); s_state.issued = s_state.consumed = s_state.rejected = UINT32_MAX;
s_state.capacity_rejections = UINT32_MAX;
issue(1, a); issue(2, b);
assert(web_admin_tickets_issue(3, &principals[3], c) == ESP_ERR_NO_MEM);
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
web_admin_tickets_get_snapshot(&snap);
assert(snap.issued == UINT32_MAX && snap.consumed == UINT32_MAX &&
snap.rejected == UINT32_MAX && snap.capacity_rejections == UINT32_MAX);
web_admin_tickets_get_snapshot(NULL);
printf("Host sizes: ticket=%zu state=%zu lock=%zu snapshot=%zu bytes\n",
sizeof(ticket_t), sizeof(s_state), sizeof(s_lock), sizeof(snap));
passed("nonwrapping epoch/generation, saturating counters, count-only snapshot");
printf("%u test groups passed\n", tests);
}