From fe1e2d98b40b6d8b1ea5f30ddaa2ca25eec9fc18 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Mon, 7 Sep 2026 10:03:45 +0200 Subject: [PATCH] Enable bounded browser account administration for Phase 8D.7 Allow other-account add/password and forced delete/role commands through shared dispatcher and handler policy. Keep self-target, generated-secret, key, bootstrap, and recovery workflows blocked. Revalidate currentness after password prompts and before database API admission. Document that admitted mutations may finish after disconnect, while subsequent stale operations must reject. Add policy, transaction-failure, cleanup, and targeted-revocation regressions. Record completed review, passing host tests and firmware build, with target validation and M2 acceptance still pending. --- docs/agent/architecture.md | 2 + docs/agent/code-map.md | 2 + docs/agent/current-state.md | 2 + docs/phase8d7_implementation.md | 31 +++- src/admin_ssh_console.c | 40 ++++- src/admin_ssh_console.h | 6 + src/user_console.c | 30 +++- tests/admin_console_boundary/accounts.c | 202 +++++++++++++++++++++++ tests/admin_console_boundary/accounts.py | 166 +++++++++++++++++++ tests/admin_console_boundary/test.c | 36 ++++ tests/admin_ssh_policy/run.py | 29 +++- 11 files changed, 533 insertions(+), 13 deletions(-) create mode 100644 tests/admin_console_boundary/accounts.c create mode 100644 tests/admin_console_boundary/accounts.py diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index d39265b..97e23f8 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -175,6 +175,8 @@ The first 8D.7 slice enables browser-admin reboot and HTTPS stop through the sam The second 8D.7 slice additionally permits exact parsed browser `web certificate rotate --force`. The request queue has a typed command-line/deferred-action union with unchanged capacity. An immutable owner `dispatcher_actions` mask sends certificate work, after the bounded drain and 200 ms delay, nonblockingly to the existing 12 KiB dispatcher rather than the 4 KiB control stack; zero mask retains SSH control-task behavior. Pending input remains gated through queueing/execution. Dispatcher token/principal/session/owner revalidation and an executing-slot reservation prevent stale execution or reuse during self-detach; WEB validates currentness again before lifecycle APIs. Transactional certificate generation/persistence commits before stop → start; generation/commit error skips lifecycle calls, stop error skips start and retains HTTPD ownership, and later lifecycle failure does not roll back committed material. HTTPD alone owns socket IO. Successful restart invalidates browser sessions and both routes; certificate trust and login must be renewed, while USB/UART0/SSH remain available. Account/legacy-credential/network/restricted SSH mutations remain blocked. No new tasks, depths, routes, assets or stack sizes. Drain/acknowledgement bounds are not execution deadlines or delivery guarantees; owner-mask/local-scratch target layout and control/dispatcher stack margins remain unmeasured (host sizeof is not target proof). +The third 8D.7 slice permits browser other-account interactive add/password and forced delete/role mutations, with shared parsed policy at dispatcher admission and canonical-handler defense. Self-target, generated-secret, key, bootstrap and recovery workflows remain blocked. Account/owner/session/token currentness is checked after password prompts and before database API operation admission. This is operation-admission currentness, not an atomic liveness/NVS-commit guarantee: an admitted derivation/mutation may finish and target-revoke after disconnect or expiry; subsequent stale operations reject. Reconnecting administrators must inspect uncertain account outcomes rather than assume cancellation. Existing transaction cleanup, account invariants and best-effort targeted notifications remain unchanged. + ## Wi-Fi and persistence `wifi_config` owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy `off`, `fallback`, or `always`. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used. diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 87fd186..d4ec84a 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -77,6 +77,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the ### Browser admin backend (8D.5) +- 8D.7 third account slice: `admin_ssh_console` shares parsed browser other-account policy with `user_console`; interactive add/password and forced delete/role now allowed, self/generated/key/bootstrap/recovery still blocked. Post-prompt/pre-DB-API currentness is operation admission, not cancellation of admitted derivation/commit. Existing target-only notifications follow success. Review has no actionable findings; `python3 tests/admin_console_boundary/accounts.py` adds deterministic handler/database failure and stale-next-operation regressions. Target/M2 pending; see `docs/phase8d7_implementation.md`. + - 8D.7 second slice: exact parsed browser `web certificate rotate --force`; `admin_ssh_console.{c,h}` supplies the typed request union/owner `dispatcher_actions` mask, bounded drain/200 ms handoff to the existing 12 KiB dispatcher, persistent pending gate and revalidated executing-slot reservation. `web_console.c` schedules; `web_admin_transport.c` revalidates then calls transactional `web_security_rotate_certificate()` → `web_server_stop()` → `web_server_start()`, short-circuiting errors and retaining ownership on failed stop. SSH/UART0 unchanged. No tasks/depth/routes/assets/stacks added; target stack margins unknown. Boundary `run.py` includes `certificate.c`; lifecycle/policy and transport 25/tickets 12 host groups pass as reported. Credential/account then other owner slices remain; user authorized stacking, not target/M2 sign-off. See `docs/phase8d7_implementation.md`. - 8D.7 first-slice history: browser `reboot`/`web stop` defer via `admin_ssh_console` control task; WEB owner revalidates cookie/principal/token before lifecycle calls. Pending console input is discarded (incoming-frame disposition latched before receive). `web_console.c` defers stop only for browser origin; other restrictions remain. Tests additionally include `python3 tests/admin_console_boundary/lifecycle.py`; handoff: `docs/phase8d7_implementation.md`. No 8D.7/M2 acceptance yet. diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 63ca29d..237e36e 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **8D.7 third account slice review completed (2026-09-07), target pending:** Inherited uncommitted code/tests enable browser other-account interactive add/password and forced delete/role; self/generated/key/bootstrap/recovery remain blocked. Independent production/test reviews found no actionable findings; prior precommit-liveness finding withdrawn. Contract is post-prompt, pre-database-API operation-admission currentness: admitted work may commit and target-revoke after disconnect/expiry, subsequent stale work rejects. Documentation now records uncertain-result recovery and deterministic-test limits. Boundary/accounts/policy (review agent), lifecycle, transport 25/tickets 12 and store/serial (parent) pass. Parent incremental `pio run` PASS 3.35 s, **95,580 B RAM / 1,648,577 B flash**, 0/+516 B versus certificate slice. No production edits in continuation, device operation, sanitizer pass, asset regeneration or commit. See `docs/phase8d7_implementation.md`. Supersedes older all-account-blocked/next-account notes; remaining owner/credential parity, target checks, numeric reserves and 8D.7/M2 acceptance remain pending. No next slice started. + - **8D.7 second certificate slice implemented / host-tested / build-verified, target pending:** Exact parsed browser `web certificate rotate --force` is enabled. Typed deferred-request union preserves queue capacity; immutable owner `dispatcher_actions` transfers certificate work after bounded drain/200 ms delay to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending-input gate survives handoff; token/principal/session revalidation and executing-slot reservation fence stale work/self-detach reuse. WEB uses transactional certificate commit → stop → start, returning on early errors and retaining ownership on failed stop. SSH/UART0 unchanged; other account/network/credential/SSH mutations remain blocked. No new tasks/depth/routes/assets/stacks. Parent final `pio run` PASS **26.32 s, 95,580 B RAM / 1,648,061 B flash**: **0 / +1,036 B** vs first slice, **0 / +1,572 B** vs 8D.6, **+1,048 / +48,088 B** vs 8D.0. Parent final rerun passes boundary (`run.py` including `certificate.c`), lifecycle, policy, transport **25**/tickets **12**, server **11**, cookie/admin, store/serial, UI **17** plus renderer/CSP and diff checks; independent reviewer has no actionable findings. Sanitizers unavailable (missing libasan/libubsan); no hardware validation. Owner mask/local scratch and target stack margins remain unmeasured; host sizeof is not target proof. Drain/acknowledgement is bounded, not execution latency or peer receipt. **User explicitly authorized continuing to stack the next bounded slice, not target sign-off. Next: credential/account, then other owner slices; 8D.7/M2 acceptance and numeric reserves pending.** See `docs/phase8d7_implementation.md` for separate slice histories and certificate force/trust/relogin/USB/SSH/failure checklist. Supersedes the first-slice next-step/continuation-pending instruction below. Production/tests/docs updated and parent build/regressions executed; no device operation, asset regeneration or commit. - **8D.7 first increment implemented / host-tested / build-verified (2026-09-06), target pending:** User requests starting 8D.7. Scope split enables only browser `reboot` and `web stop` through existing bounded deferred control; WEB revalidates session/principal/token before lifecycle APIs. Adds browser-origin query and pending snapshot flag; HTTPD discards deferred input, latching disposition before receive. Review fixed second-frame-before-poll and cancellation-during-receive races; final reviewer confirms correction. Canonical handler/console/policy, transport **23**/tickets **12**, lifecycle **11**, cookie/admin, store/serial and UI **17** suites pass. Final `pio run` **12.44 s, 95,580 B RAM / 1,647,025 B flash**, **0 / +536 B** vs 8D.6. No tasks/routes/capacity/assets/UI/credential changes or device operations. `docs/phase8d7_implementation.md` holds behavior, restrictions, validation limits/resources and disruptive target checklist. **8D.7/M2 incomplete; next slice remains safe HTTPS identity/certificate handling**, then remaining owner/account restrictions. Obtain target regression or explicit user decision before stacking runtime changes. `web stop` intentionally closes both browser routes; restart via UART0/admin SSH. Prior 8D.6 sign-off stands; internal/DMA minima **6,516 / 1,580 B** and numeric reserves remain follow-ups. diff --git a/docs/phase8d7_implementation.md b/docs/phase8d7_implementation.md index fc5d941..6e3763b 100644 --- a/docs/phase8d7_implementation.md +++ b/docs/phase8d7_implementation.md @@ -1,6 +1,35 @@ # Phase 8D.7 — Web-shell lifecycle parity and M2 acceptance -## Current status — second bounded certificate slice +## Current status: third bounded account slice + +**Account slice reviewed / host-tested / build-verified (2026-09-07); no actionable findings remain. Target validation and 8D.7/M2 acceptance remain pending.** This supersedes the account restrictions and next-slice statements in the historical sections below, not their recorded validation limits. + +Browser admins may now operate on other accounts with exact parsed `user add user|admin`, `user password `, `user delete --force`, and `user role user|admin --force`. Add/password use hidden password and confirmation prompts. Dispatcher policy and the canonical handler both enforce this bounded allowlist. Self-target mutations (even no-op role changes), generated-password output, key workflows, bootstrap and recovery remain blocked for browsers. Existing inspection is unchanged. Database final-admin and migrated-account protections remain authoritative. + +### Operation-admission currentness + +The guarantee is **operation-admission currentness**, not currentness at commit. Account, originating owner/session and console token are checked after password prompts and before admitting the database API operation; delete/role also revalidate before API admission. Queued or subsequent stale operations must reject. Disconnect, logout, expiry or revocation during password derivation does not cancel or roll back an already admitted operation. Such an operation may persist and request target-only web/SSH revocation after the initiating browser is gone. Notification failure does not undo a committed mutation, and completion output is not a receipt guarantee. + +After a disconnect with an uncertain result, reconnect using a current administrator and inspect `user list` / `user show ` before retrying. Password values cannot be inspected; verify the intended login outcome or deliberately reset the target password rather than assuming cancellation. Do not expose credentials in status or logs. + +### Review and verification + +Independent production and regression reviews found no actionable defects. The earlier proposed precommit-currentness finding was withdrawn as inconsistent with the existing executing-handler contract. No production changes were needed during this review continuation; inherited changes were preserved. + +Passed in this continuation: `python3 tests/admin_console_boundary/accounts.py`, `python3 tests/admin_console_boundary/run.py`, `python3 tests/admin_ssh_policy/run.py` (review agent); `python3 tests/admin_console_boundary/lifecycle.py`, `python3 tests/web_admin_transport/run.py --tickets` (25 transport / 12 ticket groups), and `python3 tests/web_session_store/run.py --serial` (parent). Account regressions cover admitted derivation invalidation, successful commits and target-only notification arguments, NVS open/write/commit failures, candidate cleanup, and rejection of the next stale operation. + +These are deterministic handler/database and separate console/transport boundary tests, not real HTTPD disconnect/expiry, scheduler or end-to-end socket tests. Account tests double currentness, prompts, crypto, NVS and notification APIs; they do not independently prove live connection isolation. No device or sanitizer validation was performed. + +`pio run` passed (incremental, 3.35 s): **95,580 B linked RAM / 1,648,577 B flash**, **0 / +516 B** versus the recorded certificate slice, **0 / +2,088 B** versus 8D.6, **+1,048 / +48,604 B** versus 8D.0. No new task, queue depth, route, socket capacity, assets or stack size; runtime reserves and stack margins remain unmeasured. + +### Pending account target checks + +1. Exercise each enabled other-account command, hidden confirmation/mismatch/cancel, and self/generated/key/bootstrap/recovery rejection. Verify final-admin protections and unchanged UART0/admin SSH behavior. +2. Confirm successful target mutations revoke only that account's web/SSH sessions, retaining unrelated browser serial/admin, USB and SSH traffic. Check stale queued commands and prompts after logout/expiry/revocation and slot reuse. +3. Where safely injectable, disconnect during admitted derivation and inspect the resulting account state after reconnect; do not expect cancellation. Check persistence failure and retry recovery without logging secrets. Unavailable timing/failure injection remains unexecuted. +4. Run the lifecycle/full-mix/soak/resource checklist below as well. Remaining self/generated/key/legacy-credential and other owner-specific parity work stays blocked until separately implemented; this review does not close M2 or start another slice. + +## Second bounded certificate slice history **Second slice implemented / host-tested / build-verified; independent reviewer reports no actionable findings. No hardware validation or 8D.7/M2 acceptance.** The user explicitly authorized continuing to stack the next bounded slice; this is a continuation decision, not target sign-off. Next comes credential/account handling, then other owner-specific slices. Prior 8D.6 sign-off stands; numeric reserves remain open. diff --git a/src/admin_ssh_console.c b/src/admin_ssh_console.c index 15e78ca..678a24c 100644 --- a/src/admin_ssh_console.c +++ b/src/admin_ssh_console.c @@ -183,6 +183,12 @@ static bool session_is_current(const admin_ssh_console_token_t *token, return matched && current; } +bool admin_ssh_console_dispatch_is_current(void) +{ + return xTaskGetCurrentTaskHandle() == s_task && + (!s_dispatch_remote || session_is_current(&s_dispatch_token, &s_dispatch_principal)); +} + static bool append_output_locked(admin_session_t *session, const uint8_t *data, size_t length) { @@ -488,6 +494,34 @@ static int ssh_output_write(void *cookie, const char *buffer, int length) return length; } +bool admin_ssh_console_web_user_command_allowed( + size_t argc, char **argv, const user_principal_t *principal) +{ + if (argc == 0U || strcmp(argv[0], "user") != 0) return false; + if (argc == 1U || + (argc == 2U && (strcmp(argv[1], "status") == 0 || + strcmp(argv[1], "list") == 0)) || + (argc == 3U && strcmp(argv[1], "show") == 0)) return true; + + bool role_valid = argc >= 4U && + (strcmp(argv[3], "user") == 0 || strcmp(argv[3], "admin") == 0); + bool mutation = + (argc == 4U && strcmp(argv[1], "add") == 0 && role_valid) || + (argc == 3U && strcmp(argv[1], "password") == 0) || + (argc == 4U && strcmp(argv[1], "delete") == 0 && + strcmp(argv[3], "--force") == 0) || + (argc == 5U && strcmp(argv[1], "role") == 0 && role_valid && + strcmp(argv[4], "--force") == 0); + /* Parsed names use the database's exact, case-sensitive identity. Reject + * self even for no-op role changes; their handler still requests revocation. + * Generation/output and key workflows remain outside this bounded slice. */ + return mutation && principal != NULL && principal->role == USER_ROLE_ADMIN && + principal->username_length > 0U && + principal->username_length <= USER_DATABASE_USERNAME_CAPACITY && + !(strlen(argv[2]) == principal->username_length && + memcmp(argv[2], principal->username, principal->username_length) == 0); +} + static bool remote_command_allowed(const admin_request_t *request) { char copy[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; @@ -514,10 +548,8 @@ static bool remote_command_allowed(const admin_request_t *request) } else if (strcmp(argv[0], "wifi") == 0 || strcmp(argv[0], "mdns") == 0) { allowed = argc == 2U && strcmp(argv[1], "status") == 0; } else if (strcmp(argv[0], "user") == 0) { - allowed = argc == 1U || - (argc == 2U && (strcmp(argv[1], "status") == 0 || - strcmp(argv[1], "list") == 0)) || - (argc == 3U && strcmp(argv[1], "show") == 0); + allowed = admin_ssh_console_web_user_command_allowed( + argc, argv, &request->principal); } else if (strcmp(argv[0], "reboot") == 0) { allowed = argc == 1U; } else if (strcmp(argv[0], "ssh") == 0 && argc >= 2U) { diff --git a/src/admin_ssh_console.h b/src/admin_ssh_console.h index 80e0389..0ee5dfa 100644 --- a/src/admin_ssh_console.h +++ b/src/admin_ssh_console.h @@ -121,6 +121,12 @@ esp_err_t admin_ssh_console_start_uart_frontend(void); bool admin_ssh_console_dispatch_is_remote(void); bool admin_ssh_console_dispatch_is_web(void); const user_principal_t *admin_ssh_console_dispatch_principal(void); +/* Revalidate account, originating owner/session and token before side effects. + * False outside the dispatcher; UART0 dispatch remains physically trusted. */ +bool admin_ssh_console_dispatch_is_current(void); +/* Shared parsed browser account policy: dispatcher admission + handler defense. */ +bool admin_ssh_console_web_user_command_allowed( + size_t argc, char **argv, const user_principal_t *principal); esp_err_t admin_ssh_console_dispatch_read_input( const char *prompt, uint8_t *output, size_t capacity, bool hidden, size_t *output_length); diff --git a/src/user_console.c b/src/user_console.c index 87ba230..95754c4 100644 --- a/src/user_console.c +++ b/src/user_console.c @@ -219,6 +219,15 @@ static int bootstrap(bool generated) return 0; } +static esp_err_t mutation_currentness(void) +{ + if (admin_ssh_console_dispatch_is_remote() && + !admin_ssh_console_dispatch_is_current()) { + return ESP_ERR_NOT_ALLOWED; + } + return ESP_OK; +} + static int add_user(const char *username, const char *role_text, bool generated) { user_role_t role; @@ -239,6 +248,7 @@ static int add_user(const char *username, const char *role_text, bool generated) uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0}; size_t password_length = 0U; error = read_password(password, &password_length); + if (error == ESP_OK) error = mutation_currentness(); if (error == ESP_OK) { error = user_database_create((const uint8_t *)username, strlen(username), role, password, password_length); @@ -268,6 +278,7 @@ static int change_password(const char *username, bool generated) uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0}; size_t password_length = 0U; error = read_password(password, &password_length); + if (error == ESP_OK) error = mutation_currentness(); if (error == ESP_OK) { error = user_database_set_password((const uint8_t *)username, strlen(username), @@ -388,6 +399,14 @@ static int command_user_inner(int argc, char **argv) { bool remote = admin_ssh_console_dispatch_is_remote(); const user_principal_t *principal = admin_ssh_console_dispatch_principal(); + /* Repeat admission on canonical parsed arguments: direct handler calls must + * not bypass self-target, generated-secret or UART0-only restrictions. */ + if (admin_ssh_console_dispatch_is_web() && + (!admin_ssh_console_web_user_command_allowed((size_t)argc, argv, principal) || + !admin_ssh_console_dispatch_is_current())) { + printf("Browser account command restricted or session no longer current.\n"); + return 1; + } if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0) || (argc == 2 && strcmp(argv[1], "list") == 0)) { return show_users(NULL); @@ -425,7 +444,10 @@ static int command_user_inner(int argc, char **argv) } if (argc == 4 && strcmp(argv[1], "delete") == 0 && strcmp(argv[3], "--force") == 0) { - esp_err_t error = user_database_delete((const uint8_t *)argv[2], strlen(argv[2])); + esp_err_t error = mutation_currentness(); + if (error == ESP_OK) { + error = user_database_delete((const uint8_t *)argv[2], strlen(argv[2])); + } if (error != ESP_OK) { printf("Could not delete user (the migrated or final admin is protected): %s\n", esp_err_to_name(error)); @@ -443,8 +465,10 @@ static int command_user_inner(int argc, char **argv) printf("Role must be user or admin.\n"); return 1; } - esp_err_t error = user_database_set_role((const uint8_t *)argv[2], - strlen(argv[2]), role); + esp_err_t error = mutation_currentness(); + if (error == ESP_OK) { + error = user_database_set_role((const uint8_t *)argv[2], strlen(argv[2]), role); + } if (error != ESP_OK) { printf("Could not change role (the final admin is protected): %s\n", esp_err_to_name(error)); diff --git a/tests/admin_console_boundary/accounts.c b/tests/admin_console_boundary/accounts.c new file mode 100644 index 0000000..c5b3b34 --- /dev/null +++ b/tests/admin_console_boundary/accounts.c @@ -0,0 +1,202 @@ +/* Included by accounts.py after extracted production functions. */ +static void reset(void) +{ + memset(&s_database, 0, sizeof(s_database)); + memset(&candidate_storage, 0, sizeof(candidate_storage)); + s_candidate=&candidate_storage; s_mutex=(void *)1; s_initialized=true; + s_database.version=USER_DATABASE_SCHEMA_VERSION; + s_database.size=sizeof(s_database); s_database.generation=1; + s_database.admin_bootstrapped=1; + fail_stage=0; invalidate_during_derivation=false; derivation_invalidations=0; + assert(initialize_user(&s_database.users[0], (const uint8_t *)"admin", 5, + USER_ROLE_ADMIN, (const uint8_t *)"test-password", 13)==ESP_OK); + assert(initialize_user(&s_database.users[1], (const uint8_t *)"other", 5, + USER_ROLE_USER, (const uint8_t *)"test-password", 13)==ESP_OK); + assert(initialize_user(&s_database.users[2], (const uint8_t *)"observer", 8, + USER_ROLE_USER, (const uint8_t *)"test-password", 13)==ESP_OK); + recount(&s_database); + assert(validate_database(&s_database)==ESP_OK); + actor=(user_principal_t){.role=USER_ROLE_ADMIN, .username_length=5, + .username="admin", .user_id=s_database.users[0].user_id, + .auth_generation=s_database.users[0].auth_generation}; + writes=commits=prompts=checks=web_revokes=ssh_revokes=0; + revoke_prompt=revoke_check=0; owner_current=true; remote=web=true; + mismatch=cancel_prompt=stale_prompt=false; notify_error=ESP_OK; + memset(revoked_name,0,sizeof(revoked_name)); +} +static int run(const char *line) +{ + char copy[257]; char *argv[10]={0}; + assert(strlen(line)=0 && s_database.users[fresh].role==(i==0 ? USER_ROLE_USER : USER_ROLE_ADMIN)); + } else if (i==3) { + assert(find_user(&s_database,(const uint8_t *)"other",5)<0); + } else if (i!=6) { + assert(s_database.users[1].auth_generation==before.users[1].auth_generation+1); + } else { + assert(!writes && !commits); /* Existing no-op role still revokes target. */ + } + assert(all_zero(s_candidate,sizeof(*s_candidate))); + assert(!locks); + } + const char *denied[]={ + "user password admin", "\"user\" \"password\" \"admin\"", + "user delete admin --force", "user role admin user --force", + "user role admin admin --force", "user add admin admin", + "user password admin --generate", "user password other --generate", + "user add fresh user --generate", "user key add other", + "user key add other ssh-ed25519 AAAA", "user key delete other 0 --force", + "user key clear other --force", "user bootstrap", "user bootstrap --generate", + "user recover --force", "user password other extra", + "user role other admin --force extra", "user delete other --force extra", + "user add fresh invalid", "user add fresh user extra", + "user delete other", "user role other user", + "user role \"admin\" user --force", "user add \"admin\" user", + }; + for (size_t i=0;i=0 && s_database.users[fresh].role==USER_ROLE_ADMIN); + assert(s_database.user_count==before.user_count+1); + assert(!memcmp(&before.users[1],&s_database.users[1],sizeof(stored_user_t))); + } else { + assert(s_database.users[1].auth_generation==before.users[1].auth_generation+1); + assert(memcmp(before.users[1].password_salt,s_database.users[1].password_salt, + sizeof(before.users[1].password_salt))!=0); + } + assert(web_revokes==1 && ssh_revokes==1); + assert(!strcmp(revoked_name,i==0 ? "fresh" : "other")); + assert(!memcmp(&before.users[0],&s_database.users[0],sizeof(stored_user_t))); + assert(!memcmp(&before.users[2],&s_database.users[2],sizeof(stored_user_t))); + assert(validate_database(&s_database)==ESP_OK); + assert(all_zero(s_candidate,sizeof(*s_candidate))); + } else { + assert(result!=0); unchanged(&before); + assert(commits==(stage==3 ? 1U : 0U)); + } + /* Loss of liveness cannot authorize a subsequent operation. */ + stored_database_t after=s_database; + unsigned prior_writes=writes, prior_commits=commits; + unsigned prior_web=web_revokes, prior_ssh=ssh_revokes; + assert(run("user password observer")!=0); + assert(!memcmp(&after,&s_database,sizeof(after))); + assert(prompts==2 && writes==prior_writes && commits==prior_commits); + assert(web_revokes==prior_web && ssh_revokes==prior_ssh); + } + } + /* Every enabled mutation fails closed when the originating session or copied + * account is stale BEFORE operation admission, including forced mutations. */ + for (size_t i=0;i +#include +#include +#include +#include +#include +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 }; +typedef void *SemaphoreHandle_t; +#define portMAX_DELAY 0 +#define NVS_READWRITE 1 +typedef int nvs_handle_t; +static unsigned locks, writes, commits, random_calls, prompts, checks, web_revokes, ssh_revokes; +static unsigned fail_stage, revoke_prompt, revoke_check, derivation_invalidations; +static bool invalidate_during_derivation; +static bool owner_current = true, remote = true, web = true, mismatch, cancel_prompt, stale_prompt; +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 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) { + (void)ns; (void)mode; assert(locks); + if (invalidate_during_derivation) { + assert(derivation_invalidations==1 && !owner_current); + } + *h=1; return fail_stage==1 ? ESP_FAIL : ESP_OK; +} +static int nvs_set_blob(int h, const char *key, const void *data, size_t n) { + (void)h; (void)key; (void)data; (void)n; ++writes; return fail_stage==2 ? ESP_FAIL : ESP_OK; +} +static int nvs_commit(int h) { (void)h; ++commits; return fail_stage==3 ? ESP_FAIL : ESP_OK; } +static void nvs_close(int h) { (void)h; } +static int secure_random_fill(void *p, size_t n) { + memset(p, ++random_calls, n); return fail_stage==4 ? ESP_FAIL : ESP_OK; +} +static int derive_password(const uint8_t *p, size_t n, const uint8_t *s, + uint32_t iterations, uint8_t *hash) { + (void)p; (void)n; (void)s; (void)iterations; memset(hash, 7, 32); + if (invalidate_during_derivation) { + /* Model originating browser expiry/closure after operation admission. + * This is a deterministic derivation double, not real PBKDF2/HTTPD. */ + assert(locks==1 && prompts==2 && checks==2 && owner_current); + assert(!writes && !commits); + owner_current=false; + ++derivation_invalidations; + } + return fail_stage==5 ? ESP_FAIL : ESP_OK; +} +static int mbedtls_sha256(const uint8_t *p, size_t n, uint8_t *h, int mode) { + (void)p; (void)n; (void)h; (void)mode; assert(!"keys outside slice"); return -1; +} +''' +header = strip_includes((ROOT / "src/user_database.h").read_text()) +state = db[db.index("#define USER_DATABASE_SCHEMA_VERSION"):db.index("static esp_err_t initialize_dummy_verifier(")] +fakes = r''' +bool user_database_key_valid(const uint8_t *t, size_t tn, const uint8_t *b, size_t bn) { + (void)t; (void)tn; (void)b; (void)bn; assert(!"keys outside slice"); return false; +} +static stored_database_t candidate_storage; +static user_principal_t actor; +static bool admin_ssh_console_dispatch_is_remote(void) { return remote; } +static bool admin_ssh_console_dispatch_is_web(void) { return web; } +static const user_principal_t *admin_ssh_console_dispatch_principal(void) { return remote ? &actor : NULL; } +static bool admin_ssh_console_dispatch_is_current(void) { + bool current=false; ++checks; + if (checks==revoke_check) owner_current=false; + return owner_current && user_database_principal_is_current(&actor, ¤t)==ESP_OK && current; +} +static int admin_command_gate_take(void) { return ESP_OK; } +static void admin_command_gate_give(void) {} +static int console_input_read_hidden(const char *prompt, uint8_t *out, size_t cap, + size_t min, size_t max, size_t *n) { + (void)prompt; (void)min; (void)max; assert(cap>=13); ++prompts; + memcpy(out, "test-password", 13); *n=13; + if (mismatch && prompts==2) out[0]='X'; + /* Simulate invalidation just after the prompt boundary returned success. */ + if (prompts==revoke_prompt) owner_current=false; + if (stale_prompt && prompts==2) ++actor.auth_generation; + return cancel_prompt ? ESP_ERR_INVALID_STATE : ESP_OK; +} +static int web_serial_transport_revoke_user(const uint8_t *u, size_t n) { + ++web_revokes; assert(n