Implement SSH authorized key management

This commit is contained in:
2026-09-08 16:37:47 +02:00
parent 22a7c7b0a5
commit 42f6423d4e
19 changed files with 930 additions and 83 deletions
+96 -10
View File
@@ -1091,6 +1091,44 @@ static bool target_matches_locked(const uint8_t *username, size_t length,
s_database.users[index].auth_generation == expected->auth_generation;
}
esp_err_t user_database_get_account_keys(const user_database_account_t *expected,
user_database_user_snapshot_t *snapshot)
{
if (!snapshot) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (!expected) return ESP_ERR_INVALID_ARG;
size_t length = strnlen(expected->username, sizeof(expected->username));
if (!user_database_username_valid((const uint8_t *)expected->username, length))
return ESP_ERR_INVALID_ARG;
if (!s_initialized || !s_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
esp_err_t error = ESP_ERR_NOT_FOUND;
if (target_matches_locked((const uint8_t *)expected->username, length, expected)) {
const stored_user_t *user = &s_database.users[find_user(&s_database,
(const uint8_t *)expected->username, length)];
snapshot->active = true;
snapshot->user_id = user->user_id;
snapshot->auth_generation = user->auth_generation;
snapshot->role = (user_role_t)user->role;
snapshot->username_length = length;
memcpy(snapshot->username, user->username, length);
snapshot->public_key_count = user->key_count;
for (size_t i = 0; i < USER_DATABASE_MAX_SSH_KEYS_PER_USER; ++i) {
const stored_key_t *key = &user->keys[i];
if (!key->active) continue;
user_database_key_snapshot_t *out = &snapshot->public_keys[i];
out->active = true;
out->index = (uint8_t)i;
out->key_type_length = key->type_length;
memcpy(out->key_type, key->type, key->type_length);
memcpy(out->sha256_fingerprint, key->fingerprint, sizeof(out->sha256_fingerprint));
}
error = ESP_OK;
}
xSemaphoreGive(s_mutex);
return error;
}
static esp_err_t delete_user(const uint8_t *username, size_t username_length,
const user_database_account_t *expected)
{
@@ -1245,11 +1283,11 @@ esp_err_t user_database_generate_password(
return error;
}
esp_err_t user_database_add_ssh_key(
static esp_err_t add_ssh_key(
const uint8_t *username, size_t username_length,
const uint8_t *key_type, size_t key_type_length,
const uint8_t *key_blob, size_t key_blob_length,
uint8_t *key_index)
uint8_t *key_index, const user_database_account_t *expected)
{
if (!s_initialized || s_mutex == NULL || key_index == NULL ||
!user_database_key_valid(key_type, key_type_length, key_blob, key_blob_length)) {
@@ -1257,7 +1295,8 @@ esp_err_t user_database_add_ssh_key(
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int user_index;
esp_err_t error = mutate_user_begin(username, username_length, &user_index);
esp_err_t error = target_matches_locked(username, username_length, expected)
? mutate_user_begin(username, username_length, &user_index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
stored_user_t *user = &s_candidate->users[user_index];
int free_index = -1;
@@ -1308,9 +1347,9 @@ esp_err_t user_database_add_ssh_key(
return error;
}
esp_err_t user_database_remove_ssh_key(const uint8_t *username,
size_t username_length,
uint8_t key_index)
static esp_err_t remove_ssh_key(const uint8_t *username,
size_t username_length, uint8_t key_index,
const user_database_account_t *expected)
{
if (!s_initialized || s_mutex == NULL ||
key_index >= USER_DATABASE_MAX_SSH_KEYS_PER_USER) {
@@ -1318,7 +1357,8 @@ esp_err_t user_database_remove_ssh_key(const uint8_t *username,
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int user_index;
esp_err_t error = mutate_user_begin(username, username_length, &user_index);
esp_err_t error = target_matches_locked(username, username_length, expected)
? mutate_user_begin(username, username_length, &user_index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
stored_user_t *user = &s_candidate->users[user_index];
if (user->keys[key_index].active == 0U) {
@@ -1339,15 +1379,17 @@ esp_err_t user_database_remove_ssh_key(const uint8_t *username,
return error;
}
esp_err_t user_database_clear_ssh_keys(const uint8_t *username,
size_t username_length)
static esp_err_t clear_ssh_keys(const uint8_t *username,
size_t username_length,
const user_database_account_t *expected)
{
if (!s_initialized || s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int user_index;
esp_err_t error = mutate_user_begin(username, username_length, &user_index);
esp_err_t error = target_matches_locked(username, username_length, expected)
? mutate_user_begin(username, username_length, &user_index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
stored_user_t *user = &s_candidate->users[user_index];
if (user->key_count == 0U) {
@@ -1367,3 +1409,47 @@ esp_err_t user_database_clear_ssh_keys(const uint8_t *username,
xSemaphoreGive(s_mutex);
return error;
}
esp_err_t user_database_add_ssh_key(const uint8_t *username, size_t length,
const uint8_t *type, size_t type_length, const uint8_t *blob, size_t blob_length,
uint8_t *index)
{
return add_ssh_key(username, length, type, type_length, blob, blob_length, index, NULL);
}
esp_err_t user_database_remove_ssh_key(const uint8_t *username, size_t length, uint8_t index)
{
return remove_ssh_key(username, length, index, NULL);
}
esp_err_t user_database_clear_ssh_keys(const uint8_t *username, size_t length)
{
return clear_ssh_keys(username, length, NULL);
}
static bool key_target_valid(const user_database_account_t *expected)
{
return expected && user_database_username_valid((const uint8_t *)expected->username,
strnlen(expected->username, sizeof(expected->username)));
}
esp_err_t user_database_add_ssh_key_current(const user_database_account_t *expected,
const uint8_t *type, size_t type_length, const uint8_t *blob, size_t blob_length,
uint8_t *index)
{
if (!key_target_valid(expected)) return ESP_ERR_INVALID_ARG;
return add_ssh_key((const uint8_t *)expected->username, strlen(expected->username),
type, type_length, blob, blob_length, index, expected);
}
esp_err_t user_database_remove_ssh_key_current(const user_database_account_t *expected, uint8_t index)
{
if (!key_target_valid(expected)) return ESP_ERR_INVALID_ARG;
return remove_ssh_key((const uint8_t *)expected->username, strlen(expected->username), index, expected);
}
esp_err_t user_database_clear_ssh_keys_current(const user_database_account_t *expected)
{
if (!key_target_valid(expected)) return ESP_ERR_INVALID_ARG;
return clear_ssh_keys((const uint8_t *)expected->username, strlen(expected->username), expected);
}
+10
View File
@@ -116,6 +116,16 @@ typedef struct {
user_database_account_t users[USER_DATABASE_MAX_USERS];
} user_database_accounts_t;
esp_err_t user_database_get_accounts(user_database_accounts_t *accounts);
/* Zero-wait, identity-conditional projection; fingerprints only, no key blobs.
* Output is cleared on failure; absent/stale identity returns NOT_FOUND. */
esp_err_t user_database_get_account_keys(const user_database_account_t *expected,
user_database_user_snapshot_t *snapshot);
esp_err_t user_database_add_ssh_key_current(const user_database_account_t *expected,
const uint8_t *key_type, size_t key_type_length,
const uint8_t *key_blob, size_t key_blob_length, uint8_t *key_index);
esp_err_t user_database_remove_ssh_key_current(const user_database_account_t *expected,
uint8_t key_index);
esp_err_t user_database_clear_ssh_keys_current(const user_database_account_t *expected);
/* Compare target identity under the mutation lock, before candidate/commit.
* ESP_ERR_NOT_FOUND means absent or stale; existing account invariants apply. */
esp_err_t user_database_delete_current(const user_database_account_t *expected);
+159 -28
View File
@@ -8,6 +8,7 @@
#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"
@@ -16,8 +17,9 @@
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 } account_action_t;
static const char *const s_actions[] = {"role", "delete", "create", "password"};
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;
@@ -30,6 +32,10 @@ typedef struct {
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;
@@ -48,6 +54,10 @@ static void wipe_input(account_operation_t *operation)
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)
@@ -80,37 +90,77 @@ static bool ensure_secret_timer(void)
return true;
}
/* Exact flat schemas. Only password accepts JSON escapes; canonical database
* policy validates the decoded bytes. No coercion/unknown/duplicate fields. */
static bool parse(const char *body, size_t length, account_operation_t *operation)
/* 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)
{
const char *keys[] = {"action", "username", "user_id", "auth_generation", "role", "password"};
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 < 6; ++field) {
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 < 6; ++key)
for (; key < 8; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 6 || (seen & (1U << key))) return false;
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) {
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 || pos == start || (pos - start > 1 && body[start] == '0')) return false;
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) ||
@@ -140,6 +190,7 @@ static bool parse(const char *body, size_t length, account_operation_t *operatio
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();
@@ -148,8 +199,13 @@ static bool parse(const char *body, size_t length, account_operation_t *operatio
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
const unsigned schemas[] = {31U, 15U, 51U, 47U};
return pos == length && seen == schemas[operation->action];
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)
@@ -178,6 +234,17 @@ void web_account_settings_execute(uint32_t id)
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;
@@ -185,8 +252,10 @@ void web_account_settings_execute(uint32_t id)
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 : PROTECTED) :
error == ESP_ERR_NO_MEM && operation.action == ACTION_CREATE ? FULL : FAILED;
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);
@@ -237,6 +306,80 @@ static esp_err_t list_accounts(httpd_req_t *request)
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};
@@ -290,19 +433,7 @@ esp_err_t web_account_settings_handler(httpd_req_t *request)
goto done;
}
if (mutation) {
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 && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
if (!read_request(request, &operation, false)) {
wipe_input(&operation);
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}");
goto done;
+15
View File
@@ -7,6 +7,21 @@
* results are replaceable, not durable history or an idempotent retry API. */
esp_err_t web_account_settings_handler(httpd_req_t *request);
void web_account_settings_execute(uint32_t id);
/* POST /api/settings/accounts/keys: admin cookie + Origin/CSRF, JSON exactly
* {username,user_id,auth_generation}. Read-only zero-wait snapshot, 512-byte
* response bound: {username,user_id,auth_generation,keys:[{index,type,fingerprint}]}.
* Fingerprints are OpenSSH SHA256: base64 without padding, never key blobs.
* Stale/absent target: 409 {error:"stale"}; busy DB: 503 accounts_unavailable.
* Register independently as an optional POST route.
*
* Existing account-operation POST adds key-add (+public_key, OpenSSH text <=384
* decoded bytes), key-delete (+key_index integer 0..2), key-clear. All require
* username/user_id/auth_generation. Exact schemas, <=768 body bytes/4 receives.
* Text/base64 errors: 400; canonical SSH blob/curve validation runs on dispatcher
* (failed result). Duplicate/full/stale use existing named result states.
* Success target-revokes immediately, including self; lost response/401 remains
* uncertain, never proof of cancellation. No automatic mutation retries. */
esp_err_t web_account_keys_handler(httpd_req_t *request);
/* POST /api/settings/accounts/generate-password; bodyless admin cookie +
* Origin/CSRF. RNG only, no queued/account/persistent state or retrieval. */
esp_err_t web_account_generate_password_handler(httpd_req_t *request);
+6 -1
View File
@@ -398,6 +398,10 @@ static const httpd_uri_t s_account_operation_get_uri = {
static const httpd_uri_t s_account_operation_post_uri = {
.uri = "/api/settings/account-operation", .method = HTTP_POST, .handler = web_account_settings_handler,
};
static const httpd_uri_t s_account_keys_uri = {
.uri = "/api/settings/accounts/keys", .method = HTTP_POST,
.handler = web_account_keys_handler,
};
static const httpd_uri_t s_account_generate_password_uri = {
.uri = "/api/settings/accounts/generate-password", .method = HTTP_POST,
.handler = web_account_generate_password_handler,
@@ -591,7 +595,7 @@ esp_err_t web_server_start(void)
config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 9U;
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 10U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
@@ -647,6 +651,7 @@ esp_err_t web_server_start(void)
web_httpd_register_optional(server, &s_account_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_account_operation_get_uri.uri, HTTP_GET);
(void)web_httpd_register_optional(server, &s_account_generate_password_uri);
(void)web_httpd_register_optional(server, &s_account_keys_uri);
}
if (error != ESP_OK) {
web_cookie_auth_stop();
+60 -10
View File
@@ -230,6 +230,13 @@ static const char s_index_html[] =
"<div class=\"serial-actions\"><button id=\"account-change-role\" class=\"button\" type=\"button\">Change role</button>"
"<button id=\"account-delete\" class=\"button\" type=\"button\">Delete account</button>"
"<button id=\"account-result\" class=\"button\" type=\"button\">Check Result</button></div>"
"<h3>Authorized SSH public keys</h3><p>Paste one OpenSSH Ed25519 or ECDSA P-256 public key (maximum 384 UTF-8 bytes). Public keys only; no private-key upload, export or SSH host management. Up to three keys per account.</p>"
"<button id=\"account-keys-refresh\" class=\"button\" type=\"button\">Read selected account keys</button>"
"<p id=\"account-keys-detail\" role=\"status\"></p><pre id=\"account-keys-list\"></pre>"
"<label>Key to delete<select id=\"account-key-index\"><option id=\"key-option-0\" value=\"0\"></option><option id=\"key-option-1\" value=\"1\"></option><option id=\"key-option-2\" value=\"2\"></option></select></label>"
"<label>OpenSSH public key<textarea id=\"account-public-key\" maxlength=\"384\" autocomplete=\"off\" spellcheck=\"false\"></textarea></label>"
"<div class=\"serial-actions\"><button id=\"account-key-add\" class=\"button\" type=\"button\">Import public key</button>"
"<button id=\"account-key-delete\" class=\"button\" type=\"button\">Delete selected key</button><button id=\"account-key-clear\" class=\"button\" type=\"button\">Clear all authorized keys</button></div>"
"<div class=\"serial-edit\"><label>Purpose<select id=\"account-purpose\"><option value=\"create\">Create account</option><option value=\"password\">Change selected account password</option></select></label>"
"<label id=\"account-username-label\">New username<input id=\"account-username\" maxlength=\"16\" autocomplete=\"off\"></label>"
"<label id=\"account-create-role-label\">Initial role<select id=\"account-create-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label>"
@@ -418,10 +425,36 @@ static const char s_app_js[] =
" }\n"
"}\n"
"let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n"
"let keysAbort = null, accountKeys = [], keysIdentity = '';\n"
"function keyIdentity() { const t = accounts[Number(element('account-target').value)]; return t ? JSON.stringify([t.username,t.user_id,t.auth_generation]) : ''; }\n"
"function clearAccountKeys() {\n"
" if (keysAbort) keysAbort.abort(); keysAbort = null; accountKeys = []; keysIdentity = '';\n"
" element('account-public-key').value = ''; element('account-keys-list').textContent = ''; element('account-keys-detail').textContent = 'Read keys for the selected account before deleting or clearing.';\n"
" for (let i = 0; i < 3; ++i) { const o = element('key-option-' + i); o.textContent = ''; o.hidden = o.disabled = true; }\n"
"}\n"
"async function refreshAccountKeys() {\n"
" if (!accountsLive() || accountsAbort || accountPending || keysAbort || !keyIdentity()) return;\n"
" clearAccountSecret(); clearAccountKeys();\n"
" const identity = keyIdentity(), t = accounts[Number(element('account-target').value)], controller = new AbortController(), generation = workGeneration; keysAbort = controller; accountButtons();\n"
" const current = () => keysAbort === controller && identity === keyIdentity() && accountsLive();\n"
" element('account-keys-detail').textContent = 'Reading selected account keys...';\n"
" try {\n"
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
" const {payload: p} = await api('/api/settings/accounts/keys', generation, {method: 'POST', body: JSON.stringify({username:t.username,user_id:t.user_id,auth_generation:t.auth_generation}), signal:controller.signal, limit:768, current});\n"
" if (!p || Object.keys(p).length !== 4 || p.username !== t.username || p.user_id !== t.user_id || p.auth_generation !== t.auth_generation) throw new Error('Stale key identity');\n"
" if (!Array.isArray(p.keys) || p.keys.length > 3 || new Set(p.keys.map(k => k?.index)).size !== p.keys.length || !p.keys.every(k => k && Object.keys(k).length === 3 && Number.isInteger(k.index) && k.index >= 0 && k.index <= 2 && ['ssh-ed25519','ecdsa-sha2-nistp256'].includes(k.type) && typeof k.fingerprint === 'string' && /^SHA256:[A-Za-z0-9+/]{43}$/.test(k.fingerprint))) throw new Error('Invalid keys');\n"
" accountKeys = p.keys; keysIdentity = identity;\n"
" element('account-keys-list').textContent = accountKeys.map(k => k.index + ': ' + k.type + ' ' + k.fingerprint).join('\\n');\n"
" for (let i = 0; i < 3; ++i) { const o = element('key-option-' + i), key = accountKeys.find(k => k.index === i); o.value = String(i); o.textContent = key ? key.index + ': ' + key.fingerprint : ''; o.hidden = o.disabled = !key; }\n"
" element('account-key-index').value = accountKeys.length ? String(accountKeys[0].index) : ''; element('account-keys-detail').textContent = t.username + ': ' + accountKeys.length + ' authorized keys. List refreshed.';\n"
" } catch (error) { if (current()) { clearAccountKeys(); element('account-keys-detail').textContent = error.status === 409 || error.message === 'Stale key identity' ? 'Account identity stale. Refresh accounts and select the target again; no automatic retry.' : 'Keys unavailable or invalid. Refresh accounts or explicitly read keys again; no automatic retry.'; accountButtons(); } }\n"
" finally { if (keysAbort === controller) { keysAbort = null; accountButtons(); } }\n"
"}\n"
"let secretEpoch = 0, secretAbort = null, secretTimer = null, generatedPassword = '', generatedContext = '', savedContext = '', secretExpires = 0;\n"
"function secretContext() { const t = accounts[Number(element('account-target').value)]; return JSON.stringify([element('account-purpose').value, element('account-username').value, element('account-create-role').value, t?.username, t?.user_id, t?.auth_generation]); }\n"
"function invalidateSecretRequest() { ++secretEpoch; if (secretAbort) secretAbort.abort(); secretAbort = null; savedContext = ''; element('account-password-saved').checked = false; }\n"
"function clearAccountSecret() {\n"
" element('account-public-key').value = '';\n"
" invalidateSecretRequest(); window.clearTimeout(secretTimer); secretTimer = null; secretExpires = 0; generatedPassword = generatedContext = '';\n"
" for (const id of ['account-password','account-password-confirm','account-generated']) element(id).value = '';\n"
" element('account-generated-panel').hidden = true; element('account-secret-detail').textContent = '';\n"
@@ -444,8 +477,10 @@ static const char s_app_js[] =
"element('account-purpose').value = 'create'; element('account-create-role').value = 'user';\n"
"function accountsLive() { return selected === 'settings' && settingsDomain === 'accounts' && accountRole === 'admin' && sessionVerified && !suspended && !unloading && !navigating && !loggingOut; }\n"
"function accountButtons() {\n"
" const busy = !!accountsAbort, target = accounts[Number(element('account-target').value)];\n"
" const busy = !!accountsAbort || !!keysAbort, target = accounts[Number(element('account-target').value)];\n"
" const blocked = busy || accountPending || !target;\n"
" element('account-keys-refresh').disabled = element('account-key-add').disabled = element('account-public-key').disabled = blocked;\n"
" element('account-key-delete').disabled = element('account-key-clear').disabled = element('account-key-index').disabled = blocked || !accountKeys.length || keysIdentity !== keyIdentity();\n"
" element('account-delete').disabled = element('account-change-role').disabled = blocked;\n"
" element('account-target').disabled = element('account-role').disabled = busy || accountPending || !accounts.length;\n"
" element('account-result').disabled = element('refresh-accounts').disabled = busy;\n"
@@ -456,7 +491,7 @@ static const char s_app_js[] =
" for (const id of ['account-purpose','account-username','account-create-role','account-password','account-password-confirm','account-password-saved']) element(id).disabled = busy || accountPending;\n"
"}\n"
"function clearAccounts() {\n"
" clearAccountSecret();\n"
" clearAccountSecret(); clearAccountKeys();\n"
" if (accountsAbort) accountsAbort.abort();\n"
" accountsAbort = null; accounts = []; element('accounts-list').textContent = '';\n"
" for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = ''; option.hidden = option.disabled = true; }\n"
@@ -472,7 +507,8 @@ static const char s_app_js[] =
"}\n"
"async function refreshAccounts() {\n"
" if (!accountsLive() || accountsAbort) return;\n"
" clearAccountSecret();\n"
" clearAccountSecret(); clearAccountKeys();\n"
" const previous = accounts[Number(element('account-target').value)];\n"
" const controller = new AbortController(), generation = workGeneration; accountsAbort = controller; accountButtons();\n"
" const current = () => accountsAbort === controller && accountsLive();\n"
" element('accounts-detail').textContent = 'Reading accounts; previous list may be stale.';\n"
@@ -486,13 +522,15 @@ static const char s_app_js[] =
" clearAccountSecret(); accounts = payload.users;\n"
" element('accounts-list').textContent = accounts.map(u => u.username + ' — ' + u.role + (u.username === sessionIdentity.username ? ' (you)' : '')).join('\\n');\n"
" for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = accounts[i]?.username || ''; option.hidden = option.disabled = !accounts[i]; }\n"
" element('account-target').value = '0'; element('account-role').value = accounts[0]?.role || 'user';\n"
" const index = Math.max(0, accounts.findIndex(t => t.username === previous?.username && t.user_id === previous?.user_id));\n"
" element('account-target').value = String(index); element('account-role').value = accounts[index]?.role || 'user';\n"
" element('accounts-detail').textContent = accountPending ? 'List may be stale while operation outcome is pending or unknown.' : 'Account list refreshed. Select an account before changing it.';\n"
" return true;\n"
" } catch (error) { if (live(generation) && current()) element('accounts-detail').textContent = (error.status ? error.message : 'Account list unavailable or invalid.') + ' List stale. Refresh to retry.'; }\n"
" finally { if (current()) { accountsAbort = null; accountButtons(); } }\n"
"}\n"
"async function accountOperation(action) {\n"
" if (!accountsLive() || accountsAbort || (action && accountPending)) return;\n"
" if (!accountsLive() || accountsAbort || keysAbort || (action && accountPending)) return;\n"
" let body;\n"
" if (action) {\n"
" const target = accounts[Number(element('account-target').value)], role = element('account-role').value;\n"
@@ -508,6 +546,12 @@ static const char s_app_js[] =
" request = action === 'create' ? {action, username, role: initialRole, password} : {action, username, user_id: target.user_id, auth_generation: target.auth_generation, password};\n"
" body = JSON.stringify(request);\n"
" } finally { clearAccountSecret(); if (request) request.password = ''; request = null; }\n"
" } else if (['key-add','key-delete','key-clear'].includes(action)) {\n"
" let publicKey = element('account-public-key').value; const index = Number(element('account-key-index').value);\n"
" const valid = target && (action === 'key-add' ? encoder.encode(publicKey).length <= 384 && /^(?:ssh-ed25519|ecdsa-sha2-nistp256) [A-Za-z0-9+/]+={0,2}(?: [^\\r\\n\\x00]*)?$/.test(publicKey) : keysIdentity === keyIdentity() && accountKeys.length && (action !== 'key-delete' || Number.isInteger(index) && accountKeys.some(k => k.index === index)));\n"
" clearAccountSecret();\n"
" if (!valid) { publicKey = ''; element('account-operation-detail').textContent = 'Not submitted. Paste one OpenSSH Ed25519 or ECDSA P-256 public key within 384 UTF-8 bytes, or read current keys and select a valid index.'; return; }\n"
" body = JSON.stringify({action,username:target.username,user_id:target.user_id,auth_generation:target.auth_generation,...(action === 'key-add' ? {public_key:publicKey} : action === 'key-delete' ? {key_index:index} : {})}); publicKey = '';\n"
" } else {\n"
" clearAccountSecret(); if (!target || !['role','delete'].includes(action) || !['user','admin'].includes(role)) return;\n"
" body = JSON.stringify({action, username: target.username, user_id: target.user_id, auth_generation: target.auth_generation, ...(action === 'role' ? {role} : {})});\n"
@@ -515,19 +559,20 @@ static const char s_app_js[] =
" if (encoder.encode(body).length > 768) { body = undefined; return; }\n"
" const name = action === 'create' ? element('account-username').value : target.username;\n"
" const warning = name === sessionIdentity.username ? ' ALL this accounts web/SSH sessions, including this browser serial/admin, can close immediately (even a no-op role change). A 401 or disconnect is NOT proof of success. Save the password before submitting, then re-login and inspect if the result is lost.' : '';\n"
" if (!window.confirm((action === 'delete' ? 'Delete ' : action === 'create' ? 'Create ' + element('account-create-role').value + ' account ' : action === 'password' ? 'Change password for ' : 'Change role to ' + role + ' for ') + name + '? Saved immediately; affected account sessions may be revoked.' + warning)) { body = undefined; return; }\n"
" if (!window.confirm((action === 'key-add' ? 'Import public key for ' : action === 'key-delete' ? 'Delete key ' + accountKeys.find(k => k.index === Number(element('account-key-index').value)).fingerprint + ' for ' : action === 'key-clear' ? 'Clear ALL authorized keys for ' : action === 'delete' ? 'Delete ' : action === 'create' ? 'Create ' + element('account-create-role').value + ' account ' : action === 'password' ? 'Change password for ' : 'Change role to ' + role + ' for ') + name + '? Saved immediately; affected account sessions may be revoked.' + warning)) { body = undefined; return; }\n"
" }\n"
" const controller = new AbortController(), generation = workGeneration; accountsAbort = controller; accountButtons();\n"
" const current = () => accountsAbort === controller && accountsLive();\n"
" controller.signal.addEventListener('abort', () => { body = undefined; }, {once: true});\n"
" const detail = element('account-operation-detail'); let deadline, refresh = false, until = Infinity;\n"
" const detail = element('account-operation-detail'); let deadline, refresh = false, refreshKeys = false, until = Infinity;\n"
" clearAccountKeys();\n"
" detail.textContent = accountWarning + (action ? 'Submitting once...' : 'Reading latest result...');\n"
" element('accounts-detail').textContent = 'List may be stale until operation completes and refresh succeeds.';\n"
" const read = async (method, requestBody) => {\n"
" const response = api('/api/settings/account-operation', generation, {method, body: requestBody, signal: controller.signal, limit: 96, current: () => current() && performance.now() < until}); requestBody = undefined;\n"
" const {payload: result} = await response;\n"
" if (performance.now() >= until) throw new Error('Check deadline');\n"
" if (!result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 || !['none','role','delete','create','password'].includes(result.action) ||\n"
" if (!result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 || !['none','role','delete','create','password','key-add','key-delete','key-clear'].includes(result.action) ||\n"
" !['idle','pending','ok','failed','cancelled','stale','protected','duplicate','full'].includes(result.state) || ((result.id === 0) !== (result.state === 'idle')) || ((result.id === 0) !== (result.action === 'none')) ||\n"
" (method === 'POST' && (!result.id || result.action !== action || result.state !== 'pending'))) throw new Error('Invalid operation result');\n"
" if (method === 'POST') accountWarning = '';\n"
@@ -535,6 +580,8 @@ static const char s_app_js[] =
" else if (accountId && result.id !== accountId) accountWarning = 'Previous result replaced or unavailable; outcome unknown. ';\n"
" accountId = result.id; accountPending = result.state === 'pending'; accountAwaitingAck = false;\n"
" const messages = {duplicate: 'Username already exists. Refresh before retrying.', full: 'Account capacity full. Inspect accounts before retrying.', idle: 'No retained result. Inspect accounts before retrying.', pending: 'Queued or executing...', ok: 'Account change completed and saved.', failed: 'Operation failed. Inspect accounts before retrying.', cancelled: 'Cancelled before execution: login or queue deadline stale.', stale: 'Account changed or was replaced. Refresh and select it again.', protected: 'Account is protected (including the final administrator), or database unavailable.'};\n"
" refreshKeys = result.action.startsWith('key-');\n"
" if (refreshKeys) { messages.duplicate = 'Public key already authorized. Inspect refreshed keys before retrying.'; messages.full = 'Authorized key capacity full (three). Inspect refreshed keys before retrying.'; }\n"
" detail.textContent = accountWarning + result.action + ': ' + messages[result.state];\n"
" return result.state;\n"
" };\n"
@@ -560,7 +607,7 @@ static const char s_app_js[] =
" if (state === 'pending') detail.textContent += ' Automatic checking stopped. Use Check Result; do not resubmit.';\n"
" refresh = state !== 'pending' && state !== 'idle';\n"
" } catch (error) { if (live(generation) && current()) detail.textContent = accountWarning + (error.status ? error.message : 'Outcome unknown.') + ' Use Check Result and Refresh before an explicit retry. No automatic retry.'; }\n"
" finally { body = undefined; window.clearTimeout(deadline); if (current()) { accountsAbort = null; accountButtons(); if (refresh) await refreshAccounts(); } }\n"
" finally { body = undefined; window.clearTimeout(deadline); if (current()) { accountsAbort = null; accountButtons(); if (refresh && await refreshAccounts() && refreshKeys && live(generation)) await refreshAccountKeys(); } }\n"
"}\n"
"element('settings-serial').addEventListener('click', () => selectSettingsDomain('serial'));\n"
"element('settings-accounts').addEventListener('click', () => selectSettingsDomain('accounts'));\n"
@@ -573,7 +620,10 @@ static const char s_app_js[] =
"element('account-password-saved').addEventListener('change', () => { savedContext = element('account-password-saved').checked && generatedPassword && element('account-password').value === generatedPassword && generatedContext === secretContext() && performance.now() < secretExpires ? generatedContext : ''; });\n"
"for (const id of ['account-password','account-password-confirm']) element(id).addEventListener('input', () => { invalidateSecretRequest(); accountButtons(); });\n"
"for (const id of ['account-purpose','account-username','account-create-role','account-role']) element(id).addEventListener(id === 'account-username' ? 'input' : 'change', () => { clearAccountSecret(); accountButtons(); });\n"
"element('account-target').addEventListener('change', () => { clearAccountSecret(); element('account-role').value = accounts[Number(element('account-target').value)]?.role || 'user'; accountButtons(); });\n"
"element('account-keys-refresh').addEventListener('click', refreshAccountKeys);\n"
"for (const action of ['key-add','key-delete','key-clear']) element('account-' + action).addEventListener('click', () => accountOperation(action));\n"
"element('account-key-index').addEventListener('change', clearAccountSecret);\n"
"element('account-target').addEventListener('change', () => { clearAccountSecret(); clearAccountKeys(); element('account-role').value = accounts[Number(element('account-target').value)]?.role || 'user'; accountButtons(); });\n"
"let accountRole = 'user', selected = 'serial';\n"
"let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n"
"let adminGeneration = 0, adminTimer = null;\n"