/* SPDX-License-Identifier: GPL-3.0-only */ #include "web_account_settings.h" #include #include #include #include "admin_ssh_console.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "secure_random.h" #include "mbedtls/base64.h" #include "ssh_transport.h" #include "web_cookie_auth.h" #include "web_auth_parse.h" #include "web_httpd_adapter.h" #include "web_serial_transport.h" enum { IDLE, PENDING, OK, FAILED, CANCELLED, STALE, PROTECTED, DUPLICATE, FULL }; static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "stale", "protected", "duplicate", "full"}; typedef enum { ACTION_ROLE, ACTION_DELETE, ACTION_CREATE, ACTION_PASSWORD, ACTION_KEY_ADD, ACTION_KEY_DELETE, ACTION_KEY_CLEAR } account_action_t; static const char *const s_actions[] = {"role", "delete", "create", "password", "key-add", "key-delete", "key-clear"}; typedef struct { uint32_t id; web_session_id_t session; user_principal_t principal; user_database_account_t target; int64_t deadline; user_role_t role; unsigned state; account_action_t action; bool executing; uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U]; size_t password_length; char key_type[USER_DATABASE_SSH_KEY_TYPE_CAPACITY + 1U]; uint8_t key_blob[USER_DATABASE_SSH_KEY_BLOB_CAPACITY]; size_t key_blob_length; uint8_t key_index; } account_operation_t; static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; static account_operation_t s_operation; static uint32_t s_next_id; static esp_timer_handle_t s_secret_timer; static bool s_secret_timer_started; static bool credential_action(account_action_t action) { return action == ACTION_CREATE || action == ACTION_PASSWORD; } static void wipe_input(account_operation_t *operation) { secure_wipe(&operation->principal, sizeof(operation->principal)); secure_wipe(&operation->target, sizeof(operation->target)); secure_wipe(operation->password, sizeof(operation->password)); operation->password_length = 0; secure_wipe(operation->key_type, sizeof(operation->key_type)); secure_wipe(operation->key_blob, sizeof(operation->key_blob)); operation->key_blob_length = 0; operation->key_index = 0; } static void expire_secret(void *unused) { (void)unused; taskENTER_CRITICAL(&s_lock); /* Inspect only the current ID/deadline, never a captured/rearmed job. A late * tick cannot cancel a replacement before its own deadline or executing work. */ if (s_operation.id && s_operation.state == PENDING && !s_operation.executing && credential_action(s_operation.action) && esp_timer_get_time() >= s_operation.deadline) { s_operation.state = CANCELLED; wipe_input(&s_operation); } taskEXIT_CRITICAL(&s_lock); } static bool ensure_secret_timer(void) { /* HTTPD is the sole admission owner. Once started, this one firmware-lifetime * timer is never stopped/rearmed/deleted. Expiry is best-effort scheduling, * not hard realtime; no network/database work runs in its callback. */ if (!s_secret_timer) { const esp_timer_create_args_t args = {.callback = expire_secret, .name = "account-secret"}; if (esp_timer_create(&args, &s_secret_timer) != ESP_OK) return false; } if (!s_secret_timer_started) { if (esp_timer_start_periodic(s_secret_timer, 1000000ULL) != ESP_OK) return false; s_secret_timer_started = true; } return true; } /* OpenSSH text envelope only. The canonical database parser validates the SSH * blob (including the P256 point) on the dispatcher, not the HTTPD stack. */ static bool parse_public_key(const char *text, size_t length, account_operation_t *operation) { size_t type_length = 0; while (type_length < length && text[type_length] != ' ' && text[type_length] != '\t') ++type_length; if (!((type_length == 11 && !memcmp(text, "ssh-ed25519", 11)) || (type_length == 19 && !memcmp(text, "ecdsa-sha2-nistp256", 19)))) return false; size_t start = type_length; while (start < length && (text[start] == ' ' || text[start] == '\t')) ++start; size_t end = start; while (end < length && text[end] != ' ' && text[end] != '\t') ++end; size_t encoded_length = end - start; if (!encoded_length || encoded_length > 172 || encoded_length % 4) return false; for (size_t i = start; i < end; ++i) { unsigned char c = (unsigned char)text[i]; if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/' || (c == '=' && i >= end - 2))) return false; } for (size_t i = end; i < length; ++i) if ((text[i] < ' ' || text[i] > '~') && text[i] != '\t') return false; if (mbedtls_base64_decode(operation->key_blob, sizeof(operation->key_blob), &operation->key_blob_length, (const uint8_t *)text + start, encoded_length) != 0) return false; /* Round-trip rejects noncanonical padding and unused base64 bits. */ unsigned char encoded[173]; size_t written = 0; if (mbedtls_base64_encode(encoded, sizeof(encoded), &written, operation->key_blob, operation->key_blob_length) != 0 || written != encoded_length || memcmp(encoded, text + start, written)) return false; memcpy(operation->key_type, text, type_length); return true; } /* Exact flat schemas. Password/public_key accept JSON escapes; canonical database * policy validates the decoded bytes. No coercion/unknown/duplicate fields. */ static bool parse_request(const char *body, size_t length, account_operation_t *operation, bool keys_only) { const char *keys[] = {"action", "username", "user_id", "auth_generation", "role", "password", "public_key", "key_index"}; unsigned seen = 0; size_t pos = 0; #define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos #define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0) TAKE('{'); for (unsigned field = 0; field < 8; ++field) { if (field) { TAKE(','); } TAKE('"'); size_t start = pos; while (pos < length && body[pos] != '"') ++pos; if (pos == length) return false; unsigned key = 0; for (; key < 8; ++key) if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break; if (key == 8 || (seen & (1U << key))) return false; ++pos; TAKE(':'); SPACE(); uint32_t number = 0; char value[USER_DATABASE_USERNAME_CAPACITY + 1] = {0}; if (key == 2 || key == 3 || key == 7) { start = pos; while (pos < length && body[pos] >= '0' && body[pos] <= '9') { unsigned digit = (unsigned)(body[pos++] - '0'); if (number > (UINT32_MAX - digit) / 10U) return false; number = number * 10U + digit; } if ((!number && key != 7) || pos == start || (pos - start > 1 && body[start] == '0')) return false; if (key == 7 && number >= USER_DATABASE_MAX_SSH_KEYS_PER_USER) return false; } else if (key == 6) { char text[385] = {0}; size_t text_length = 0; if (!web_auth_parse_json_string(body, length, &pos, (uint8_t *)text, sizeof(text), &text_length) || !parse_public_key(text, text_length, operation)) return false; } else if (key == 5) { if (!web_auth_parse_json_string(body, length, &pos, operation->password, sizeof(operation->password), &operation->password_length) || !user_database_password_valid(operation->password, operation->password_length)) return false; } else { TAKE('"'); start = pos; while (pos < length && body[pos] != '"') { if (body[pos] < ' ' || body[pos] > '~' || body[pos] == '\\' || pos - start >= sizeof(value) - 1) return false; ++pos; } if (pos == length) return false; memcpy(value, body + start, pos - start); ++pos; } switch (key) { case 0: { unsigned action = 0; for (; action < sizeof(s_actions) / sizeof(*s_actions); ++action) if (!strcmp(value, s_actions[action])) break; if (action == sizeof(s_actions) / sizeof(*s_actions)) return false; operation->action = (account_action_t)action; } break; case 1: if (!user_database_username_valid((const uint8_t *)value, strlen(value))) return false; memcpy(operation->target.username, value, sizeof(value)); break; case 2: operation->target.user_id = number; break; case 3: operation->target.auth_generation = number; break; case 4: if (!user_role_parse(value, &operation->role)) return false; break; case 7: operation->key_index = (uint8_t)number; break; } seen |= 1U << key; SPACE(); if (pos < length && body[pos] == '}') break; } TAKE('}'); SPACE(); #undef TAKE #undef SPACE const unsigned schemas[] = {31U, 15U, 51U, 47U, 79U, 143U, 15U}; return pos == length && seen == (keys_only ? 14U : schemas[operation->action]); } static bool parse(const char *body, size_t length, account_operation_t *operation) { return parse_request(body, length, operation, false); } void web_account_settings_execute(uint32_t id) { account_operation_t operation = {0}; taskENTER_CRITICAL(&s_lock); bool admitted = id && s_operation.id == id && s_operation.state == PENDING && !s_operation.executing; if (admitted) { s_operation.executing = true; operation = s_operation; wipe_input(&s_operation); } taskEXIT_CRITICAL(&s_lock); if (!admitted) return; bool current = false; esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t); unsigned state = CANCELLED; if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN && esp_timer_get_time() < operation.deadline) { /* CLI and typed mutations share this dispatcher. Target identity is also * compared under the database mutation lock, not just at HTTP admission. */ switch (operation.action) { case ACTION_ROLE: error = user_database_set_role_current(&operation.target, operation.role); break; case ACTION_DELETE: error = user_database_delete_current(&operation.target); break; case ACTION_CREATE: error = user_database_create((const uint8_t *)operation.target.username, strlen(operation.target.username), operation.role, operation.password, operation.password_length); break; case ACTION_KEY_ADD: error = user_database_add_ssh_key_current(&operation.target, (const uint8_t *)operation.key_type, strlen(operation.key_type), operation.key_blob, operation.key_blob_length, &operation.key_index); break; case ACTION_KEY_DELETE: error = user_database_remove_ssh_key_current(&operation.target, operation.key_index); break; case ACTION_KEY_CLEAR: error = user_database_clear_ssh_keys_current(&operation.target); break; case ACTION_PASSWORD: error = user_database_set_password_current(&operation.target, operation.password, operation.password_length); break; } secure_wipe(operation.password, sizeof(operation.password)); operation.password_length = 0; state = error == ESP_OK ? OK : error == ESP_ERR_NOT_FOUND ? STALE : error == ESP_ERR_INVALID_STATE ? (operation.action == ACTION_CREATE ? DUPLICATE : operation.action <= ACTION_PASSWORD ? PROTECTED : FAILED) : error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY && operation.action == ACTION_KEY_ADD ? DUPLICATE : error == ESP_ERR_NO_MEM && (operation.action == ACTION_CREATE || operation.action == ACTION_KEY_ADD) ? FULL : FAILED; if (error == ESP_OK && operation.action != ACTION_CREATE) { size_t length = strlen(operation.target.username); (void)web_serial_transport_revoke_user((const uint8_t *)operation.target.username, length); (void)ssh_transport_revoke_user((const uint8_t *)operation.target.username, length); } } secure_wipe(operation.password, sizeof(operation.password)); operation.password_length = 0; taskENTER_CRITICAL(&s_lock); if (s_operation.id == id && s_operation.state == PENDING && s_operation.executing) { s_operation.state = state; s_operation.executing = false; wipe_input(&s_operation); } taskEXIT_CRITICAL(&s_lock); secure_wipe(&operation, sizeof(operation)); } static esp_err_t respond(httpd_req_t *request, const char *status, const char *body) { esp_err_t error = httpd_resp_set_status(request, status); if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8"); if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store"); if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff"); if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer"); if (error == ESP_OK) error = httpd_resp_sendstr(request, body); return web_httpd_unread_body(request) ? ESP_FAIL : error; } static esp_err_t list_accounts(httpd_req_t *request) { user_database_accounts_t accounts; if (user_database_get_accounts(&accounts) != ESP_OK) return respond(request, "503 Service Unavailable", "{\"error\":\"accounts_unavailable\"}"); char body[1024]; size_t used = (size_t)snprintf(body, sizeof(body), "{\"users\":["); for (size_t i = 0; i < accounts.count; ++i) { const user_database_account_t *user = &accounts.users[i]; /* Database username policy makes these ASCII strings JSON-safe. */ int written = snprintf(body + used, sizeof(body) - used, "%s{\"username\":\"%s\",\"user_id\":%" PRIu32 ",\"auth_generation\":%" PRIu32 ",\"role\":\"%s\"}", i ? "," : "", user->username, user->user_id, user->auth_generation, user_role_to_string(user->role)); if (written < 0 || (size_t)written >= sizeof(body) - used) return ESP_FAIL; used += (size_t)written; } if (used + 3 > sizeof(body)) return ESP_FAIL; memcpy(body + used, "]}", 3); return respond(request, "200 OK", body); } static bool read_request(httpd_req_t *request, account_operation_t *operation, bool keys_only) { char type[40] = {0}, body[768]; size_t received = 0; bool valid = request->content_len && request->content_len <= sizeof(body) && httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK && (!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8")); for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) { int count = httpd_req_recv(request, body + received, request->content_len - received); if (count <= 0 || (size_t)count > request->content_len - received) valid = false; else received += (size_t)count; } valid = valid && received == request->content_len && (keys_only ? parse_request(body, received, operation, true) : parse(body, received, operation)); secure_wipe(body, sizeof(body)); return valid; } esp_err_t web_account_keys_handler(httpd_req_t *request) { web_session_view_t view = {0}; account_operation_t operation = {0}; user_database_user_snapshot_t snapshot = {0}; bool allowed = false; esp_err_t error = web_cookie_auth_require_json(request, 768, &view, &allowed); if (error != ESP_OK || !allowed) goto done; if (view.principal.role != USER_ROLE_ADMIN) { error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done; } if (strcmp(request->uri, "/api/settings/accounts/keys") || !read_request(request, &operation, true)) { error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}"); goto done; } error = user_database_get_account_keys(&operation.target, &snapshot); if (error != ESP_OK) { error = error == ESP_ERR_NOT_FOUND ? respond(request, "409 Conflict", "{\"error\":\"stale\"}") : respond(request, "503 Service Unavailable", "{\"error\":\"accounts_unavailable\"}"); goto done; } char body[512]; int written = snprintf(body, sizeof(body), "{\"username\":\"%s\",\"user_id\":%" PRIu32 ",\"auth_generation\":%" PRIu32 ",\"keys\":[", snapshot.username, snapshot.user_id, snapshot.auth_generation); if (written < 0 || (size_t)written >= sizeof(body)) { error = ESP_FAIL; goto done; } size_t used = (size_t)written; bool comma = false; for (size_t i = 0; i < USER_DATABASE_MAX_SSH_KEYS_PER_USER; ++i) { const user_database_key_snapshot_t *key = &snapshot.public_keys[i]; if (!key->active) continue; unsigned char fingerprint[45]; size_t length = 0; if (mbedtls_base64_encode(fingerprint, sizeof(fingerprint), &length, key->sha256_fingerprint, sizeof(key->sha256_fingerprint)) != 0 || length != 44) { error = ESP_FAIL; goto done; } fingerprint[43] = 0; /* OpenSSH SHA256 fingerprints omit base64 padding. */ written = snprintf(body + used, sizeof(body) - used, "%s{\"index\":%u,\"type\":\"%s\",\"fingerprint\":\"SHA256:%s\"}", comma ? "," : "", key->index, key->key_type, (const char *)fingerprint); if (written < 0 || (size_t)written >= sizeof(body) - used) { error = ESP_FAIL; goto done; } used += (size_t)written; comma = true; } if (used + 3 > sizeof(body)) { error = ESP_FAIL; goto done; } memcpy(body + used, "]}", 3); error = respond(request, "200 OK", body); done: secure_wipe(&operation, sizeof(operation)); secure_wipe(&view, sizeof(view)); web_httpd_wipe_request(request, web_httpd_unread_body(request)); return error; } esp_err_t web_account_generate_password_handler(httpd_req_t *request) { web_session_view_t view = {0}; user_database_generated_password_t generated = {0}; char response[96] = {0}; bool allowed = false; esp_err_t error = web_cookie_auth_require(request, true, false, &view, &allowed); if (error != ESP_OK || !allowed) goto done; if (view.principal.role != USER_ROLE_ADMIN) { error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done; } error = user_database_generate_password_value(&generated); if (error != ESP_OK) { secure_wipe(&generated, sizeof(generated)); error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}"); goto done; } bool current = false; error = web_session_store_check_principal(view.id, &view.principal, ¤t); if (error != ESP_OK || !current) { secure_wipe(&generated, sizeof(generated)); error = respond(request, "401 Unauthorized", "{\"error\":\"authentication_required\"}"); goto done; } int written = snprintf(response, sizeof(response), "{\"password\":\"%s\"}", (const char *)generated.password); secure_wipe(&generated, sizeof(generated)); error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response); done: secure_wipe(&generated, sizeof(generated)); secure_wipe(response, sizeof(response)); secure_wipe(&view, sizeof(view)); web_httpd_wipe_request(request, web_httpd_unread_body(request)); return error; } esp_err_t web_account_settings_handler(httpd_req_t *request) { web_session_view_t view = {0}; account_operation_t operation = {0}; bool allowed = false, mutation = request->method == HTTP_POST; esp_err_t error = mutation ? web_cookie_auth_require_json(request, 768, &view, &allowed) : web_cookie_auth_require(request, false, false, &view, &allowed); if (error != ESP_OK || !allowed) goto done; if (view.principal.role != USER_ROLE_ADMIN) { error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done; } if (!strcmp(request->uri, "/api/settings/accounts")) { error = mutation ? respond(request, "400 Bad Request", "{\"error\":\"invalid_request\"}") : list_accounts(request); goto done; } if (mutation) { if (!read_request(request, &operation, false)) { wipe_input(&operation); error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}"); goto done; } if (credential_action(operation.action) && !ensure_secret_timer()) { wipe_input(&operation); error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}"); goto done; } operation.session = view.id; operation.principal = view.principal; operation.deadline = esp_timer_get_time() + 30000000LL; operation.state = PENDING; taskENTER_CRITICAL(&s_lock); bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX; if (!busy) { operation.id = ++s_next_id; s_operation = operation; } taskEXIT_CRITICAL(&s_lock); if (busy || admin_ssh_console_submit_account_settings(operation.id) != ESP_OK) { taskENTER_CRITICAL(&s_lock); if (!busy && s_operation.id == operation.id && !s_operation.executing) secure_wipe(&s_operation, sizeof(s_operation)); taskEXIT_CRITICAL(&s_lock); wipe_input(&operation); error = httpd_resp_set_hdr(request, "Retry-After", "1"); if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}"); goto done; } } else { taskENTER_CRITICAL(&s_lock); if (s_operation.session == view.id) { operation.id = s_operation.id; operation.action = s_operation.action; operation.state = s_operation.state; } taskEXIT_CRITICAL(&s_lock); } wipe_input(&operation); char response[96]; int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}", operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]); error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, mutation ? "202 Accepted" : "200 OK", response); done: secure_wipe(&operation, sizeof(operation)); secure_wipe(&view, sizeof(view)); web_httpd_wipe_request(request, web_httpd_unread_body(request)); return error; }