Add typed account and password settings

- Add admin account list, create, role, delete, and password workflows
- Execute identity-checked mutations through the existing dispatcher
- Bound queued credential lifetime and wipe transient secrets
- Add explicit password generation with saved-value acknowledgement
- Handle self-revocation and uncertain outcomes without automatic
  retries
- Register optional account routes without disrupting terminal
  transports
- Expand host regressions and document contracts and pending target
  checks

Validated host suites and pio run; hardware validation remains pending.
This commit is contained in:
2026-09-08 09:27:02 +02:00
parent 42548f6334
commit 94433ef975
30 changed files with 1864 additions and 48 deletions
+101
View File
@@ -39,8 +39,109 @@ static void unchanged(const stored_database_t *before)
assert(all_zero(s_candidate,sizeof(*s_candidate)));
assert(!locks);
}
static void typed_account_tests(void)
{
reset(); user_database_accounts_t list;
assert(user_database_get_accounts(&list)==ESP_OK && last_wait==0 && list.count==3);
assert(!strcmp(list.users[1].username,"other"));
user_database_account_t other=list.users[1], admin=list.users[0];
snapshot_busy=true; memset(&list,0xff,sizeof(list));
assert(user_database_get_accounts(&list)==ESP_ERR_TIMEOUT && all_zero(&list,sizeof(list)));
snapshot_busy=false;
assert(user_database_delete_current(&admin)==ESP_ERR_INVALID_STATE);
assert(user_database_set_role_current(&admin,USER_ROLE_USER)==ESP_ERR_INVALID_STATE);
assert(!writes && !commits);
assert(user_database_set_role_current(&other,USER_ROLE_ADMIN)==ESP_OK);
unsigned saved=commits;
assert(user_database_delete_current(&other)==ESP_ERR_NOT_FOUND && commits==saved);
assert(user_database_set_role_current(&other,USER_ROLE_USER)==ESP_ERR_NOT_FOUND);
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
for (fail_stage=1;fail_stage<=3;++fail_stage) {
stored_database_t before=s_database;
assert(user_database_delete_current(&other)==ESP_FAIL); unchanged(&before);
assert(user_database_set_role_current(&other,USER_ROLE_USER)==ESP_FAIL); unchanged(&before);
}
fail_stage=0; assert(user_database_delete_current(&other)==ESP_OK);
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,(const uint8_t *)"test-password",13)==ESP_OK);
assert(user_database_delete_current(&other)==ESP_ERR_NOT_FOUND);
assert(user_database_set_role_current(&other,USER_ROLE_ADMIN)==ESP_ERR_NOT_FOUND);
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
assert(user_database_delete_current(&other)==ESP_OK);
assert(all_zero(s_candidate,sizeof(*s_candidate)) && !locks);
s_initialized=false; memset(&list,0xff,sizeof(list));
assert(user_database_get_accounts(&list)==ESP_ERR_INVALID_STATE && all_zero(&list,sizeof(list)));
assert(user_database_delete_current(NULL)==ESP_ERR_INVALID_ARG);
}
static void typed_password_tests(void)
{
reset(); user_database_accounts_t list;
assert(user_database_get_accounts(&list)==ESP_OK);
user_database_account_t other=list.users[1], admin=list.users[0];
const uint8_t password[]="quote\"slash\\ space";
for (unsigned stage=1;stage<=5;++stage) {
fail_stage=stage; stored_database_t before=s_database;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_FAIL);
unchanged(&before);
}
fail_stage=0; writes=commits=0;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_OK);
assert(writes==1 && commits==1 && s_database.users[1].auth_generation==other.auth_generation+1);
assert(all_zero(s_candidate,sizeof(*s_candidate)));
stored_database_t before=s_database; unsigned rng=random_calls;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
unchanged(&before); assert(writes==1 && commits==1 && random_calls==rng);
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
assert(user_database_delete_current(&other)==ESP_OK);
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,password,sizeof(password)-1)==ESP_OK);
before=s_database; rng=random_calls;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
unchanged(&before); assert(random_calls==rng);
assert(user_database_set_password_current(NULL,password,sizeof(password)-1)==ESP_ERR_INVALID_ARG);
other.user_id=0;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
memset(other.username,'x',sizeof(other.username));
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_INVALID_ARG);
assert(user_database_set_password_current(&admin,(const uint8_t *)"short",5)==ESP_ERR_INVALID_ARG);
/* Own password is allowed even for the last administrator; old principal is stale. */
assert(user_database_set_password_current(&admin,password,sizeof(password)-1)==ESP_OK);
bool current=true; assert(user_database_principal_is_current(&actor,&current)==ESP_OK && !current);
assert(s_database.admin_count==1);
/* With a second admin, canonical self role/delete invariants allow both. */
assert(user_database_set_role((const uint8_t *)"other",5,USER_ROLE_ADMIN)==ESP_OK);
assert(user_database_get_accounts(&list)==ESP_OK); admin=list.users[0];
assert(user_database_set_role_current(&admin,USER_ROLE_USER)==ESP_OK);
assert(user_database_get_accounts(&list)==ESP_OK); admin=list.users[0];
assert(user_database_delete_current(&admin)==ESP_OK);
reset(); before=s_database; rng=random_calls;
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_ADMIN,password,sizeof(password)-1)==ESP_ERR_INVALID_STATE);
unchanged(&before); assert(!writes && !commits && rng==random_calls);
for (unsigned i=3;i<USER_DATABASE_MAX_USERS;++i) {
char name[17]; snprintf(name,sizeof(name),"account%u",i);
assert(user_database_create((const uint8_t *)name,strlen(name),USER_ROLE_USER,password,sizeof(password)-1)==ESP_OK);
}
before=s_database; rng=random_calls; unsigned saved=commits;
assert(user_database_create((const uint8_t *)"extra",5,USER_ROLE_USER,password,sizeof(password)-1)==ESP_ERR_NO_MEM);
unchanged(&before); assert(commits==saved && rng==random_calls);
/* RNG-only helper is independent of initialized storage and leaves all DB state alone. */
s_initialized=false; s_mutex=NULL;
for (unsigned mode=0;mode<2;++mode) {
user_database_generated_password_t generated; memset(&generated,0xa5,sizeof(generated));
fail_stage=mode ? 4 : 0;
assert(user_database_generate_password_value(&generated)==(mode ? ESP_FAIL : ESP_OK));
if (mode) assert(all_zero(&generated,sizeof(generated)));
else {
assert(generated.password_length==24 && strlen((const char *)generated.password)==24);
for (size_t i=0;i<24;++i) assert(strchr((const char *)s_generated_alphabet,generated.password[i]));
}
assert(!memcmp(&before,&s_database,sizeof(before)) && commits==saved && !locks);
secure_wipe(&generated,sizeof(generated));
}
assert(user_database_generate_password_value(NULL)==ESP_ERR_INVALID_ARG);
}
int main(void)
{
typed_account_tests();
typed_password_tests();
const char *supported[]={
"user add fresh user", "user add fresh admin", "user password other",
"user delete other --force", "user role other admin --force",
+11 -4
View File
@@ -24,6 +24,7 @@ db = (ROOT / "src/user_database.c").read_text()
console = (ROOT / "src/user_console.c").read_text()
admin = (ROOT / "src/admin_ssh_console.c").read_text()
prelude = r'''
#define _POSIX_C_SOURCE 200809L
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
@@ -33,7 +34,10 @@ prelude = r'''
typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
ESP_ERR_NO_MEM, ESP_ERR_NOT_FOUND, ESP_ERR_NOT_ALLOWED,
ESP_ERR_INVALID_RESPONSE, ESP_ERR_INVALID_VERSION };
ESP_ERR_INVALID_RESPONSE, ESP_ERR_INVALID_VERSION, ESP_ERR_TIMEOUT };
#define pdTRUE 1
static bool snapshot_busy;
static int last_wait;
typedef void *SemaphoreHandle_t;
#define portMAX_DELAY 0
#define NVS_READWRITE 1
@@ -45,7 +49,7 @@ static bool owner_current = true, remote = true, web = true, mismatch, cancel_pr
static int notify_error = ESP_OK;
static char revoked_name[17];
static void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
static void xSemaphoreTake(void *m, int t) { (void)m; (void)t; assert(!locks++); }
static int xSemaphoreTake(void *m, int t) { (void)m; last_wait=t; if (snapshot_busy) return 0; assert(!locks++); return pdTRUE; }
static void xSemaphoreGive(void *m) { (void)m; assert(locks-- == 1); }
static const char *esp_err_to_name(int e) { (void)e; return "injected error"; }
static int nvs_open(const char *ns, int mode, int *h) {
@@ -143,8 +147,11 @@ db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
"find_free_user", "stored_keys_equal", "validate_database", "recount",
"next_generation", "discard_candidate", "commit_candidate_locked", "initialize_user",
"user_database_principal_is_current", "create_locked", "user_database_create",
"mutate_user_begin", "user_database_delete", "user_database_set_role",
"user_database_set_password"]
"mutate_user_begin", "target_matches_locked", "delete_user", "set_role",
"user_database_delete", "user_database_set_role", "user_database_get_accounts",
"user_database_delete_current", "user_database_set_role_current",
"set_password", "user_database_set_password", "user_database_set_password_current",
"user_database_generate_password_value"]
console_names = ["print_usage", "revoke_user_network_sessions", "read_password",
"show_generated_password", "mutation_currentness", "add_user", "change_password",
"parse_key_index", "command_user_inner", "command_user"]
+2 -1
View File
@@ -31,7 +31,8 @@ typedef int *SemaphoreHandle_t;
#define pdMS_TO_TICKS(x) (x)
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
static unsigned lock_depth, ticks, runs, actions;
static uint32_t serial_settings_executed;
static uint32_t serial_settings_executed, account_settings_executed;
static void web_account_settings_execute(uint32_t id) { assert(!lock_depth); account_settings_executed = id; }
static unsigned serial_settings_preceding_runs, queue_send_wait;
static void web_serial_settings_execute(uint32_t id) {
assert(!lock_depth);
+11
View File
@@ -340,5 +340,16 @@ int main(void)
pump(worker_task);
assert(runs == before_serial + 5 && serial_settings_executed == 17 && !s_request_queue->count);
puts("PASS: typed Serial admission uses zero wait on success/full queue, preserves all four queued UART requests and FIFO execution, no command-string dispatch");
assert(admin_ssh_console_submit_account_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_account_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_account_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
queue_full = false;
assert(admin_ssh_console_submit_serial_settings(21) == ESP_OK);
assert(admin_ssh_console_submit_account_settings(22) == ESP_OK && queue_send_wait == 0);
pump(worker_task);
assert(serial_settings_executed == 21 && account_settings_executed == 22 && runs == before_serial + 5);
puts("PASS: typed Accounts uses same bounded queue with nonblocking admission and isolated dispatcher routing");
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
}