469 lines
15 KiB
C
469 lines
15 KiB
C
/* SPDX-License-Identifier: GPL-3.0-only */
|
|
#include "web_session_store.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 {
|
|
web_session_id_t id;
|
|
int64_t expires_at_us;
|
|
user_principal_t principal;
|
|
uint8_t token_digest[WEB_SESSION_STORE_SECRET_BYTES];
|
|
uint8_t origin_digest[WEB_SESSION_STORE_SECRET_BYTES];
|
|
uint8_t csrf[WEB_SESSION_STORE_SECRET_BYTES];
|
|
} session_entry_t;
|
|
|
|
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
|
static struct {
|
|
session_entry_t entries[WEB_SESSION_STORE_CAPACITY];
|
|
uint64_t next_id;
|
|
uint64_t epoch;
|
|
bool ready;
|
|
bool initializing;
|
|
uint32_t issued;
|
|
uint32_t capacity_rejections;
|
|
uint32_t expired;
|
|
uint32_t invalidated;
|
|
uint32_t lookup_rejections;
|
|
uint32_t init_failures;
|
|
} s_state;
|
|
|
|
static bool equal_bytes(const uint8_t *a, const uint8_t *b, size_t length)
|
|
{
|
|
uint8_t difference = 0U;
|
|
for (size_t i = 0U; i < length; ++i) {
|
|
difference |= a[i] ^ b[i];
|
|
}
|
|
return difference == 0U;
|
|
}
|
|
|
|
static void encode_hex(const uint8_t *bytes, char *text)
|
|
{
|
|
static const char hex[] = "0123456789abcdef";
|
|
for (size_t i = 0U; i < WEB_SESSION_STORE_SECRET_BYTES; ++i) {
|
|
text[2U * i] = hex[bytes[i] >> 4U];
|
|
text[2U * i + 1U] = hex[bytes[i] & 15U];
|
|
}
|
|
text[WEB_SESSION_STORE_TOKEN_LENGTH] = '\0';
|
|
}
|
|
|
|
static esp_err_t digest(const void *input, size_t length, uint8_t *output)
|
|
{
|
|
return mbedtls_sha256(input, length, output, 0) == 0 ? ESP_OK : ESP_FAIL;
|
|
}
|
|
|
|
static esp_err_t origin_digest(const char *origin, size_t length, uint8_t *output)
|
|
{
|
|
if (origin == NULL || length <= 8U ||
|
|
length > WEB_SESSION_STORE_ORIGIN_MAX_LENGTH ||
|
|
memcmp(origin, "https://", 8U) != 0 || memchr(origin, '\0', length) != NULL) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
return digest(origin, length, output);
|
|
}
|
|
|
|
static session_entry_t *find_locked(web_session_id_t id)
|
|
{
|
|
if (id != 0U) {
|
|
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
|
if (s_state.entries[i].id == id) {
|
|
return &s_state.entries[i];
|
|
}
|
|
}
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
static void retire_locked(session_entry_t *entry, bool expired)
|
|
{
|
|
if (entry->id != 0U) {
|
|
if (expired) {
|
|
++s_state.expired;
|
|
} else {
|
|
++s_state.invalidated;
|
|
}
|
|
secure_wipe(entry, sizeof(*entry));
|
|
}
|
|
}
|
|
|
|
static void expire_locked(int64_t now)
|
|
{
|
|
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
|
session_entry_t *entry = &s_state.entries[i];
|
|
if (entry->id != 0U && entry->expires_at_us <= now) {
|
|
retire_locked(entry, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Also cancels issuance already outside the lock, even if no record matched.
|
|
* Exhaustion is fail-closed rather than allowing an epoch/identity ABA. */
|
|
static void advance_epoch_locked(void)
|
|
{
|
|
if (s_state.epoch != UINT64_MAX) {
|
|
++s_state.epoch;
|
|
} else {
|
|
s_state.ready = false;
|
|
}
|
|
}
|
|
|
|
static void export_view(const session_entry_t *entry, web_session_view_t *view)
|
|
{
|
|
view->id = entry->id;
|
|
view->expires_at_us = entry->expires_at_us;
|
|
view->principal = entry->principal;
|
|
encode_hex(entry->csrf, view->csrf);
|
|
}
|
|
|
|
esp_err_t web_session_store_init(void)
|
|
{
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (s_state.ready) {
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return ESP_OK;
|
|
}
|
|
if (s_state.initializing || s_state.epoch == UINT64_MAX) {
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
s_state.initializing = true;
|
|
uint64_t epoch = s_state.epoch;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
uint8_t probe[WEB_SESSION_STORE_SECRET_BYTES] = {0};
|
|
esp_err_t error = secure_random_fill(probe, sizeof(probe));
|
|
secure_wipe(probe, sizeof(probe));
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
if (epoch != s_state.epoch) {
|
|
error = ESP_ERR_INVALID_STATE;
|
|
}
|
|
s_state.initializing = false;
|
|
s_state.ready = error == ESP_OK;
|
|
if (error != ESP_OK) {
|
|
++s_state.init_failures;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return error;
|
|
}
|
|
|
|
void web_session_store_stop(void)
|
|
{
|
|
taskENTER_CRITICAL(&s_lock);
|
|
advance_epoch_locked();
|
|
s_state.ready = false;
|
|
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
|
retire_locked(&s_state.entries[i], false);
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
|
|
/* Never enter the database while holding our lock. On return, re-find the
|
|
* non-reused ID and deadline: logout/stop/slot reuse may have raced the call. */
|
|
static esp_err_t resolve(web_session_id_t id, web_session_view_t *view,
|
|
const user_principal_t *expected)
|
|
{
|
|
session_entry_t candidate = {0};
|
|
int64_t now = esp_timer_get_time();
|
|
taskENTER_CRITICAL(&s_lock);
|
|
expire_locked(now);
|
|
session_entry_t *entry = find_locked(id);
|
|
esp_err_t error = s_state.ready ? ESP_ERR_NOT_FOUND : ESP_ERR_INVALID_STATE;
|
|
if (s_state.ready && entry != NULL) {
|
|
candidate = *entry;
|
|
error = ESP_OK;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
|
|
if (error == ESP_OK && expected != NULL &&
|
|
(candidate.principal.user_id != expected->user_id ||
|
|
candidate.principal.auth_generation != expected->auth_generation ||
|
|
candidate.principal.role != expected->role ||
|
|
candidate.principal.method != expected->method ||
|
|
candidate.principal.username_length != expected->username_length ||
|
|
memcmp(candidate.principal.username, expected->username,
|
|
candidate.principal.username_length) != 0)) {
|
|
error = ESP_ERR_NOT_FOUND;
|
|
}
|
|
if (error == ESP_OK) {
|
|
bool current = false;
|
|
error = user_database_principal_is_current(&candidate.principal, ¤t);
|
|
now = esp_timer_get_time();
|
|
taskENTER_CRITICAL(&s_lock);
|
|
expire_locked(now);
|
|
entry = find_locked(id);
|
|
if (!s_state.ready || entry == NULL) {
|
|
error = ESP_ERR_NOT_FOUND;
|
|
} else if (error != ESP_OK || !current) {
|
|
retire_locked(entry, false);
|
|
if (error == ESP_OK) {
|
|
error = ESP_ERR_NOT_FOUND;
|
|
}
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
if (error == ESP_OK && view != NULL) {
|
|
export_view(&candidate, view);
|
|
}
|
|
if (error != ESP_OK) {
|
|
taskENTER_CRITICAL(&s_lock);
|
|
++s_state.lookup_rejections;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
secure_wipe(&candidate, sizeof(candidate));
|
|
return error;
|
|
}
|
|
|
|
void web_session_store_prune(void)
|
|
{
|
|
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
|
taskENTER_CRITICAL(&s_lock);
|
|
web_session_id_t id = s_state.entries[i].id;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (id != 0U) {
|
|
(void)resolve(id, NULL, NULL);
|
|
}
|
|
}
|
|
}
|
|
|
|
esp_err_t web_session_store_issue(
|
|
const user_principal_t *principal, const char *origin, size_t origin_length,
|
|
char token[WEB_SESSION_STORE_TOKEN_LENGTH + 1U], web_session_view_t *view)
|
|
{
|
|
if (token != NULL) {
|
|
secure_wipe(token, WEB_SESSION_STORE_TOKEN_LENGTH + 1U);
|
|
}
|
|
if (view != NULL) {
|
|
secure_wipe(view, sizeof(*view));
|
|
}
|
|
if (principal == NULL || token == NULL || view == NULL ||
|
|
principal->user_id == 0U || principal->auth_generation == 0U ||
|
|
principal->method != USER_AUTH_METHOD_PASSWORD ||
|
|
(principal->role != USER_ROLE_USER && principal->role != USER_ROLE_ADMIN) ||
|
|
principal->username_length == 0U ||
|
|
principal->username_length > USER_DATABASE_USERNAME_CAPACITY ||
|
|
principal->username[principal->username_length] != '\0') {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
taskENTER_CRITICAL(&s_lock);
|
|
bool ready = s_state.ready;
|
|
uint64_t epoch = s_state.epoch;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
if (!ready) {
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
web_session_store_prune();
|
|
|
|
session_entry_t candidate = {0};
|
|
uint8_t random[2U * WEB_SESSION_STORE_SECRET_BYTES] = {0};
|
|
esp_err_t error = origin_digest(origin, origin_length, candidate.origin_digest);
|
|
if (error == ESP_OK) {
|
|
error = secure_random_fill(random, sizeof(random));
|
|
}
|
|
if (error == ESP_OK) {
|
|
encode_hex(random, token);
|
|
memcpy(candidate.csrf, random + WEB_SESSION_STORE_SECRET_BYTES,
|
|
sizeof(candidate.csrf));
|
|
error = digest(token, WEB_SESSION_STORE_TOKEN_LENGTH, candidate.token_digest);
|
|
}
|
|
bool current = false;
|
|
if (error == ESP_OK) {
|
|
candidate.principal = *principal;
|
|
error = user_database_principal_is_current(&candidate.principal, ¤t);
|
|
if (error == ESP_OK && !current) {
|
|
error = ESP_ERR_NOT_FOUND;
|
|
}
|
|
}
|
|
if (error == ESP_OK) {
|
|
int64_t now = esp_timer_get_time();
|
|
taskENTER_CRITICAL(&s_lock);
|
|
expire_locked(now);
|
|
session_entry_t *available = NULL;
|
|
bool duplicate = false;
|
|
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
|
session_entry_t *entry = &s_state.entries[i];
|
|
if (entry->id == 0U) {
|
|
if (available == NULL) {
|
|
available = entry;
|
|
}
|
|
} else if (equal_bytes(entry->token_digest, candidate.token_digest,
|
|
sizeof(entry->token_digest))) {
|
|
duplicate = true;
|
|
}
|
|
}
|
|
if (!s_state.ready || epoch != s_state.epoch ||
|
|
s_state.next_id == UINT64_MAX || now < 0 ||
|
|
now > INT64_MAX - WEB_SESSION_STORE_LIFETIME_US) {
|
|
error = ESP_ERR_INVALID_STATE;
|
|
} else if (duplicate) {
|
|
error = ESP_FAIL;
|
|
} else if (available == NULL) {
|
|
++s_state.capacity_rejections;
|
|
error = ESP_ERR_NO_MEM;
|
|
} else {
|
|
candidate.id = ++s_state.next_id;
|
|
candidate.expires_at_us = now + WEB_SESSION_STORE_LIFETIME_US;
|
|
*available = candidate;
|
|
++s_state.issued;
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
if (error == ESP_OK) {
|
|
export_view(&candidate, view);
|
|
} else {
|
|
secure_wipe(token, WEB_SESSION_STORE_TOKEN_LENGTH + 1U);
|
|
}
|
|
secure_wipe(random, sizeof(random));
|
|
secure_wipe(&candidate, sizeof(candidate));
|
|
return error;
|
|
}
|
|
|
|
esp_err_t web_session_store_lookup(
|
|
const char *token, size_t token_length, const char *origin,
|
|
size_t origin_length, web_session_view_t *view)
|
|
{
|
|
if (view == NULL) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
secure_wipe(view, sizeof(*view));
|
|
if (token == NULL || token_length != WEB_SESSION_STORE_TOKEN_LENGTH) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
for (size_t i = 0U; i < token_length; ++i) {
|
|
if (!((token[i] >= '0' && token[i] <= '9') ||
|
|
(token[i] >= 'a' && token[i] <= 'f'))) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
}
|
|
uint8_t token_hash[WEB_SESSION_STORE_SECRET_BYTES] = {0};
|
|
uint8_t origin_hash[WEB_SESSION_STORE_SECRET_BYTES] = {0};
|
|
esp_err_t error = origin_digest(origin, origin_length, origin_hash);
|
|
if (error == ESP_OK) {
|
|
error = digest(token, token_length, token_hash);
|
|
}
|
|
if (error == ESP_OK) {
|
|
web_session_id_t id = 0U;
|
|
taskENTER_CRITICAL(&s_lock);
|
|
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
|
const session_entry_t *entry = &s_state.entries[i];
|
|
bool matches = equal_bytes(entry->token_digest, token_hash, sizeof(token_hash));
|
|
matches &= equal_bytes(entry->origin_digest, origin_hash, sizeof(origin_hash));
|
|
if (entry->id != 0U && matches) {
|
|
id = entry->id;
|
|
}
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
error = resolve(id, view, NULL);
|
|
}
|
|
secure_wipe(token_hash, sizeof(token_hash));
|
|
secure_wipe(origin_hash, sizeof(origin_hash));
|
|
return error;
|
|
}
|
|
|
|
esp_err_t web_session_store_is_current(web_session_id_t id, bool *current)
|
|
{
|
|
if (current == NULL) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
*current = false;
|
|
esp_err_t error = resolve(id, NULL, NULL);
|
|
if (error == ESP_OK) {
|
|
*current = true;
|
|
}
|
|
return error;
|
|
}
|
|
|
|
esp_err_t web_session_store_check_principal(web_session_id_t id,
|
|
const user_principal_t *principal,
|
|
bool *current)
|
|
{
|
|
if (current == NULL) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
*current = false;
|
|
if (principal == NULL) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
esp_err_t error = resolve(id, NULL, principal);
|
|
*current = error == ESP_OK;
|
|
return error;
|
|
}
|
|
|
|
void web_session_store_invalidate_username(const uint8_t *username, size_t length)
|
|
{
|
|
if ((username == NULL && length != 0U) ||
|
|
(username != NULL && !user_database_username_valid(username, length))) {
|
|
return;
|
|
}
|
|
taskENTER_CRITICAL(&s_lock);
|
|
advance_epoch_locked();
|
|
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
|
session_entry_t *entry = &s_state.entries[i];
|
|
if (username == NULL ||
|
|
(entry->principal.username_length == length &&
|
|
memcmp(entry->principal.username, username, length) == 0)) {
|
|
retire_locked(entry, false);
|
|
}
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
|
|
void web_session_store_invalidate(web_session_id_t id)
|
|
{
|
|
if (id == 0U) {
|
|
return;
|
|
}
|
|
taskENTER_CRITICAL(&s_lock);
|
|
advance_epoch_locked();
|
|
session_entry_t *entry = find_locked(id);
|
|
if (entry != NULL) {
|
|
retire_locked(entry, false);
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
|
|
void web_session_store_invalidate_user(uint32_t user_id)
|
|
{
|
|
if (user_id == 0U) {
|
|
return;
|
|
}
|
|
taskENTER_CRITICAL(&s_lock);
|
|
advance_epoch_locked();
|
|
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
|
if (s_state.entries[i].principal.user_id == user_id) {
|
|
retire_locked(&s_state.entries[i], false);
|
|
}
|
|
}
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
}
|
|
|
|
esp_err_t web_session_store_get_snapshot(web_session_store_snapshot_t *snapshot)
|
|
{
|
|
if (snapshot == NULL) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
memset(snapshot, 0, sizeof(*snapshot));
|
|
int64_t now = esp_timer_get_time();
|
|
taskENTER_CRITICAL(&s_lock);
|
|
expire_locked(now);
|
|
snapshot->initialized = s_state.ready;
|
|
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
|
snapshot->active += s_state.entries[i].id != 0U;
|
|
}
|
|
snapshot->issued = s_state.issued;
|
|
snapshot->capacity_rejections = s_state.capacity_rejections;
|
|
snapshot->expired = s_state.expired;
|
|
snapshot->invalidated = s_state.invalidated;
|
|
snapshot->lookup_rejections = s_state.lookup_rejections;
|
|
snapshot->init_failures = s_state.init_failures;
|
|
snapshot->storage_bytes = sizeof(s_state) + sizeof(s_lock);
|
|
snapshot->slot_bytes = sizeof(session_entry_t);
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
return ESP_OK;
|
|
}
|