- 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.
301 lines
9.9 KiB
C
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, ©.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(©, 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);
|
|
}
|