Files
ESP32_Serial_Swiss_Army_Knife/tests/web_admin_tickets/test.c
T
Commander1024 aeb2043396 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.
2026-09-06 14:41:41 +02:00

286 lines
13 KiB
C

/* 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);
}