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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user