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");
}
+68 -13
View File
@@ -37,8 +37,8 @@ def define(path, name):
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
source, re.M | re.S)
# Non-array declarations have no brackets; explicit shape avoids silent omission.
if len(uri_tables) != 16:
raise RuntimeError('Review URI extraction: expected 14 descriptors and two tables')
if len(uri_tables) != 20:
raise RuntimeError('Review URI extraction: expected 18 descriptors and two tables')
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
source.index('static esp_err_t ensure_mutex(void)')]
header = (ROOT / 'src/web_server.h').read_text()
@@ -108,7 +108,11 @@ HANDLER(root_handler) HANDLER(status_handler) HANDLER(ticket_handler)
HANDLER(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler)
HANDLER(serial_settings_handler)
HANDLER(web_serial_settings_handler)
HANDLER(web_serial_settings_handler) HANDLER(web_account_settings_handler)
HANDLER(web_account_generate_password_handler)
static unsigned account_calls, account_fail_at;
static unsigned generation_calls;
static bool generation_fail;
static esp_err_t route_error_handler(httpd_req_t *r, httpd_err_code_t c) { (void)r; (void)c; assert(0); return ESP_FAIL; }
static esp_err_t web_serial_transport_init(void) { assert(!locked); ++serial_inits; return serial_init_error; }
static esp_err_t web_cookie_auth_start(void) { assert(!locked); ++auth_starts; auth_live = auth_error == ESP_OK; return auth_error; }
@@ -120,7 +124,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
assert(config->httpd.max_uri_handlers == 19 && config->port_secure == 443);
assert(config->httpd.max_uri_handlers == 23 && config->port_secure == 443);
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
assert(config->tls_handshake_timeout_ms == 5000);
assert(config->servercert_len == 1 && config->servercert[0] == 1);
@@ -149,11 +153,26 @@ static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t
if (error == ESP_OK) { assert(registered_count < 32); registered[registered_count++] = uri; }
return error;
}
static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
assert(s == SERVER && auth_live && ssl_live && !locked && uri->handler == web_account_settings_handler);
if (++account_calls == account_fail_at) return ESP_ERR_NO_MEM;
registered[registered_count++] = uri; return ESP_OK;
}
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
assert(uri->method == HTTP_GET);
if (uri->handler == web_account_settings_handler) return account_register(s, uri);
assert(!strcmp(uri->uri, "/api/settings/serial"));
return httpd_register_uri_handler(s, uri);
}
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
if (uri->handler == web_account_generate_password_handler) {
assert(s == SERVER && auth_live && ssl_live && !locked);
assert(!strcmp(uri->uri, "/api/settings/accounts/generate-password") && uri->method == HTTP_POST);
assert(!uri->is_websocket); ++generation_calls;
if (generation_fail) return ESP_ERR_NO_MEM;
registered[registered_count++] = uri; return ESP_OK;
}
if (uri->handler == web_account_settings_handler) return account_register(s, uri);
assert(s == SERVER && auth_live && ssl_live && !locked);
assert(!strcmp(uri->uri, "/api/settings/serial-operation") && uri->handler == web_serial_settings_handler);
if (++operation_calls == operation_fail_at) return ESP_ERR_NO_MEM;
@@ -163,7 +182,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
(!strcmp(uri, "/api/settings/serial-operation") && method == HTTP_GET));
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation")) && method == HTTP_GET));
++unregister_calls;
for (unsigned i = 0; i < registered_count; ++i) {
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
@@ -228,6 +247,7 @@ static void reset(void) {
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
operation_calls = operation_fail_at = 0;
account_calls = account_fail_at = generation_calls = 0; generation_fail = false;
}
static void fresh_registration(void) { registration_calls = registered_count = 0; }
static void start(void) {
@@ -261,7 +281,8 @@ int main(void) {
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 19 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
reset(); start(); assert(registered_count == 23 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(route("/api/settings/serial")->handler == serial_settings_handler);
const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin");
assert(ticket->method == HTTP_POST && ticket->handler == web_admin_transport_ticket_handler && !ticket->is_websocket);
@@ -313,7 +334,7 @@ int main(void) {
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
assert(registered_count == 17 && unregister_calls == failure - 17);
assert(registered_count == 21 && unregister_calls == failure - 17);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
assert(route("/ws/serial")->handler == websocket_handler);
@@ -322,13 +343,13 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 19 && admin_attaches == 1 && s_counters.starts == 2);
assert(registered_count == 23 && admin_attaches == 1 && s_counters.starts == 2);
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
}
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
reset(); registration_fail_at = 18; unregister_fail = true;
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 18);
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 22);
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
ticket = route("/api/admin/ws-ticket");
@@ -340,7 +361,7 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds);
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
assert(registered_count == 19 && admin_attaches == 1 && web_server_stop() == ESP_OK);
assert(registered_count == 23 && admin_attaches == 1 && web_server_stop() == ESP_OK);
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
@@ -362,7 +383,7 @@ int main(void) {
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
reset(); settings_fail = true; start();
assert(settings_calls == 1 && registered_count == 18);
assert(settings_calls == 1 && registered_count == 22);
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
settings_fail = false; fresh_registration(); start();
assert(route("/api/settings/serial")->handler == serial_settings_handler);
@@ -370,13 +391,47 @@ int main(void) {
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
for (unsigned failure = 1; failure <= 2; ++failure) {
reset(); operation_fail_at = failure; start();
assert(registered_count == 17 && operation_calls == failure && unregister_calls == failure - 1);
assert(registered_count == 21 && operation_calls == failure && unregister_calls == failure - 1);
assert(auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
assert(web_server_stop() == ESP_OK);
}
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
puts("13 lifecycle groups passed (16 required fatal positions, 5 optional routes, plus failed unregister)");
for (unsigned failure = 1; failure <= 3; ++failure) {
reset(); account_calls = 0; account_fail_at = failure; start();
assert(account_calls == failure && registered_count == (failure == 1 ? 20 : 21));
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
assert(web_server_stop() == ESP_OK);
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
assert(registered_count == 23 && account_calls == 3);
assert(web_server_stop() == ESP_OK);
}
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
assert(registered_count == 22 && auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
reset(); generation_fail = true; start();
assert(generation_calls == 1 && registered_count == 22 && account_calls == 3);
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
unsigned account_mutations = 0;
for (unsigned i = 0; i < registered_count; ++i) {
assert(strcmp(registered[i]->uri, "/api/settings/accounts/generate-password"));
if (!strcmp(registered[i]->uri, "/api/settings/account-operation") && registered[i]->method == HTTP_POST)
++account_mutations;
}
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
generation_fail = false; fresh_registration(); start();
assert(generation_calls == 2 && registered_count == 23);
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(web_server_stop() == ESP_OK);
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
puts("15 lifecycle groups passed (16 required fatal positions, 9 optional routes, plus failed unregister)");
return 0;
}
'''
+26
View File
@@ -163,6 +163,32 @@ def main():
b, out = span(body), Credentials()
check("login", str(index), lambda: api.web_auth_parse_login(b, size(body), C.byref(out)),
out, expected, decoded)
api.web_auth_parse_json_string.argtypes = [C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t),
C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)]
api.web_auth_parse_json_string.restype = C.c_bool
strings = [(b' "quote\\\"slash\\\\ space",', 65, b'quote"slash\\ space'),
(b'"\\u0020\\u0022\\u005c"', 4, b' "\\'),
(b'""', 1, b''), (b'"a"', 1, None),
(b'"' + b'\\u0022' * 64 + b'"', 65, b'"' * 64),
(b'"' + b'\\u0022' * 65 + b'"', 65, None),
(b'"\\u0000"', 65, None), (b'"abc\\q"', 65, None),
(b'"abc\\ud800"', 65, None), (b'"abc\xff"', 65, None),
(None, 65, None)]
complete = b'"secret\\\"value"'
strings += [(complete[:i], 65, None) for i in range(len(complete))]
for index, (body, capacity, expected) in enumerate(strings):
prefix = b'prefix:'
raw = None if body is None else prefix + body
data, out = span(raw), C.create_string_buffer(capacity)
position, length = C.c_size_t(len(prefix)), C.c_size_t(999)
check("string", str(index),
lambda: api.web_auth_parse_json_string(data, size(raw), C.byref(position), out, capacity, C.byref(length)),
out, expected, lambda x: x.value)
if expected is None:
if position.value != len(prefix) or length.value != 0:
failures.append(f"string: {index}: failure changed cursor or retained length")
elif length.value != len(expected) or raw[position.value - 1:position.value] != b'"':
failures.append(f"string: {index}: incorrect length/cursor")
for failure in failures:
print("FAIL:", failure)
print(f"{count} cases; {len(failures)} failures")
+30
View File
@@ -16,6 +16,36 @@ This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, brows
See `docs/phase8d3_implementation.md` for source verification, other suite commands, build accounting and the target checklist.
## Accounts (8D.10)
```sh
python3 tests/web_cookie_auth/run.py --accounts
python3 tests/admin_console_boundary/accounts.py
```
The first command adds nine account HTTP/operation groups using production
cookie/store/handler/parser and the canonical generated-value helper. Queue,
database mutations, timer scheduling and revocation are doubles; authorization
is real. Covers max-width eight-account projection, strict 768-byte/four-receive
credential schemas, decoded printable-ASCII passwords, bodyless generated-value
authorization/currentness/no-mutation/cleanup, timer creation/start failure,
queued expiry/replacement/executing fences, self success revocation and protected
failure, pending/result isolation, stale IDs, submission/execution failure,
target-only notifications, session invalidation and missed revocation/DB failure.
Parent reports PASS for these nine groups plus shared regressions. Direct-handler
tests do not prove route registration; the missing registration is now fixed as
an independent optional endpoint (23 handlers), and the route agent reports 15
lifecycle groups passing for registration, failure isolation and restart.
Implementation is host-tested/build-verified (parent `pio run` PASS, 25.61 s,
95,908 B RAM / 1,694,237 B flash), not target accepted. New timer runtime costs
remain unmeasured. No sanitizer validation or device/asset/commit/8D.11 action.
The second command separately exercises production conditional database mutation
and zero-wait list bodies with NVS/RTOS doubles, including last-admin protection,
target generation/recreation checks and commit-failure cleanup. It retains the
canonical CLI account tests. These are not end-to-end RTOS/flash/TLS tests.
See `docs/phase8d10_implementation.md` for current contracts, historical slice 1
evidence and pending target checks. Timer doubles do not prove hard cleanup latency.
## Read-only Serial Settings
```sh
@@ -0,0 +1,377 @@
/* Production auth/store/handler, deterministic dispatcher and DB/transport doubles.
* Actual conditional database transactions are tested by accounts.py. */
#define ESP_ERR_TIMEOUT 0x107
#define ESP_ERR_NOT_FOUND 0x105
static unsigned wiped_passwords, wiped_responses, wiped_generated, wiped_bodies;
static const uint8_t *executing_password;
static void account_wipe(void *p, size_t n) {
if (n==65) ++wiped_passwords;
if (n==96) ++wiped_responses;
if (n==sizeof(user_database_generated_password_t)) ++wiped_generated;
if (n==768) ++wiped_bodies;
secure_wipe(p,n); zero(p,n);
if (p==executing_password) executing_password=NULL;
}
#define secure_wipe account_wipe
#include "account_parse_production.h"
#include "../../src/web_account_settings.c"
#undef secure_wipe
static unsigned timer_creates, timer_starts;
static bool timer_create_fail, timer_start_fail;
static void (*timer_callback)(void *);
int esp_timer_create(const esp_timer_create_args_t *args, esp_timer_handle_t *out) {
assert(!host_lock_depth && !s_secret_timer); ++timer_creates;
if (timer_create_fail) return ESP_FAIL;
timer_callback=args->callback; *out=(void *)1; return ESP_OK;
}
int esp_timer_start_periodic(esp_timer_handle_t timer, uint64_t period) {
assert(!host_lock_depth && timer==s_secret_timer && period==1000000); ++timer_starts;
return timer_start_fail ? ESP_FAIL : ESP_OK;
}
static bool dispatcher, queue_fail, list_fail;
static uint32_t queued;
static unsigned mutations, web_revokes, ssh_revokes, lists;
static esp_err_t mutation_error;
static void (*mutation_hook)(void);
static void (*queue_hook)(void);
static bool self_target;
static void check_slot_wiped(void) {
zero(s_operation.password,sizeof(s_operation.password)); assert(!s_operation.password_length);
zero(&s_operation.principal,sizeof(s_operation.principal));
zero(&s_operation.target,sizeof(s_operation.target));
}
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id) {
assert(!host_lock_depth && !dispatcher && id);
if (queue_hook) { void (*hook)(void)=queue_hook; queue_hook=NULL; hook(); }
if (queue_fail) return ESP_ERR_TIMEOUT;
queued=id; return ESP_OK;
}
esp_err_t user_database_get_accounts(user_database_accounts_t *out) {
assert(!host_lock_depth && !dispatcher); ++lists; memset(out,0,sizeof(*out));
if (list_fail) return ESP_ERR_TIMEOUT;
out->count=8;
for (unsigned i=0;i<8;++i) {
snprintf(out->users[i].username,sizeof(out->users[i].username),"account%09u",i);
out->users[i].role=USER_ROLE_ADMIN; out->users[i].user_id=UINT32_MAX-i;
out->users[i].auth_generation=UINT32_MAX;
}
return ESP_OK;
}
esp_err_t user_database_delete_current(const user_database_account_t *target) {
assert(dispatcher && !host_lock_depth && !strcmp(target->username,self_target ? "alice" : "carol"));
assert(s_operation.executing); check_slot_wiped();
assert(target->user_id==7 && target->auth_generation==2); ++mutations;
if (mutation_hook) { void (*hook)(void)=mutation_hook; mutation_hook=NULL; hook(); }
return mutation_error;
}
esp_err_t user_database_set_role_current(const user_database_account_t *target,user_role_t role) {
assert(role==USER_ROLE_ADMIN); return user_database_delete_current(target);
}
esp_err_t user_database_create(const uint8_t *u,size_t n,user_role_t role,const uint8_t *p,size_t pn) {
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,"carol",5) && role==USER_ROLE_ADMIN);
assert(user_database_password_valid(p,pn)); check_slot_wiped(); ++mutations;
if (mutation_hook) { void (*hook)(void)=mutation_hook; mutation_hook=NULL; hook(); }
return mutation_error;
}
esp_err_t user_database_set_password_current(const user_database_account_t *target,const uint8_t *p,size_t pn) {
assert(user_database_password_valid(p,pn)); executing_password=p;
return user_database_delete_current(target);
}
esp_err_t web_serial_transport_revoke_user(const uint8_t *u,size_t n) {
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,self_target ? "alice" : "carol",5));
assert(!executing_password);
if (self_target) web_session_store_invalidate_username(u,n);
++web_revokes; return ESP_FAIL;
}
esp_err_t ssh_transport_revoke_user(const uint8_t *u,size_t n) {
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,self_target ? "alice" : "carol",5)); ++ssh_revokes; return ESP_FAIL;
}
static const char deletion[]="{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}";
static const char role_body[]="{\"action\":\"role\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"role\":\"admin\"}";
static void account_begin(const issued_t *identity,const char *body) {
begin("/api/settings/account-operation",body?HTTP_POST:HTTP_GET,body); same_origin();
if (body) add("Content-Type","application/json");
if (identity) {
char cookie[100]; snprintf(cookie,sizeof(cookie),"__Host-sak-session=%s",identity->token); add("Cookie",cookie);
if (body) add("X-CSRF-Token",identity->view.csrf);
}
}
static void account_expect(const char *status) {
unsigned before=mutations;
esp_err_t error=web_account_settings_handler(&req);
assert(error==(send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
if (strcmp(status,response_status)) fprintf(stderr,"expected %s got %s\n",status,response_status);
assert(!strcmp(status,response_status) && mutations==before);
assert(strlen(output)<1024); zero(scratch,sizeof(scratch));
}
static void submit_account(const issued_t *identity,const char *body) {
account_begin(identity,body); account_expect("202 Accepted"); assert(s_operation.id==queued && s_operation.state==PENDING);
}
static void execute_account(void) { dispatcher=true; web_account_settings_execute(queued); dispatcher=false; }
static void invalidate_actor(void) { web_session_store_invalidate(s_operation.session); }
static const char create_body[]="{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"password1234\"}";
static const char password_body[]="{\"action\":\"password\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"password\":\"password1234\"}";
static void generate_begin(const issued_t *identity) {
account_begin(identity,""); req.uri="/api/settings/accounts/generate-password";
}
static void generate_expect(const char *status) {
account_operation_t before=s_operation;
unsigned ids=s_next_id, calls=mutations, response_wipes=wiped_responses, generated_wipes=wiped_generated;
esp_err_t result=web_account_generate_password_handler(&req);
assert(result==((send_fail || fail_header || aux.remaining_len) ? ESP_FAIL : ESP_OK));
if (!fail_header) assert(!strcmp(status,response_status));
assert(!memcmp(&before,&s_operation,sizeof(before)) && ids==s_next_id && mutations==calls);
assert(wiped_responses==response_wipes+1 && wiped_generated>generated_wipes);
zero(scratch,sizeof(scratch));
}
static void generated_tests(void) {
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob);
unsigned rng=rng_calls;
generate_begin(NULL); generate_expect("401 Unauthorized");
generate_begin(&user); generate_expect("403 Forbidden");
for (unsigned mode=0;mode<9;++mode) {
generate_begin(&admin);
if (mode==0) { req.content_len=aux.remaining_len=1; }
if (mode==1) req.method=HTTP_GET;
if (mode==2) req.uri="/api/settings/accounts/generate-password?x=1";
if (mode==3) add("Origin","https://evil.example");
if (mode==4) add("X-CSRF-Token","duplicate");
if (mode==5) add("Transfer-Encoding","chunked");
if (mode==6) add("Sec-Fetch-Site","cross-site");
if (mode==7) stale_user=alice.user_id;
if (mode==8) db_fail=true;
(void)web_account_generate_password_handler(&req);
assert(response_status[0]=='4' && rng_calls==rng);
stale_user=0; db_fail=false;
}
admin=mint(&alice); rng=rng_calls;
generate_begin(&admin); generate_expect("200 OK");
assert(rng_calls==rng+1 && strlen(output)==39 && !strncmp(output,"{\"password\":\"",13));
for (unsigned i=13;i<37;++i) assert(strchr((const char *)s_generated_alphabet,output[i]));
bool no_store=false;
for (unsigned i=0;i<aux.resp_hdrs_count;++i)
if (!strcmp(response_headers[i].field,"Cache-Control")) no_store=!strcmp(response_headers[i].value,"no-store");
assert(no_store);
rng_fail=true; generate_begin(&admin); generate_expect("503 Service Unavailable"); rng_fail=false;
assert(!strstr(output,"password"));
send_fail=true; generate_begin(&admin); generate_expect("200 OK"); send_fail=false;
fail_header=1; setter_calls=0; generate_begin(&admin); generate_expect(""); fail_header=0;
unsigned header_capacity=server.config.max_resp_headers;
for (unsigned capacity=0;capacity<3;++capacity) {
generate_begin(&admin); server.config.max_resp_headers=capacity;
unsigned wipes=wiped_responses, sent=sends;
assert(web_account_generate_password_handler(&req)==ESP_ERR_HTTPD_RESP_HDR);
assert(sends==sent && wiped_responses==wipes+1);
}
server.config.max_resp_headers=header_capacity;
hook_id=admin.view.id; rng_hook=invalidate_hook;
generate_begin(&admin); generate_expect("401 Unauthorized"); assert(!strstr(output,"password"));
admin=mint(&alice);
/* Generate even with the operation slot occupied; it must not touch it. */
submit_account(&admin,create_body); generate_begin(&admin); generate_expect("200 OK"); execute_account();
web_session_store_invalidate(admin.view.id);
rng=rng_calls; generate_begin(&admin); generate_expect("401 Unauthorized"); assert(rng_calls==rng);
puts("PASS Accounts generated value: real RNG helper/auth/store, bodyless/admin/currentness/Origin/CSRF, base64url/no-store, no slot/DB write, success/RNG/header/send cleanup");
}
static void expire_queued(void) { now=s_operation.deadline; timer_callback(NULL); }
static void executing_tick(void) {
assert(s_operation.executing); check_slot_wiped();
now=s_operation.deadline; timer_callback(NULL);
assert(s_operation.executing && s_operation.state==PENDING);
web_account_settings_execute(s_operation.id); /* Duplicate dispatcher delivery. */
}
static void validation_tick(void) {
assert(s_operation.executing); check_slot_wiped();
timer_callback(NULL); assert(s_operation.state==PENDING);
}
static issued_t busy_actor;
static void executing_busy_request(void) {
assert(dispatcher && s_operation.executing);
dispatcher=false; account_begin(&busy_actor,create_body); account_expect("503 Service Unavailable"); dispatcher=true;
assert(s_operation.executing); check_slot_wiped();
}
static void credential_tests(void) {
auth_reset(); issued_t admin=mint(&alice); receive_fragment=768;
unsigned before=mutations;
timer_create_fail=true; account_begin(&admin,create_body); account_expect("503 Service Unavailable"); timer_create_fail=false;
assert(!s_secret_timer && mutations==before);
timer_start_fail=true; account_begin(&admin,password_body); account_expect("503 Service Unavailable"); timer_start_fail=false;
assert(s_secret_timer && !s_secret_timer_started && mutations==before);
submit_account(&admin,password_body); assert(timer_creates==2 && timer_starts==2);
uint32_t old=queued; assert(s_operation.password_length==12);
now=s_operation.deadline-1; timer_callback(NULL); assert(s_operation.state==PENDING);
++now; timer_callback(NULL); assert(s_operation.state==CANCELLED); check_slot_wiped();
submit_account(&admin,create_body); timer_callback(NULL); assert(s_operation.state==PENDING);
dispatcher=true; web_account_settings_execute(old); dispatcher=false;
assert(s_operation.state==PENDING && mutations==before);
execute_account(); assert(mutations==before+1 && s_operation.state==OK); check_slot_wiped();
assert(timer_creates==2 && timer_starts==2);
/* Timeout between publication and queue submission leaves only a stale ID. */
queue_hook=expire_queued; account_begin(&admin,password_body); account_expect("202 Accepted");
assert(s_operation.state==CANCELLED); execute_account(); assert(mutations==before+1); check_slot_wiped();
queue_fail=true; account_begin(&admin,password_body); account_expect("503 Service Unavailable"); queue_fail=false;
zero(&s_operation,sizeof(s_operation));
submit_account(&admin,password_body); db_hook=validation_tick; execute_account();
assert(s_operation.state==OK && !executing_password); check_slot_wiped();
submit_account(&admin,password_body); mutation_hook=executing_tick; execute_account();
assert(s_operation.state==OK && !s_operation.executing && !executing_password);
/* Tick during external validation, followed by deadline check: cancelled. */
submit_account(&admin,password_body); db_hook=executing_tick; before=mutations; execute_account();
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
submit_account(&admin,password_body); web_session_store_invalidate(admin.view.id); execute_account();
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
admin=mint(&alice);
submit_account(&admin,password_body); stale_user=alice.user_id; execute_account(); stale_user=0;
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
admin=mint(&alice);
submit_account(&admin,password_body); db_fail=true; execute_account(); db_fail=false;
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
admin=mint(&alice);
const esp_err_t errors[]={ESP_ERR_NOT_FOUND,ESP_FAIL,ESP_ERR_INVALID_STATE,ESP_ERR_NO_MEM};
const unsigned states[]={STALE,FAILED,DUPLICATE,FULL};
unsigned revokes=web_revokes;
for (unsigned i=0;i<4;++i) {
mutation_error=errors[i]; submit_account(&admin,i<2?password_body:create_body); execute_account();
assert(s_operation.state==states[i] && web_revokes==revokes && !executing_password); check_slot_wiped();
}
mutation_error=ESP_OK;
send_fail=true; account_begin(&admin,password_body); account_expect("202 Accepted"); send_fail=false;
assert(s_operation.password_length==12); execute_account(); assert(s_operation.state==OK); check_slot_wiped();
/* Keep executing ownership even if another request arrives during validation. */
busy_actor=admin; submit_account(&admin,password_body); db_hook=executing_busy_request; execute_account();
assert(s_operation.state==OK); check_slot_wiped();
queue_fail=true; queue_hook=expire_queued;
account_begin(&admin,create_body); account_expect("503 Service Unavailable"); queue_fail=false;
zero(&s_operation,sizeof(s_operation));
assert(timer_creates==2 && timer_starts==2);
puts("PASS Accounts credential queue: timer create/start failure, explicit expiry, replacement/old IDs, queue failure, validation/execution ticks, stale/dead sessions, error and lost-ack cleanup");
}
static void password_parser_tests(void) {
auth_reset(); issued_t admin=mint(&alice); receive_fragment=768;
char body[800];
const char *valid[]={"password1234", "space space ", "quote\\\"slash\\\\", "\\u0020\\u0022\\u005c123456789", "slash\\/1234567"};
for (unsigned i=0;i<sizeof(valid)/sizeof(*valid);++i) {
snprintf(body,sizeof(body),"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"%s\"}",valid[i]);
account_operation_t parsed={0}; assert(parse(body,strlen(body),&parsed));
assert(user_database_password_valid(parsed.password,parsed.password_length));
if (i==2) assert(parsed.password_length==12 && !memcmp(parsed.password,"quote\"slash\\",12));
secure_wipe(&parsed,sizeof(parsed));
submit_account(&admin,body); execute_account(); assert(s_operation.state==OK);
}
const char *invalid[]={"", "12345678901", "12345678901\\n", "12345678901\\t", "12345678901\\u0000", "12345678901\\u007f", "12345678901\\u0080", "12345678901\\uD800", "12345678901\\x20"};
for (unsigned i=0;i<sizeof(invalid)/sizeof(*invalid);++i) {
snprintf(body,sizeof(body),"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"%s\"}",invalid[i]);
account_begin(&admin,body); account_expect("400 Bad Request");
}
for (unsigned n=11;n<=65;++n) {
char escaped[391];
for (unsigned i=0;i<n;++i) memcpy(escaped+i*6,"\\u0022",6);
escaped[n*6]=0;
snprintf(body,sizeof(body),"{\"action\":\"password\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"password\":\"%s\"}",escaped);
account_begin(&admin,body);
account_expect(n>=12 && n<=64 ? "202 Accepted" : "400 Bad Request");
if (n>=12 && n<=64) { assert(s_operation.password_length==n); execute_account(); }
}
const char *bad[]={
"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"password1234\",\"user_id\":7}",
"{\"action\":\"password\",\"username\":\"carol\",\"password\":\"password1234\"}",
"{\"action\":\"password\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"password\":\"password1234\",\"role\":\"admin\"}",
"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"password1234\",\"password\":\"password5678\"}"};
for (unsigned i=0;i<sizeof(bad)/sizeof(*bad);++i) { account_begin(&admin,bad[i]); account_expect("400 Bad Request"); }
for (size_t i=0;i<strlen(password_body);++i) {
account_begin(&admin,password_body); req.content_len=aux.remaining_len=i;
account_expect("400 Bad Request");
}
size_t n=strlen(create_body); memcpy(body,create_body,n); memset(body+n,' ',768-n); body[768]=0;
receive_fragment=192; submit_account(&admin,body); execute_account();
receive_fragment=191; account_begin(&admin,body); account_expect("400 Bad Request");
receive_fragment=768; recv_fail=true; account_begin(&admin,password_body); account_expect("400 Bad Request"); recv_fail=false;
puts("PASS Accounts passwords: decoded quote/backslash/space/slash/Unicode escapes, printable ASCII 12..64, exact schemas, truncations, 768-byte/four-receive bound");
}
static void self_tests(void) {
const char *bodies[]={
"{\"action\":\"role\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2,\"role\":\"admin\"}",
"{\"action\":\"delete\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2}",
"{\"action\":\"password\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2,\"password\":\"password1234\"}"};
self_target=true;
for (unsigned i=0;i<3;++i) {
auth_reset(); issued_t admin=mint(&alice), sibling=mint(&alice), user=mint(&bob);
unsigned revokes=web_revokes;
mutation_error=ESP_ERR_INVALID_STATE; submit_account(&admin,bodies[i]); execute_account();
assert(s_operation.state==PROTECTED && web_revokes==revokes);
account_begin(&admin,NULL); account_expect("200 OK"); assert(strstr(output,"protected"));
mutation_error=ESP_OK; submit_account(&admin,bodies[i]); execute_account();
assert(s_operation.state==OK && web_revokes==revokes+1 && !executing_password);
account_begin(&admin,NULL); account_expect("401 Unauthorized"); assert(!strstr(output,"\"state\""));
account_begin(&sibling,NULL); account_expect("401 Unauthorized");
bool current=false; assert(web_session_store_check_principal(user.view.id,&user.view.principal,&current)==ESP_OK && current);
}
self_target=false;
puts("PASS Accounts self role/delete/password: protected failures keep login, success immediately target-revokes all logins, stale result reads denied, unrelated user survives");
}
static void account_settings_tests(void) {
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob), other=mint(&alice); receive_fragment=64;
account_begin(NULL,deletion); account_expect("401 Unauthorized");
account_begin(&user,deletion); account_expect("403 Forbidden");
for (unsigned mode=0;mode<8;++mode) {
account_begin(&admin,deletion);
if (mode==0) req.content_len=aux.remaining_len=769;
if (mode==1) req.uri="/api/settings/account-operation?x=1";
if (mode==2) req.method=HTTP_GET;
if (mode==3) add("X-CSRF-Token","duplicate");
if (mode==4) add("Origin","https://evil.example");
if (mode==5) add("Transfer-Encoding","chunked");
if (mode==6) add("Content-Type","text/plain");
if (mode==7) add("Sec-Fetch-Site","cross-site");
unsigned before=s_next_id; (void)web_account_settings_handler(&req);
assert(response_status[0]=='4' && s_next_id==before && !mutations);
}
puts("PASS Accounts security: current cookie/admin, body/query/framing/CSRF/Origin bounds");
const char *bad[]={"{}","[]","{\"action\":\"password\"}","{\"action\":\"delete\",\"action\":\"role\"}",
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":0,\"auth_generation\":2}",
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":4294967296,\"auth_generation\":2}",
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":07,\"auth_generation\":2}",
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7e0,\"auth_generation\":2}",
"{\"action\":\"delete\",\"username\":\"c\\u0061rol\",\"user_id\":7,\"auth_generation\":2}",
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"role\":\"user\"}"};
for (unsigned i=0;i<sizeof(bad)/sizeof(*bad);++i) { account_begin(&admin,bad[i]); account_expect("400 Bad Request"); }
for (size_t n=0;n<strlen(role_body);++n) { account_operation_t out={0}; assert(!parse(role_body,n,&out)); }
account_operation_t parsed={0}; assert(parse(role_body,strlen(role_body),&parsed));
receive_fragment=1; account_begin(&admin,deletion); account_expect("400 Bad Request"); receive_fragment=64;
puts("PASS Accounts strict bounded schema/parser and fragmented-body rejection");
account_begin(&admin,NULL); req.uri="/api/settings/accounts"; account_expect("200 OK");
assert(strstr(output,"account000000007") && !strstr(output,"password") && !strstr(output,"key"));
list_fail=true; account_begin(&admin,NULL); req.uri="/api/settings/accounts"; account_expect("503 Service Unavailable"); list_fail=false;
unsigned reads=lists; account_begin(&user,NULL); req.uri="/api/settings/accounts"; account_expect("403 Forbidden"); assert(lists==reads);
puts("PASS Accounts list: eight bounded public projections, zero mutation and unavailable/role isolation");
queue_fail=true; account_begin(&admin,deletion); account_expect("503 Service Unavailable"); queue_fail=false;
submit_account(&admin,deletion); uint32_t old=queued;
account_begin(&other,role_body); account_expect("503 Service Unavailable");
account_begin(&other,NULL); account_expect("200 OK"); assert(strstr(output,"\"id\":0"));
account_begin(&admin,NULL); account_expect("200 OK"); assert(strstr(output,"pending"));
execute_account(); assert(s_operation.state==OK && mutations==1 && web_revokes==1 && ssh_revokes==1);
execute_account(); assert(mutations==1);
submit_account(&other,role_body); dispatcher=true; web_account_settings_execute(old); web_account_settings_execute(0); dispatcher=false;
assert(mutations==1 && s_operation.state==PENDING); execute_account(); assert(mutations==2 && web_revokes==2);
account_begin(&admin,NULL); account_expect("200 OK"); assert(strstr(output,"\"id\":0"));
puts("PASS Accounts single pending slot, session isolation, stale queued IDs and target-only best-effort notifications");
const esp_err_t errors[]={ESP_FAIL,ESP_ERR_NOT_FOUND,ESP_ERR_INVALID_STATE};
const unsigned states[]={FAILED,STALE,PROTECTED};
for (unsigned i=0;i<3;++i) { mutation_error=errors[i]; submit_account(&admin,deletion); execute_account(); assert(s_operation.state==states[i] && web_revokes==2 && ssh_revokes==2); }
mutation_error=ESP_OK;
unsigned before=mutations; submit_account(&admin,deletion); s_operation.deadline=0; execute_account(); assert(s_operation.state==CANCELLED && mutations==before);
submit_account(&admin,deletion); web_session_store_invalidate(admin.view.id); execute_account(); assert(s_operation.state==CANCELLED && mutations==before);
submit_account(&other,deletion); mutation_hook=invalidate_actor; execute_account(); assert(s_operation.state==OK && mutations==before+1 && web_revokes==3);
zero(&s_operation.principal,sizeof(s_operation.principal)); zero(&s_operation.target,sizeof(s_operation.target));
auth_reset(); admin=mint(&alice); before=mutations;
submit_account(&admin,deletion); db_fail=true; execute_account(); db_fail=false;
assert(s_operation.state==CANCELLED && mutations==before);
admin=mint(&alice);
submit_account(&admin,deletion); stale_user=alice.user_id; execute_account(); stale_user=0;
assert(s_operation.state==CANCELLED && mutations==before);
puts("PASS Accounts execution failure/stale/protected results, dequeue cancellation, missed account revocation/DB failure and admitted-work completion after expiry");
credential_tests(); password_parser_tests(); generated_tests(); self_tests();
assert(wiped_passwords && wiped_bodies && wiped_generated && wiped_responses);
}
+15
View File
@@ -45,6 +45,15 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
admin = "--admin" in sys.argv
settings = "--settings" in sys.argv
serial_settings = "--serial-settings" in sys.argv
accounts = "--accounts" in sys.argv
if accounts:
HEADERS["esp_timer.h"] += """
#include <stdbool.h>
typedef void *esp_timer_handle_t;
typedef struct { void (*callback)(void *); const char *name; bool skip_unhandled_events; } esp_timer_create_args_t;
int esp_timer_create(const esp_timer_create_args_t *, esp_timer_handle_t *);
int esp_timer_start_periodic(esp_timer_handle_t, uint64_t);
"""
if admin:
HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n"
HEADERS["esp_heap_caps.h"] = """#pragma once
@@ -132,6 +141,11 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
settings_source += ' serial_service_counters_t serial_counters = {0};\n' + acquisition
settings_source += ' return snprintf(response, capacity,\n' + serial_format + ',\n' + serial_arguments + ');\n}\n'
(tmp / 'settings_production.h').write_text(settings_source)
if accounts:
db_source = (ROOT / 'src/user_database.c').read_text()
alphabet_start = db_source.index('static const uint8_t s_generated_alphabet')
alphabet = db_source[alphabet_start:db_source.index(';', alphabet_start) + 1]
(tmp / 'account_parse_production.h').write_text(alphabet + '\n' + '\n'.join(function(db_source, name) for name in ('user_database_username_valid', 'user_database_password_valid', 'user_role_parse', 'user_role_to_string', 'user_database_generate_password_value')))
if serial_settings:
config_source = (ROOT / 'src/serial_config.c').read_text()
names = ['serial_config_defaults', 'serial_config_validate']
@@ -155,6 +169,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
*(["-DHOST_ADMIN"] if admin else []),
*(["-DHOST_SETTINGS"] if settings else []),
*(["-DHOST_SERIAL_SETTINGS"] if serial_settings else []),
*(["-DHOST_ACCOUNTS"] if accounts else []),
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
"-o", str(tmp / "test")], check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=20)
+12
View File
@@ -1,7 +1,13 @@
/* Production store dependency doubles and its existing public API suite. */
#define main store_tests
#ifdef HOST_ACCOUNTS
#define user_database_username_valid store_username_valid
#endif
#include "../web_session_store/test.c"
#undef main
#ifdef HOST_ACCOUNTS
#undef user_database_username_valid
#endif
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#include "esp_httpd_priv.h"
@@ -126,6 +132,9 @@ static void auth_reset(void) {
#ifdef HOST_SERIAL_SETTINGS
#include "serial_settings_test.c"
#endif
#ifdef HOST_ACCOUNTS
#include "account_settings_test.c"
#endif
int main(void) {
assert(store_tests() == 0); auth_reset();
@@ -279,6 +288,9 @@ int main(void) {
#endif
#ifdef HOST_SERIAL_SETTINGS
serial_settings_tests();
#endif
#ifdef HOST_ACCOUNTS
account_settings_tests();
#endif
return 0;
}
+32 -6
View File
@@ -65,7 +65,24 @@ Coverage:
- Repeated current Settings selection is a no-op during submission, between and
during result checks, and during completion refresh: requests, timers, visible
values/control state, final outcome and socket/writer identity remain intact.
**35 Node groups total.**
- Accounts: admin-only strict eight-user/1,024-byte list, no secret/key fields,
confirmed identity-bound role/delete, automatic completion/list refresh,
ten-check limit/manual recovery, stale/protected/failure and lost-acknowledgement
handling, cancellation, and 401/identity isolation. The original 41 groups remain;
the former self-denial assertion now checks enabled self actions and confirmation cancellation.
- Second slice: exact create/password JSON and CSRF, 768-byte request ceiling,
untrimmed 1264 printable ASCII passwords including spaces/quotes/backslashes,
confirmation and username validation, separate bodyless generation without
mutation/list changes, strict 24-character base64url/96-byte generation response.
- Generated acknowledgement binds value and operation/target identity; edits,
regeneration, target/purpose changes reset it. 60-second lifetime, including
delayed timer admission checks; submission/cancel/failure and all lifecycle wipes.
Late headers/streamed bodies, concurrent reconnect and newer snapshots are fenced.
- Self password/role/delete warnings and POST/poll 401 close both routes without
success claims or proactive logout. Safe duplicate/full messages and no routine
secret outputs, storage, clipboard writes or history APIs.
**57 Node groups total**, plus renderer/HTML/CSP checks, reported PASS by the UI
continuation agent (four added beyond its earlier 53-group slice 2 run).
## Automatic result-check budget
@@ -91,7 +108,8 @@ existing 15-second per-request bound (session validation and snapshot GET are
separate requests). Settings remain visible but conflicting controls are disabled
during work; old snapshots are explicitly stale during pending/uncertain work or
a failed refresh. A successful refresh replaces the browser draft. Only Reset
asks for confirmation, specifically because it overwrites saved configuration.
asks for confirmation among Serial actions, specifically because it overwrites saved
configuration. Every Accounts mutation retains an explicit confirmation.
Tests use a deterministic clock and individually fired timer callbacks, including
callbacks invoked after cancellation and fetch/body doubles that ignore abort.
@@ -100,8 +118,10 @@ These deliberately exercise fences beyond normal browser cancellation behavior.
## Integration and known gaps
This covers 8D.3 session behavior, the 8D.6 selector, 8D.8 Settings and the 8D.9
Serial UI. Operation responses are fetch doubles, not end-to-end execution of
`web_serial_settings.c`, dispatcher work, serial reconfiguration or NVS persistence. The renderer
Serial UI and both 8D.10 Accounts slices. Operation/generation responses are fetch
doubles, not end-to-end execution of `web_serial_settings.c`,
`web_account_settings.c`, dispatcher work, credential generation/derivation,
serial reconfiguration or NVS persistence. The renderer
still relies on its caller to authenticate resources; protected asset failures
must be 401, never a redirect to HTML served as JavaScript. No Basic fallback is
implemented here. Existing 8D.5 server authorization/protocols are unchanged.
@@ -110,7 +130,13 @@ These tests model DOM, timers, fetch cancellation and WebSocket events. They do
not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior,
actual bfcache policy, cookie expiry, server revocation, or hardware serial byte
integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout.
Prior 8D.6 signoff stands; the new Settings build and pending target checklist are in
`docs/phase8d8_implementation.md`. No target resource reserve is claimed. Browser secret
Prior 8D.6 signoff stands; current slice 2 contracts and pending target checklist are in
`docs/phase8d10_implementation.md`. Implementation is complete, host-tested/build-verified,
not target accepted: parent build PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash.
The generated endpoint is independently optionally registered (23 handlers), with
route-agent lifecycle 15 PASS for failure isolation/restart. UI 57/CSP and lifecycle
15 results are agent-attributed, not claims of the parent's additional reruns.
Target/signoff and new timer runtime measurements remain open; no sanitizer,
device/assets/commit/8D.11 action or target resource reserve is claimed. Browser secret
references are dropped and never persisted/logged, but JavaScript cannot securely
wipe engine-managed strings.
+297 -4
View File
@@ -11,9 +11,9 @@ const serialSettings = (extra = {}) => ({running: true, baud: 230400, data_bits:
const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}});
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; };
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': []};
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': []};
const fits = [];
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
@@ -39,7 +39,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
location: {origin: 'https://sak.local', replace: path => redirects.push(path)}};
const context = vm.createContext({window, document: {getElementById(id) {
return nodes[id] ||= {textContent: '', dataset: {}, classList: {toggle() {}},
return nodes[id] ||= {textContent: '', value: '', checked: false, dataset: {}, classList: {toggle() {}},
setAttribute(k, v) { this[k] = v; },
getBoundingClientRect: () => ({width: 100, height: 100}),
addEventListener(k, fn) { this[k] = fn; }};
@@ -56,9 +56,10 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
calls.push({url, ...options});
const next = queues[url].shift();
if (next !== undefined) return typeof next === 'function' ? next(options) : next;
if (url === '/api/session') return session({role});
if (url === '/api/session') return session({role, username});
if (url === '/api/status') return json({});
if (url === '/api/settings/serial') return json(serialSettings());
if (url === '/api/settings/accounts') return json({users: [{username: 'alice', user_id: 1, auth_generation: 2, role: 'admin'}, {username: 'carol', user_id: 7, auth_generation: 2, role: 'user'}]});
if (url === '/api/ws-ticket') return ticket();
if (url === '/api/admin/ws-ticket') return json({ticket: '0123456789abcdef'.repeat(4), expires_in: 30});
throw new Error('network unavailable');
@@ -782,6 +783,94 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
assert.equal(b.nodes['setting-baud'].textContent, '230400');
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
});
async function accountsBrowser() {
const b = browser({role: 'admin', username: 'alice'}); b.start(); await tick(); b.sockets[0].emit('open');
b.click('select-admin'); b.click('admin-toggle'); await tick(); b.sockets[1].emit('open');
b.click('select-settings'); await tick(); b.click('settings-accounts'); await tick();
return b;
}
const accountPath = '/api/settings/account-operation';
const accountReply = (id, state, action = 'role') => json({id, state, action});
await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => {
const u = await connected(); u.click('settings-accounts'); await tick();
assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts'));
const b = await accountsBrowser();
assert.match(b.nodes['accounts-list'].textContent, /alice.*admin.*you/);
assert.match(b.nodes['accounts-list'].textContent, /carol.*user/);
assert.ok(!b.nodes['account-delete'].disabled);
const calls=b.calls.length; b.window.confirm=()=>false; b.click('account-delete'); await tick(); assert.equal(b.calls.length,calls); b.window.confirm=()=>true;
b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); assert.ok(!b.nodes['account-delete'].disabled);
for(let i=0;i<3;++i) { b.click('settings-serial'); await tick(); b.click('settings-accounts'); await tick(); }
assert.equal(b.sockets.length,2); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
for (const bad of [{users:[{username:'<img>',role:'user',user_id:1,auth_generation:1}]}, {users:Array(9).fill({})}, {users:[],password:'SECRET'}, {users:[{username:'safe',role:'admin',user_id:0,auth_generation:1}]}]) {
b.queues['/api/settings/accounts'].push(json(bad)); b.click('refresh-accounts'); await tick();
assert.match(b.nodes['accounts-detail'].textContent,/stale/); assert.ok(!b.nodes['accounts-list'].textContent.includes('SECRET'));
}
});
await test('Accounts role/delete confirmation, typed identity, automatic result and list refresh', async () => {
for (const action of ['role','delete']) {
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); b.nodes['account-role'].value='admin';
const button=action==='role'?'account-change-role':'account-delete', before=b.calls.length;
b.window.confirm=()=>false; b.click(button); await tick(); assert.equal(b.calls.length,before);
b.window.confirm=()=>true; b.queues[accountPath].push(accountReply(10,'pending',action)); b.click(button); await tick();
const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1);
assert.deepEqual(JSON.parse(posts[0].body),{action,username:'carol',user_id:7,auth_generation:2,...(action==='role'?{role:'admin'}:{})});
assert.equal(posts[0].headers['X-CSRF-Token'],token); assert.ok(b.nodes['account-target'].disabled);
for (const state of ['pending','ok']) { b.queues[accountPath].push(accountReply(10,state,action)); b.fire(1000); await tick(); }
assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/);
assert.match(b.nodes['accounts-detail'].textContent,/refreshed/);
assert.equal(b.calls.filter(c=>c.url==='/api/settings/accounts').length,2);
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
await test('Accounts bounded checks exhaust to manual recovery without POST retry', async () => {
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
b.queues[accountPath].push(accountReply(11,'pending')); b.click('account-change-role'); await tick();
for(let i=0;i<10;++i) { b.queues[accountPath].push(accountReply(11,'pending')); b.elapse(1000); b.fire(1000); await tick(); }
assert.match(b.nodes['account-operation-detail'].textContent,/stopped.*Check Result/); assert.ok(!b.nodes['account-result'].disabled);
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='GET').length,10);
b.queues[accountPath].push(accountReply(11,'ok')); b.click('account-result'); await tick();
assert.match(b.nodes['account-operation-detail'].textContent,/completed/);
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1);
});
await test('Accounts cancellation fences pending posts, checks and refreshes on domain/view/pagehide', async () => {
for (const mode of ['domain','view','pagehide']) {
const b=await accountsBrowser(), d=deferred(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
b.queues[accountPath].push(d.promise); b.click('account-change-role'); await tick();
const request=b.calls.filter(c=>c.url===accountPath).at(-1);
if(mode==='domain') b.click('settings-serial'); else if(mode==='view') b.click('select-serial'); else b.emit('pagehide');
assert.ok(request.signal.aborted); const detail=b.nodes['account-operation-detail'].textContent;
d.resolve(accountReply(12,'pending')); await tick(); assert.equal(b.nodes['account-operation-detail'].textContent,detail);
assert.equal(b.nodes['accounts-list'].textContent,'');
assert.ok(!b.calls.some(c=>c.url===accountPath && c.method==='GET'));
}
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
b.queues[accountPath].push(accountReply(13,'pending')); b.click('account-change-role'); await tick();
b.click('settings-serial'); await tick(); assert.ok(![...b.timers.values()].some(t=>t.ms===1000 || t.ms===15000));
});
await test('Accounts timeout, stale/protected/failed outcomes, failed refresh and unknown acknowledgement', async () => {
for(const state of ['stale','protected','failed','cancelled']) {
const b=await accountsBrowser(); b.queues[accountPath].push(accountReply(14,state)); b.queues['/api/settings/accounts'].push(failure(503));
b.click('account-result'); await tick(); assert.match(b.nodes['accounts-detail'].textContent,/stale/); assert.match(b.nodes['accounts-list'].textContent,/carol/);
}
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
b.queues[accountPath].push(()=>{throw new Error('lost');}); b.click('account-change-role'); await tick();
assert.match(b.nodes['account-operation-detail'].textContent,/unknown/);
for(let i=0;i<2;++i) { b.queues[accountPath].push(accountReply(15,'ok')); b.click('account-result'); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/Acknowledgement lost/); }
b.queues[accountPath].push(accountReply(16,'pending')); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin'; b.click('account-change-role'); await tick();
const d=deferred(); b.queues[accountPath].push(d.promise); b.fire(1000); await tick();
b.elapse(15000); b.fire(15000); await tick(); d.resolve(accountReply(16,'ok')); await tick();
assert.match(b.nodes['account-operation-detail'].textContent,/unknown/); assert.ok(!b.nodes['account-result'].disabled);
});
await test('Accounts 401 and identity changes close routes without adopting stale list', async () => {
for(const identity of [false,true]) {
const b=await accountsBrowser();
if(identity) b.queues['/api/session'].push(session({role:'admin',username:'replacement'}));
else b.queues['/api/settings/accounts'].push(failure(401));
b.click('refresh-accounts'); await tick(); assert.deepEqual(b.redirects,[identity?'/':'/login']);
assert.ok(b.sockets.every(s=>s.closed)); assert.equal(b.nodes['accounts-list'].textContent,'');
}
});
await test('Routine actions never confirm; Reset cancellation has no request or state change', async () => {
const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick();
const confirms = []; b.window.confirm = message => { confirms.push(message); return false; };
@@ -796,5 +885,209 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
assert.equal(confirms.length, 1); assert.match(confirms[0], /overwrites saved NVS configuration/);
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 6);
});
const generatePath = '/api/settings/accounts/generate-password', secret = 'Abcdefghijklmnopqrst_-12';
function draft(b, purpose = 'create', password = ' typed "pass\\word ') {
b.nodes['account-purpose'].value = purpose; b.nodes['account-purpose'].change();
if (purpose === 'create') { b.nodes['account-username'].value = 'new_account'; b.nodes['account-username'].input(); }
b.nodes['account-password'].value = b.nodes['account-password-confirm'].value = password;
}
function cleanSecret(b) {
for (const id of ['account-password','account-password-confirm','account-generated']) assert.equal(b.nodes[id].value, '', id);
assert.equal(b.nodes['account-password-saved'].checked, false); assert.ok(b.nodes['account-generated-panel'].hidden);
}
function noSecretOutput(b, value = secret) {
for (const [id, node] of Object.entries(b.nodes)) assert.ok(!node.textContent.includes(value), id);
}
async function generated(b) {
b.queues[generatePath].push(json({password: secret})); b.click('account-generate'); await tick();
assert.equal(b.nodes['account-generated'].value, secret);
b.nodes['account-password-confirm'].value = secret; b.nodes['account-password-confirm'].input();
b.nodes['account-password-saved'].checked = true; b.nodes['account-password-saved'].change();
}
await test('Create/password exact JSON, escaped printable ASCII and bounds, CSRF and isolated sockets', async () => {
for (const purpose of ['create','password']) for (const password of [' '.repeat(12), ' typed "pass\\word ', '\\'.repeat(64)]) {
const b = await accountsBrowser(); draft(b, purpose, password);
b.nodes['account-target'].value = '1'; b.nodes['account-create-role'].value = 'admin';
b.queues[accountPath].push(accountReply(20, 'pending', purpose)); b.click('account-submit-password'); cleanSecret(b); await tick();
const post = b.calls.find(c => c.url === accountPath && c.method === 'POST'); assert.ok(post);
assert.deepEqual(JSON.parse(post.body), purpose === 'create' ? {action:purpose, username:'new_account', role:'admin', password} : {action:purpose, username:'carol', user_id:7, auth_generation:2, password});
assert.ok(Buffer.byteLength(post.body) <= 768); assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
b.queues[accountPath].push(accountReply(20, 'ok', purpose)); b.fire(1000); await tick();
noSecretOutput(b, password); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
await test('Credential validation rejects short/long/non-ASCII/mismatch and invalid usernames; every attempt wipes', async () => {
for (const password of ['a'.repeat(11), 'a'.repeat(65), 'é'.repeat(12), 'abcde\nfghijklm', 'abcde\u007ffghijklm']) {
const b=await accountsBrowser(); draft(b, 'create', password); b.click('account-submit-password'); await tick(); cleanSecret(b);
assert.ok(!b.calls.some(c=>c.url===accountPath));
}
for (const name of ['', 'Aname', 'a'.repeat(17), 'a b', '<img>']) {
const b=await accountsBrowser(); draft(b); b.nodes['account-username'].value=name; b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
const b=await accountsBrowser(); draft(b); b.nodes['account-password-confirm'].value='different password'; b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
});
await test('Explicit generation is bodyless CSRF, bounded, separate from account list and mutation slot', async () => {
const b=await accountsBrowser(); draft(b); const listReads=b.calls.filter(c=>c.url==='/api/settings/accounts').length;
assert.ok(!b.calls.some(c=>c.url===generatePath)); await generated(b);
const post=b.calls.find(c=>c.url===generatePath); assert.equal(post.body,''); assert.equal(post.headers['X-CSRF-Token'],token); assert.equal(post.headers['Content-Type'],undefined);
assert.equal(b.calls.filter(c=>c.url==='/api/settings/accounts').length,listReads); assert.ok(!b.calls.some(c=>c.url===accountPath)); noSecretOutput(b);
assert.ok([...b.timers.values()].some(t=>t.ms===60000));
b.queues[accountPath].push(accountReply(21,'pending','create')); b.click('account-submit-password'); cleanSecret(b); await tick();
assert.equal(JSON.parse(b.calls.find(c=>c.url===accountPath).body).password,secret);
});
await test('Generated acknowledgement binds exact value and intent; edits and regeneration reset it', async () => {
for (const edit of ['unchecked','password','confirm','username','role','target','purpose','regenerate','silent-target','silent-password']) {
const b=await accountsBrowser(); draft(b); await generated(b);
if(edit==='unchecked') b.nodes['account-password-saved'].checked=false;
if(edit==='password') { b.nodes['account-password'].value='different password'; b.nodes['account-password'].input(); }
if(edit==='confirm') b.nodes['account-password-confirm'].input();
if(edit==='username') { b.nodes['account-username'].value='another'; b.nodes['account-username'].input(); }
if(edit==='role') { b.nodes['account-create-role'].value='admin'; b.nodes['account-create-role'].change(); }
if(edit==='target' || edit==='silent-target') { b.nodes['account-target'].value='1'; if(edit==='target') b.nodes['account-target'].change(); }
if(edit==='purpose') { b.nodes['account-purpose'].value='password'; b.nodes['account-purpose'].change(); }
if(edit==='regenerate') { b.queues[generatePath].push(json({password:secret})); b.click('account-generate'); await tick(); }
if(edit==='silent-password') b.nodes['account-password'].value=b.nodes['account-password-confirm'].value='different password';
if(!edit.startsWith('silent')) assert.equal(b.nodes['account-password-saved'].checked,false);
b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
});
await test('Generated lifetime wipes at 60 seconds and expired delayed timer cannot authorize submission', async () => {
for(const timer of [true,false]) {
const b=await accountsBrowser(); draft(b); await generated(b); b.elapse(60000);
if(timer) b.fire(60000); else b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
});
await test('All secret lifecycle cleanup: view/domain/target/purpose/refresh/pagehide/logout/identity/401', async () => {
for(const mode of ['view','domain','target','purpose','refresh','pagehide','logout','identity','401']) {
const b=await accountsBrowser(); draft(b); await generated(b);
if(mode==='view') b.click('select-serial');
if(mode==='domain') b.click('settings-serial');
if(mode==='target') { b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); }
if(mode==='purpose') { b.nodes['account-purpose'].value='password'; b.nodes['account-purpose'].change(); }
if(mode==='refresh') b.click('refresh-accounts');
if(mode==='pagehide') b.emit('pagehide');
if(mode==='logout') b.click('sign-out');
if(mode==='identity' || mode==='401') { b.queues['/api/session'].push(mode==='identity'?session({role:'admin',username:'newadmin'}):failure(401)); b.click('refresh-accounts'); }
await tick(); cleanSecret(b); noSecretOutput(b); assert.ok(![...b.timers.values()].some(t=>t.ms===60000));
}
});
await test('Late generation headers and streamed bodies cannot resurrect secrets after form cancellation', async () => {
for(const body of [false,true]) for(const mode of ['target','edit','purpose','domain','pagehide','logout','refresh']) {
const b=await accountsBrowser(); draft(b); const d=deferred(); let stream;
b.queues[generatePath].push(body?new Response(new ReadableStream({start(c){stream=c;}})):d.promise);
b.click('account-generate'); await tick(); const post=b.calls.find(c=>c.url===generatePath);
if(mode==='target') b.nodes['account-target'].change();
if(mode==='edit') b.nodes['account-password'].input();
if(mode==='purpose') b.nodes['account-purpose'].change();
if(mode==='domain') b.click('settings-serial');
if(mode==='pagehide') b.emit('pagehide');
if(mode==='logout') b.click('sign-out');
if(mode==='refresh') b.click('refresh-accounts');
assert.ok(post.signal.aborted);
if(body) { stream.enqueue(new TextEncoder().encode(JSON.stringify({password:secret}))); stream.close(); } else d.resolve(json({password:secret}));
await tick(); cleanSecret(b); noSecretOutput(b); assert.equal(b.calls.filter(c=>c.url===generatePath).length,1); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
});
await test('Generation errors/schema/96-byte overflow/timeouts never echo response or retry', async () => {
for(const response of [failure(503), failure(403), json({password:secret,extra:1}),json({password:'!'.repeat(24)}),json({password:'x'.repeat(25)}),new Response(' '.repeat(97)),json({password:secret+' '.repeat(100)})]) {
const b=await accountsBrowser(); draft(b); b.queues[generatePath].push(response); b.click('account-generate'); await tick(); cleanSecret(b); noSecretOutput(b); noSecretOutput(b,'SECRET ERROR BODY');
assert.match(b.nodes['account-secret-detail'].textContent,/Nothing applied/); assert.equal(b.calls.filter(c=>c.url===generatePath).length,1);
}
const b=await accountsBrowser(); draft(b); const d=deferred(); b.queues[generatePath].push(d.promise); b.click('account-generate'); await tick(); b.fire(15000); d.resolve(json({password:secret})); await tick(); cleanSecret(b);
});
await test('Generation timeout releases controls on fetch abort; explicit retry is isolated from serial/admin', async () => {
const b = await accountsBrowser(); draft(b);
b.queues[generatePath].push(options => new Promise((_, reject) => {
options.signal.addEventListener('abort', () => reject(new Error('SECRET timeout')), {once:true});
}));
b.click('account-generate'); await tick(); assert.ok(b.nodes['account-generate'].disabled);
b.fire(15000); await tick(); cleanSecret(b); noSecretOutput(b, 'SECRET timeout');
assert.ok(!b.nodes['account-generate'].disabled && !b.nodes['account-submit-password'].disabled);
assert.match(b.nodes['account-secret-detail'].textContent, /Nothing applied/);
assert.equal(b.calls.filter(c => c.url === generatePath).length, 1);
assert.ok(!b.calls.some(c => c.url === accountPath));
await generated(b);
assert.equal(b.calls.filter(c => c.url === generatePath).length, 2);
assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
});
await test('Generation endpoint 401 wipes secrets and closes both routes once without a mutation', async () => {
const b = await accountsBrowser(); draft(b); await generated(b);
b.queues[generatePath].push(failure(401)); b.click('account-generate'); await tick();
cleanSecret(b); noSecretOutput(b); noSecretOutput(b, 'SECRET ERROR BODY');
assert.deepEqual(b.redirects, ['/login']); assert.ok(b.sockets.every(s => s.closed));
assert.equal(b.timers.size, 0); assert.ok(!b.calls.some(c => c.url === accountPath));
b.window.sakSessionExpired(); assert.deepEqual(b.redirects, ['/login']);
});
await test('Credential pre-submit session cancellation fences late responses and never sends the password', async () => {
for (const purpose of ['create', 'password']) for (const mode of ['view', 'pagehide', 'identity', '401']) {
const b = await accountsBrowser(); draft(b, purpose); await generated(b);
const pending = deferred(); b.queues['/api/session'].push(pending.promise);
b.click('account-submit-password'); cleanSecret(b); await tick();
const check = b.calls.filter(c => c.url === '/api/session').at(-1);
if (mode === 'view') b.click('select-serial');
if (mode === 'pagehide') b.emit('pagehide');
pending.resolve(mode === 'identity' ? session({role:'admin', username:'replacement'}) :
mode === '401' ? failure(401) : session({role:'admin', username:'alice'}));
await tick(); cleanSecret(b); noSecretOutput(b);
assert.ok(check.signal.aborted); assert.ok(!b.calls.some(c => c.url === accountPath));
assert.deepEqual(b.redirects, mode === 'identity' ? ['/'] : mode === '401' ? ['/login'] : []);
assert.ok(b.sockets.every(s => mode === 'view' ? !s.closed : s.closed));
}
});
await test('Self password/role/delete confirmation, no preemptive logout; 401 closes without success claim', async () => {
for(const action of ['password','role','delete']) for(const stage of ['post','poll']) {
const b=await accountsBrowser(); let warning=''; b.window.confirm=m=>{warning=m;return true;};
if(action==='password') { draft(b,'password'); await generated(b); }
const button=action==='password'?'account-submit-password':action==='role'?'account-change-role':'account-delete';
b.queues[accountPath].push(stage==='post'?failure(401):accountReply(25,'pending',action)); b.click(button); cleanSecret(b);
assert.ok(b.sockets.every(s=>!s.closed)); await tick();
assert.match(warning,/ALL.*web\/SSH.*browser serial\/admin/); assert.match(warning,/401.*NOT proof/); assert.match(warning,/even a no-op role/); assert.ok(!warning.includes(secret));
if(stage==='poll') { assert.ok(b.sockets.every(s=>!s.closed)); b.queues['/api/session'].push(failure(401)); b.fire(1000); await tick(); }
assert.deepEqual(b.redirects,['/login']); assert.ok(b.sockets.every(s=>s.closed)); cleanSecret(b); noSecretOutput(b);
assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/completed and saved/); assert.ok(!b.calls.some(c=>c.url==='/api/logout'));
}
});
await test('Cancelled confirmation and failed credential POST wipe; duplicate/full safe result messages', async () => {
for(const cancel of [true,false]) {
const b=await accountsBrowser(); draft(b); await generated(b); b.window.confirm=()=>!cancel;
b.queues[accountPath].push(failure(503)); b.click('account-submit-password'); cleanSecret(b); await tick(); noSecretOutput(b); assert.equal(b.calls.filter(c=>c.url===accountPath).length,cancel?0:1);
}
for(const state of ['duplicate','full']) { const b=await accountsBrowser(); b.queues[accountPath].push(accountReply(27,state,'create')); b.click('account-result'); await tick(); assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/undefined|unknown/); }
});
await test('Rejected self mutations retain both routes and lease without logout or success claims', async () => {
for (const action of ['password', 'role', 'delete']) for (const outcome of [403, 'protected', 'failed', 'stale']) {
const b = await accountsBrowser();
b.sockets[0].emit('message', {data:JSON.stringify({type:'hello', clientId:8, writerId:8, role:'writer'})});
if (action === 'password') { draft(b, 'password'); await generated(b); }
b.queues[accountPath].push(outcome === 403 ? failure(403) : accountReply(28, 'pending', action));
b.click(action === 'password' ? 'account-submit-password' : action === 'role' ? 'account-change-role' : 'account-delete');
await tick();
if (outcome !== 403) { b.queues[accountPath].push(accountReply(28, outcome, action)); b.fire(1000); await tick(); }
cleanSecret(b); noSecretOutput(b); assert.deepEqual(b.redirects, []);
assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
assert.equal(b.nodes['writer-id'].textContent, '8'); assert.equal(b.nodes['release-control'].disabled, false);
assert.doesNotMatch(b.nodes['account-operation-detail'].textContent, /completed and saved/);
assert.ok(!b.calls.some(c => c.url === '/api/logout'));
assert.equal(b.calls.filter(c => c.url === accountPath && c.method === 'POST').length, 1);
}
});
await test('Generation session checks cannot strand reconnect; newer admission fences old generation', async () => {
const b=await accountsBrowser(); draft(b); const old=deferred(); b.queues['/api/session'].push(old.promise);
b.click('account-generate'); await tick(); b.click('connection-toggle'); b.click('connection-toggle'); await tick();
old.resolve(session({role:'admin',username:'alice'})); await tick();
assert.equal(b.sockets.length,3); assert.ok(!b.sockets[1].closed); assert.ok(!b.calls.some(c=>c.url===generatePath)); cleanSecret(b);
await generated(b); assert.equal(b.nodes['account-generated'].value,secret);
const c=await accountsBrowser(); draft(c); const reconnect=deferred(); c.click('connection-toggle'); c.queues['/api/session'].push(reconnect.promise); c.click('connection-toggle'); await tick();
await generated(c); reconnect.resolve(session({role:'admin',username:'alice'})); await tick(); assert.equal(c.sockets.length,3); assert.equal(c.nodes['account-generated'].value,secret);
});
await test('Old generation/refresh responses cannot overwrite new target snapshot or generated value', async () => {
const b=await accountsBrowser(); draft(b); const old=deferred(); b.queues[generatePath].push(old.promise); b.click('account-generate'); await tick();
b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); await generated(b);
old.resolve(json({password:'x'.repeat(24)})); await tick(); assert.equal(b.nodes['account-generated'].value,secret); assert.equal(b.nodes['account-password-saved'].checked,true);
const stale=deferred(); b.queues['/api/settings/accounts'].push(stale.promise); b.click('refresh-accounts'); await tick(); cleanSecret(b);
b.click('settings-serial'); await tick(); b.click('settings-accounts'); await tick(); draft(b); await generated(b);
stale.resolve(json({users:[{username:'replaced',role:'user',user_id:90,auth_generation:99}]})); await tick();
assert.match(b.nodes['accounts-list'].textContent,/alice/); assert.doesNotMatch(b.nodes['accounts-list'].textContent,/replaced/); assert.equal(b.nodes['account-generated'].value,secret);
});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });
+7 -1
View File
@@ -78,7 +78,13 @@ esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
assert (rendered['html'].index('id="terminal-title"') <
rendered['html'].index('id="admin-toggle"') <
rendered['html'].index('id="terminal-selector"'))
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization'):
for field in ('account-password', 'account-password-confirm'):
assert re.search(r'id="' + field + r'" type="password" maxlength="64" autocomplete="new-password"', rendered['html'])
assert 'id="account-generated" readonly autocomplete="off"' in rendered['html']
assert 'id="account-password-saved" type="checkbox"' in rendered['html']
assert 'not applied yet' in rendered['html'] and 'no retrieval' in rendered['html']
assert 'JavaScript cannot securely zero strings' in rendered['html']
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization', 'clipboard', 'pushState', 'replaceState'):
assert forbidden not in rendered['script'] + rendered['loader'], forbidden
(tmp / 'rendered.json').write_text(json.dumps(rendered))
subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30)