/* SPDX-License-Identifier: GPL-3.0-only */ #include "web_admin_tickets.h" #include #include #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); }