Files
ESP32_Serial_Swiss_Army_Knife/src/web_admin_tickets.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

301 lines
9.9 KiB
C

/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_admin_tickets.h"
#include <limits.h>
#include <string.h>
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "mbedtls/sha256.h"
#include "secure_random.h"
typedef struct {
uint64_t generation;
web_session_id_t id;
int64_t expires_at_us;
user_principal_t principal;
uint8_t digest[32];
} ticket_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static struct {
ticket_t tickets[WEB_ADMIN_TICKET_CAPACITY];
uint64_t epoch;
uint64_t generation;
uint32_t issued, consumed, rejected, capacity_rejections;
bool ready;
} s_state;
static void increment(uint32_t *counter)
{
if (*counter != UINT32_MAX) ++*counter;
}
static bool equal_digest(const uint8_t *a, const uint8_t *b)
{
volatile uint8_t difference = 0;
for (size_t i = 0; i < 32; ++i) difference |= a[i] ^ b[i];
return difference == 0;
}
static bool admin(const user_principal_t *p)
{
return p != NULL && p->role == USER_ROLE_ADMIN &&
p->method == USER_AUTH_METHOD_PASSWORD && p->user_id != 0 &&
p->auth_generation != 0 && p->username_length != 0 &&
p->username_length <= USER_DATABASE_USERNAME_CAPACITY;
}
static bool same_principal(const user_principal_t *a, const user_principal_t *b)
{
return admin(b) && a->user_id == b->user_id &&
a->auth_generation == b->auth_generation && a->role == b->role &&
a->method == b->method && a->username_length == b->username_length &&
memcmp(a->username, b->username, a->username_length) == 0;
}
static bool current(web_session_id_t id, const user_principal_t *p)
{
bool valid = false;
return id != 0 && admin(p) &&
web_session_store_check_principal(id, p, &valid) == ESP_OK && valid;
}
static void expire_locked(int64_t now)
{
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t *t = &s_state.tickets[i];
if (t->generation && t->expires_at_us <= now) secure_wipe(t, sizeof(*t));
}
}
/* Fixed two-slot walk. Generation prevents an external check from deleting a
* replacement, including when RNG returns the same bytes on a later issue. */
static void prune(void)
{
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t copy = {0};
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
copy = s_state.tickets[i];
taskEXIT_CRITICAL(&s_lock);
if (copy.generation && !current(copy.id, &copy.principal)) {
taskENTER_CRITICAL(&s_lock);
if (s_state.tickets[i].generation == copy.generation)
secure_wipe(&s_state.tickets[i], sizeof(ticket_t));
taskEXIT_CRITICAL(&s_lock);
}
secure_wipe(&copy, sizeof(copy));
}
}
static void advance_epoch_locked(void)
{
if (s_state.epoch != UINT64_MAX) ++s_state.epoch;
if (s_state.epoch == UINT64_MAX) {
s_state.ready = false;
secure_wipe(s_state.tickets, sizeof(s_state.tickets));
}
}
void web_admin_tickets_start(void)
{
taskENTER_CRITICAL(&s_lock);
if (!s_state.ready && s_state.epoch != UINT64_MAX &&
s_state.generation != UINT64_MAX) {
advance_epoch_locked();
s_state.ready = s_state.epoch != UINT64_MAX;
}
taskEXIT_CRITICAL(&s_lock);
}
void web_admin_tickets_stop(void)
{
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
s_state.ready = false;
secure_wipe(s_state.tickets, sizeof(s_state.tickets));
taskEXIT_CRITICAL(&s_lock);
}
static bool capture_epoch(uint64_t *epoch)
{
taskENTER_CRITICAL(&s_lock);
*epoch = s_state.epoch;
bool ready = s_state.ready;
taskEXIT_CRITICAL(&s_lock);
return ready;
}
static esp_err_t result(esp_err_t error)
{
if (error != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
increment(&s_state.rejected);
taskEXIT_CRITICAL(&s_lock);
}
return error;
}
esp_err_t web_admin_tickets_issue(web_session_id_t id,
const user_principal_t *principal, char token[WEB_ADMIN_TICKET_LENGTH + 1U])
{
ticket_t candidate = {0};
uint8_t random[32] = {0};
uint64_t epoch = 0;
esp_err_t error = ESP_ERR_INVALID_ARG;
if (token == NULL) return result(error);
secure_wipe(token, WEB_ADMIN_TICKET_LENGTH + 1U);
if (id == 0 || principal == NULL) goto done;
error = ESP_ERR_INVALID_STATE;
if (!capture_epoch(&epoch) || !current(id, principal)) goto done;
candidate.id = id;
candidate.principal = *principal;
prune();
if (!current(id, &candidate.principal)) goto done;
error = secure_random_fill(random, sizeof(random));
/* Recheck even when crypto fails; never use an old authorization result. */
bool valid = current(id, &candidate.principal);
if (error != ESP_OK) goto done;
error = ESP_ERR_INVALID_STATE;
if (!valid) goto done;
static const char hex[] = "0123456789abcdef";
for (size_t i = 0; i < sizeof(random); ++i) {
token[2 * i] = hex[random[i] >> 4];
token[2 * i + 1] = hex[random[i] & 15];
}
int crypto = mbedtls_sha256(random, sizeof(random), candidate.digest, 0);
valid = current(id, &candidate.principal);
error = crypto == 0 ? ESP_ERR_INVALID_STATE : ESP_FAIL;
if (crypto != 0 || !valid) goto done;
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
if (s_state.ready && epoch == s_state.epoch && now >= 0 &&
now <= INT64_MAX - WEB_ADMIN_TICKET_LIFETIME_US &&
s_state.generation != UINT64_MAX) {
ticket_t *free_slot = NULL;
bool duplicate = false;
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t *t = &s_state.tickets[i];
if (!t->generation) free_slot = t;
else if (equal_digest(t->digest, candidate.digest)) duplicate = true;
}
if (duplicate) error = ESP_FAIL;
else if (free_slot == NULL) {
increment(&s_state.capacity_rejections);
error = ESP_ERR_NO_MEM;
} else {
candidate.generation = ++s_state.generation;
candidate.expires_at_us = now + WEB_ADMIN_TICKET_LIFETIME_US;
*free_slot = candidate;
increment(&s_state.issued);
error = ESP_OK;
}
}
taskEXIT_CRITICAL(&s_lock);
done:
secure_wipe(random, sizeof(random));
secure_wipe(&candidate, sizeof(candidate));
if (error != ESP_OK) secure_wipe(token, WEB_ADMIN_TICKET_LENGTH + 1U);
return result(error);
}
static int unhex(char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
esp_err_t web_admin_tickets_consume(const char *token, web_session_id_t id,
const user_principal_t *principal)
{
uint8_t bytes[32] = {0}, digest[32] = {0};
ticket_t found = {0};
uint64_t epoch = 0;
esp_err_t error = ESP_ERR_INVALID_ARG;
if (token == NULL) goto done;
for (size_t i = 0; i < WEB_ADMIN_TICKET_LENGTH; ++i) {
int n = unhex(token[i]);
if (n < 0) goto done;
bytes[i / 2] |= (uint8_t)(n << ((i % 2 == 0) ? 4 : 0));
}
if (token[WEB_ADMIN_TICKET_LENGTH] != '\0') goto done;
error = ESP_ERR_INVALID_STATE;
if (!capture_epoch(&epoch)) goto done;
bool before = current(id, principal);
if (mbedtls_sha256(bytes, sizeof(bytes), digest, 0) != 0) {
(void)current(id, principal);
error = ESP_FAIL;
goto done;
}
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
if (s_state.ready && epoch == s_state.epoch) {
error = ESP_ERR_NOT_FOUND;
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t *t = &s_state.tickets[i];
if (t->generation && equal_digest(t->digest, digest)) {
found = *t;
secure_wipe(t, sizeof(*t));
increment(&s_state.consumed);
break;
}
}
}
taskEXIT_CRITICAL(&s_lock);
/* Burn precedes acting on either currentness result or identity binding. */
bool after = current(id, principal);
if (found.generation) {
now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
error = before && after && found.id == id &&
same_principal(&found.principal, principal) && s_state.ready &&
epoch == s_state.epoch && now < found.expires_at_us ?
ESP_OK : ESP_ERR_INVALID_STATE;
taskEXIT_CRITICAL(&s_lock);
}
done:
secure_wipe(bytes, sizeof(bytes));
secure_wipe(digest, sizeof(digest));
secure_wipe(&found, sizeof(found));
return result(error);
}
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *username,
size_t length)
{
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t *t = &s_state.tickets[i];
bool match = id != 0 ? t->id == id : username == NULL ||
(length == t->principal.username_length &&
length <= USER_DATABASE_USERNAME_CAPACITY &&
memcmp(username, t->principal.username, length) == 0);
if (match) secure_wipe(t, sizeof(*t));
}
taskEXIT_CRITICAL(&s_lock);
}
void web_admin_tickets_get_snapshot(web_admin_tickets_snapshot_t *snapshot)
{
if (snapshot == NULL) return;
prune();
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
*snapshot = (web_admin_tickets_snapshot_t) {
.issued = s_state.issued, .consumed = s_state.consumed,
.rejected = s_state.rejected,
.capacity_rejections = s_state.capacity_rejections,
.storage_bytes = sizeof(s_state) + sizeof(s_lock), .ready = s_state.ready,
};
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i)
if (s_state.tickets[i].generation) ++snapshot->active;
taskEXIT_CRITICAL(&s_lock);
}