From 94433ef975dfad746cec82f677f43d58640a1136 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 8 Sep 2026 09:27:02 +0200 Subject: [PATCH] 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. --- docs/agent/architecture.md | 8 + docs/agent/code-map.md | 4 + docs/agent/current-state.md | 6 + docs/agent/design-decisions.md | 8 + docs/phase8d10_implementation.md | 109 +++++ docs/phase8d_plan.md | 6 + src/CMakeLists.txt | 1 + src/admin_ssh_console.c | 18 +- src/admin_ssh_console.h | 3 +- src/user_database.c | 102 ++++- src/user_database.h | 22 + src/web_account_settings.c | 353 ++++++++++++++++ src/web_account_settings.h | 12 + src/web_auth_parse.c | 17 + src/web_auth_parse.h | 5 + src/web_server.c | 22 +- src/web_ui.c | 198 ++++++++- tests/admin_console_boundary/accounts.c | 101 +++++ tests/admin_console_boundary/accounts.py | 15 +- tests/admin_console_boundary/fakes.h | 3 +- tests/admin_console_boundary/test.c | 11 + tests/web_admin_transport/server_lifecycle.py | 81 +++- tests/web_auth_parse/run.py | 26 ++ tests/web_cookie_auth/README.md | 30 ++ tests/web_cookie_auth/account_settings_test.c | 377 ++++++++++++++++++ tests/web_cookie_auth/run.py | 15 + tests/web_cookie_auth/test.c | 12 + tests/web_ui_session/README.md | 38 +- tests/web_ui_session/browser.cjs | 301 +++++++++++++- tests/web_ui_session/run.py | 8 +- 30 files changed, 1864 insertions(+), 48 deletions(-) create mode 100644 docs/phase8d10_implementation.md create mode 100644 src/web_account_settings.c create mode 100644 src/web_account_settings.h create mode 100644 tests/web_cookie_auth/account_settings_test.c diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 765bfc5..ae9103e 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -152,6 +152,14 @@ The final administrator cannot be deleted or demoted. UART0 is trusted for initi NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials, legacy recovery credentials, and TLS/SSH private keys remain recoverable under physical flash extraction. +## Typed Accounts settings (8D.10) + +Current slice 2 extends the same dispatcher slot to create/password and self role/delete/password. Mutation bodies are 768 bytes/four receives; results stay secret-free, 96 bytes, replaceable and session-bound. Conditional password mutation checks identity under the canonical database mutex. A one-second firmware-lifetime ESP timer cancels/wipes non-executing queued credentials at their 30-second deadline plus timer/scheduling latency; dequeue wipes shared inputs after copying, and dispatcher-local credentials persist until admitted work returns. This is not a hard execution/erasure deadline. Separate bodyless admin/Origin/CSRF POST `/api/settings/accounts/generate-password` returns a 24-character value before any commit, without retained retrieval. UI generation has a 60-second best-effort lifetime and context-bound saved acknowledgement before separate submission; JavaScript cannot securely wipe strings. Self revocation may prevent final response/result access; disconnect/401 proves neither success nor cancellation. Browser-shell restrictions and UART0-only bootstrap/recovery remain unchanged. The generated endpoint is independently optionally registered, with failure isolation/restart coverage and 23 handlers. Implementation is complete, host-tested/build-verified; target validation/signoff remains pending. Parent build: 25.61 s, 95,908 B RAM / 1,694,237 B flash; timer runtime costs and stack/heap margins remain unmeasured. No task/stack/queue depth/socket expansion or 8D.11 work. Current contracts and attributed host evidence: `docs/phase8d10_implementation.md`. + +**Historical slice 1 architecture (superseded scope/counts, retained evidence):** + +`web_account_settings` supplies an optional admin-only compact account list and one session-bound role/delete operation/result slot, separate from Serial's slot but executed on the same dispatcher queue. HTTPD authorizes/parses/queues; the dispatcher revalidates the initiating login/admin and 30-second dequeue deadline, then calls conditional database mutations and best-effort target web/SSH revocation after success. `user_database_get_accounts()` copies at most eight username/role/ID/auth-generation records under the existing mutex with zero wait and no key/password fields. `user_database_delete_current()` and `user_database_set_role_current()` compare target identity under the mutation lock and share canonical CLI commit/invariant logic; stale selection never intentionally mutates a replacement account. Results are replaceable, not durable/idempotent, and already-admitted work can complete after logout. Self-target, create/password/generated-secret workflows remain unavailable in this slice; bootstrap/recovery remain UART0-only. Accounts UI confirms mutations, retains visible stale lists/outcomes during bounded auto-check/refresh and fences navigation/session changes without changing broker ownership. Three optional routes bring HTTPD handlers to 22; six sockets/no LRU and existing tasks/stacks/queue depth remain. See `docs/phase8d10_implementation.md` for limits and pending target checks. + ## Typed Serial settings (8D.9) `web_serial_settings` admits strict bounded admin cookie/Origin/CSRF JSON into one static session-bound operation/result slot, queuing only a non-reused ID on the existing administration dispatcher. HTTPD never runs serial/NVS mutations. The dispatcher checks session/principal currentness and a 30-second dequeue deadline before canonical Apply/Start/Stop/Save/Load/Defaults/Reset APIs; already admitted work may complete after logout. A blocked dispatcher retains the slot, not a timed job cancellation. Results are login-isolated and replaceable after completion; no durable history/idempotent retry guarantee. After acknowledgement the UI checks at one-second intervals, bounded to 10 GET attempts and a 15-second overall deadline including session checks, then automatically refreshes working values for known terminal outcomes. Errors/exhaustion use manual recovery; no automatic mutation retries or navigation resumption. Settings remain visible with stale/pending labels; only Reset confirms saved-NVS overwrite. Selecting the current view is a no-op. Settings UI retains uncertain-result warnings, explicit RAM/NVS/discard explanations and both terminal sockets/lease. Two optional exact GET/POST registrations bring the URI budget to 19, with six sockets and unchanged tasks/stacks/queue depth. `/api/status` also uses the zero-wait serial snapshot and emits `running:null` when unavailable. See `docs/phase8d9_implementation.md` for resource and target-pending evidence. diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 83205ec..c8fe1d8 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -78,6 +78,10 @@ This is a semantic map, not a complete file inventory. Start here, then read the - 8D.9: `web_serial_settings.{c,h}` owns strict 256-byte typed mutation admission and one session-bound pending/result slot. Existing `admin_ssh_console` dispatcher consumes only an ID, revalidates currentness/dequeue deadline and calls canonical serial APIs. `web_server.c` adds optional GET/POST `/api/settings/serial-operation` (19 handlers total); `web_cookie_auth_require_json()` retains Origin/CSRF/admin policy, private optional registration supports exact GET/POST. UI adds explicit framing/lifecycle/persistence with automatic completion checks (1 s, at most 10 GETs/15 s overall), refresh on known terminal results and manual uncertainty recovery without socket/lease changes. Settings stay visible/stale while pending; only Reset confirms NVS overwrite; selecting the current view is a no-op. `/api/status` uses a consistent zero-wait serial snapshot (`running:null` when unavailable). Tests: cookie `--serial-settings` (10 groups), `--settings` (6), UI (35 after UX refinement), console boundary and lifecycle (13). Build verified, target/signoff pending; bounds and failure contracts: `docs/phase8d9_implementation.md`. Supersedes 8D.8's no-8D.9 status above. +- 8D.10 first slice: `web_account_settings.{c,h}` owns compact admin account list and one session-bound other-account role/delete operation slot. `user_database_get_accounts()` is a zero-wait key/secret-free projection; `*_current()` role/delete wrappers compare target ID/auth generation under the canonical mutation lock. Existing dispatcher routes IDs; successful calls target-revoke web/SSH. Optional GET `/api/settings/accounts`, GET/POST `/api/settings/account-operation` raise handlers to 22, sockets/tasks/stacks/queue depth unchanged. UI Accounts subview preserves terminal/lease semantics, confirms mutations and auto-checks/refreshes with manual uncertainty recovery. Tests: cookie `--accounts` (5), canonical accounts, dispatcher, lifecycle (14), UI (41 + CSP). Target pending; create/password/generated-secret/self changes remain next slice, 8D.10 incomplete. Record: `docs/phase8d10_implementation.md`. + +- **Current 8D.10 slice 2 (supersedes first-slice exclusions above):** `web_account_settings.{c,h}` adds create/password/self and separate bodyless POST `/api/settings/accounts/generate-password`; `user_database_set_password_current()` shares mutation-lock target checks and canonical commit logic, `user_database_generate_password_value()` generates without mutation. 768-byte/four-receive admission, 96-byte secret-free results; one-second periodic timer cancels/wipes queued non-executing credentials after 30 seconds plus scheduling latency, while dispatcher wipes executing locals on return. Generation has no retained retrieval; UI uses 60-second context-bound acknowledgement before separate submission. Self revocation can deny result retrieval; 401/disconnect is uncertain. Browser-shell restrictions unchanged. Missing generated-route registration found in review is fixed: independent optional endpoint, 23 handlers, failure isolation/restart coverage. Implementation complete, host-tested/build-verified; target/signoff pending. Parent PASS canonical accounts/boundary, parser 294, cookie accounts 9/shared and serial-settings 10, transport 25/tickets 12, store/serial and diff check; UI agent PASS 57/CSP, route agent lifecycle 15. Parent build 25.61 s, 95,908 B RAM / 1,694,237 B flash (+80/+9,880 vs slice 1; +200/+25,400 vs final 8D.9 UX). Timer runtime costs/stack margins remain unmeasured; no 8D.11. Exact evidence attribution: `docs/phase8d10_implementation.md`. + ### Browser admin backend (8D.5) - **8D.7 current status (2026-09-07): implemented scope validated; M2 explicitly signed off by the user ("Jupp, sign M2 off").** Supersedes M2-open/target-pending/continuation statements in the historical slices below; accepted M2 does not require revalidation. User verified certificate rotation and web start/stop via UART0/SSH admin/web admin, restarting after browser stop via another route; full mix without broker drops up to 230400 baud after external adapter correction is user-reported. Intermittent supported two serial + one admin admission failures, recently not recurring, are accepted nonblocking, not fixed. Browser self/generated/key/legacy-credential and other owner command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Numeric memory reserves/stack margins remain unapproved; no full parity or individual unreported checklist passes. Next: separately requested 8D.8 read-only settings entry and Serial page; sign-off alone authorizes no implementation. Evidence: `docs/phase8d7_implementation.md`. diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 24a67a1..742a8ee 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,12 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **Current 8D.10 slice 2 complete, host-tested/build-verified (2026-09-08); target/signoff pending, not target accepted:** Create/password/generated-value/self workflows use the existing dispatcher slot, 768-byte/four-receive admission and canonical mutation-lock identity checks. One-second periodic timer cancels/wipes non-executing credentials at 30 seconds plus scheduling latency; admitted work is not cancelled and locals wipe after return. Generation is separate before commit, with no retained retrieval; UI 60-second lifetime/context-bound acknowledgement and best-effort secret clearing. Immediate self revocation can lose POST/results: 401/disconnect is uncertain, inspect after relogin before retry. Browser-shell restrictions unchanged. Review's only finding, missing generated-route registration, is fixed as an independent optional endpoint with failure isolation/restart coverage: **23 handlers**, six sockets/no LRU unchanged. Parent PASS canonical `accounts.py`, boundary `run.py`, parser **294**, cookie `--accounts` **9 plus shared**, `--serial-settings` **10**, transport **25**/tickets **12**, store `--serial` and diff check. UI agent **57 plus CSP** (four added beyond 53); route agent lifecycle **15** pass. These UI/lifecycle results are agent-attributed, not claims of the parent's additional reruns. Parent `pio run` **PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash**, **+80/+9,880** vs slice 1 and **+200/+25,400** vs final 8D.9 UX. New timer runtime costs, heap reserves and stack margins remain unmeasured/unapproved. Record/checklist: `docs/phase8d10_implementation.md`; slice 1 below remains historical. This update is documentation-only; no source/test/build action by this documentation agent. No sanitizer validation, device/assets/commit/8D.11 action, full 8D.10 target signoff or prior-phase signoff/reserve approval inferred. + +The following slice 1 entry is explicit historical evidence; its unavailable/next-slice statements and build/counts do not describe current slice 2. + +- **8D.10 first slice complete (2026-09-08), host-tested/build-verified; target pending, phase incomplete:** User requested 8D.10; selected plan's pre-edit list/role/delete versus create/password split. `web_account_settings.{c,h}` provides admin-only compact list and other-account role/delete on one session-bound slot, executed by the existing dispatcher. Zero-wait `user_database_get_accounts()` and mutation-lock identity checks preserve canonical final-admin/NVS semantics and reject stale/recreated targets; successful calls target-revoke web/SSH. Three optional routes, 22 handler budget, six sockets/no LRU, unchanged tasks/stacks/queue depth. Accounts subview has confirmations, bounded auto-completion/refresh (10 GETs/15 s), uncertainty recovery and navigation/session fencing without broker lease effects. Final parent cookie `--accounts` (5 groups/shared auth), canonical accounts, console boundary, server lifecycle (14), UI (41 + CSP), transport/tickets and store/serial pass; prior settings/serial/admin/policy/lifecycle reruns also pass. Final `pio run` **25.00 s / 95,828 B RAM / 1,684,357 B flash**, **+120 / +15,520 B** vs final 8D.9 UX; diff check PASS. Exact evidence/contracts/target checklist: `docs/phase8d10_implementation.md`. No independent review/sanitizer/device/assets/commit action or reserve/prior-phase signoff. Next is second 8D.10 slice: create/password, one-time generated secrets and safe own-account changes; these remain unavailable. No 8D.11 work or full 8D.10 completion. + - **8D.9 UX refinement (2026-09-08), host-tested/build-verified; target pending:** User reported successful Apply required awkward manual Check Result then Refresh and approved automatic flow/removing routine popups. `web_ui.c` now keeps settings visible/stale while pending, checks acknowledged operations every 1 s up to 10 GET attempts/15 s overall, then refreshes working values while preserving outcome. Errors/exhaustion/lost ack use manual recovery; no POST retry, no resume after navigation. Only Reset retains a saved-NVS overwrite confirmation; inline discard semantics remain. Current-view selection is a no-op so repeated Settings clicks cannot cancel work. Parent UI **35 groups + renderer/CSP**, `pio run` **10.91 s / 95,708 B RAM / 1,668,837 B flash**, diff check PASS; **0 / +2,112 B** vs original 8D.9. No backend/assets/device/commit action or new signoff. Details/evidence limits in `docs/phase8d9_implementation.md`; target automatic-completion, failure/manual-recovery and full-mix latency checks pending. - **8D.9 continuation complete (2026-09-07), implemented / reviewed / host-tested / build-verified; target/signoff pending:** Preserved inherited implementation; typed Apply/Start/Stop/Save/Load/Defaults/Reset uses one session-bound slot and the existing dispatcher queue, 256-byte JSON POST and 96-byte explicit GET results. No new tasks/stacks/queue depth or broker lease semantics; 19 URI handlers, six sockets/no LRU. Fixed persistent uncertain-result warnings and consistent zero-wait `/api/status` serial state (`running:null` when unavailable). Final parent Serial 10, Settings/status 6, UI 27/CSP, console boundary and lifecycle 13 groups pass; additional transport/tickets/store/admin/account/lifecycle regressions passed during review. Final `pio run` 23.73 s, **95,708 B RAM / 1,666,725 B flash**, **+128 / +12,196 B** vs 8D.8. Deadline is dequeue admission only; blocked dispatcher retains pending slot, admitted NVS work may finish after logout, completed results can be replaced. Exact contracts/tests/limits and pending target checklist: `docs/phase8d9_implementation.md`. No target/device/asset/commit action, reserve approval or 8D.8/8D.9 signoff. Stop before separately requested 8D.10; M2 acceptance and existing deferred restrictions/admission followups stand. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index 1dc1321..5e87853 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -106,6 +106,14 @@ Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutab **8D.9:** HTTPD performs bounded typed admission/result reads only; serial reconfiguration and NVS execute on the existing dispatcher so CLI commands cannot interleave. One global pending slot rejects concurrent work; copied session/principal plus non-reused ID fence stale queued work. A 30-second deadline is checked on dequeue, not a cancellation timer or execution limit. Admitted mutations may finish after revocation; completed results can be replaced. Keep explicit uncertain-outcome recovery and never automatically retry mutations. Apply/Defaults are RAM-only, Save persists working device state rather than browser drafts, and Reset follows canonical apply/persist/best-effort-rollback ordering. Navigation preserves broker clients/writer lease, while explicit serial reconfiguration can discard serial-service pending data. No generic command runner/job history is exposed. See `src/web_serial_settings.{c,h}` and `docs/phase8d9_implementation.md`. +## Typed account selection is checked inside the database mutation lock + +**Current 8D.10 slice 2:** Extend conditional identity checks to password replacement; create uses canonical duplicate/capacity/commit policy. Keep generation separate from commit: the protected bodyless generated-value POST returns one transient value, performs no mutation and retains no retrieval state. Browser saved acknowledgement is context-bound UX, not delivery proof or server authorization. Queued credentials require a one-second periodic timer to cancel/wipe non-executing work at the 30-second deadline plus scheduling latency; execution copies then wipes shared inputs, with local wiping after admitted database work returns. Neither timer nor logout cancels admitted commits. Self password/role/delete uses immediate canonical target revocation, not deferred acknowledgement: 401/disconnect is uncertain and requires relogin/inspection before any explicit retry. Generation is independently optionally registered, preserving failure isolation and restart behavior at 23 handlers. No shell restriction change, secret result/history, new executor or 8D.11 work. Implementation is complete, host-tested/build-verified, not target accepted; timer runtime costs remain unmeasured. See contracts, build and attributed test evidence in `docs/phase8d10_implementation.md`. + +The following first-slice exclusions are historical and superseded by slice 2: + +**8D.10 first slice:** Accounts HTTPD routes expose a compact zero-wait list without password/key data and submit role/delete IDs to the existing dispatcher. Do not use the larger blocking CLI snapshot on HTTPD. Initiating-session currentness is checked before operation admission; target username/account ID/auth generation is compared under the database lock before candidate staging. Conditional and CLI mutations share invariant/commit logic. Notify only the target's web/SSH sessions after successful calls; notification failure does not undo persistence. Separate bounded Serial/Accounts slots do not create another executor. Completed results remain replaceable, no mutation auto-retry, and navigation is not cancellation. Self-target and create/password/generated-secret delivery are intentionally excluded until the next slice defines safe delivery/reconnect semantics. `src/web_account_settings.{c,h}`, `src/user_database.{c,h}`, `docs/phase8d10_implementation.md`. + ## Browser authentication has a narrow version-pinned HTTPD boundary **8D.8 read-only settings:** Reuse bodyless GET cookie/current-admin policy and the existing bounded browser API/errors; no CSRF mutation semantics on a read. Obtain working serial config/running atomically with a zero-wait existing serial mutex, never block HTTPD on stop/reconfiguration or inspect NVS. Navigation changes view/input only, preserving both terminal sockets/lease/output; Settings session validation must not supersede serial-admission checks. One optional exact-GET URI raises only handler capacity to 17. The private adapter stages both descriptor/name allocations before publishing, avoiding the installed 5.5.0 public registration's freed table pointer on strdup failure. Serialized startup/exact matcher only, normal HTTPD allocation/free ownership; re-audit this boundary on SDK changes. Existing public registration callers are not refactored by this phase. diff --git a/docs/phase8d10_implementation.md b/docs/phase8d10_implementation.md new file mode 100644 index 0000000..05b8102 --- /dev/null +++ b/docs/phase8d10_implementation.md @@ -0,0 +1,109 @@ +# Phase 8D.10 — Accounts and passwords + +## Current second slice (2026-09-08) + +**8D.10 implementation is complete, host-tested and build-verified, including create/password/generated-value/self workflows and route integration. Target validation and full 8D.10 signoff remain pending; this is not target acceptance. No 8D.11 work.** This supersedes slice 1 scope exclusions and next-work instructions, not its historical evidence. M2 acceptance stands; continuation is not prior-phase target signoff or reserve approval. + +### Current contracts + +- The admin-only list and single session-bound operation/result slot now support create/password/role/delete, including own-account password/role/delete. HTTPD authorizes/parses/queues; the existing dispatcher executes mutations. No new application task, stack allocation, queue depth, socket or broker client. +- Mutation JSON is **768 bytes / four receive calls**. Exact create schema: `{action, username, role, password}`; password replacement: `{action, username, user_id, auth_generation, password}`. Role/delete schemas are unchanged. Only password accepts JSON escapes; decoded values must be canonical **12-64 printable ASCII bytes**, without trimming. Spaces, quotes and backslashes are valid. Unknown/duplicate/extra fields, malformed encoding and invalid identities reject. Body, parsed credentials and consumed request scratch are wiped on exit. +- Create uses canonical `user_database_create()` duplicate/capacity/NVS policy. `user_database_set_password_current()` shares CLI mutation logic and checks target identity under the mutation mutex before staging, derivation, generation increment and commit-before-live-install. Role/delete retain conditional checks and final-admin/migrated-account protections. Secret-free **96-byte** `{id, action, state}` results add `duplicate` and `full`; results remain replaceable, originating-session-bound, not durable history or retry tokens. +- Credential admission requires one lazily created firmware-lifetime **one-second periodic ESP timer**; create/start failure rejects admission. At/after the **30-second admission deadline**, a tick cancels and wipes the current non-executing pending create/password slot, permitting replacement even while the dispatcher is blocked. Cleanup is nominally deadline plus up to one period **plus scheduling latency**, not a hard real-time guarantee. The callback inspects current ID/deadline, never cancels a replacement early or executing work, and does no database/network work. Role/delete retain dequeue-only expiry; obsolete queued IDs cannot execute replacement work. +- Dequeue marks execution, copies inputs locally and immediately wipes shared credentials/principal/target. Dispatcher revalidates originating session/admin and deadline before database API admission. The timer does not wipe executing local data; credentials are wiped after database return or rejection, and the full local operation at exit. Blocking/derivation/NVS duration is not bounded by the queue deadline. Already-admitted work may complete after logout/expiry; navigation/request abort is not backend cancellation. +- Separate bodyless admin/Origin/CSRF-protected **`POST /api/settings/accounts/generate-password`** calls `user_database_generate_password_value()` and rechecks session currentness before returning one **24-character base64url** value in a **96-byte**, no-store response. It performs no account mutation, slot reservation, NVS commit or revocation. Generated/response scratch is wiped on success and failure. There is **no retained secret or retrieval endpoint**. A lost generation response means nothing was applied; explicit regeneration yields another value. Generation precedes a separate commit, rather than commit-and-retrieve or guaranteed delivery. +- UI generation is explicitly not applied. Submission requires confirmation and, for generated values, saved-password acknowledgement bound to the value and operation/target context. Generated references expire after **60 seconds**, including monotonic admission checks for delayed timers. Edits/context changes invalidate acknowledgement; submission/cancellation, navigation/session/page lifecycle cleanup clears secrets and fences late replies. No routine secret storage/logging/history/clipboard writes. JavaScript/browser-managed copies cannot be securely zeroed; reference clearing is best effort. +- Successful non-create calls immediately best-effort target-revoke web/SSH; create does not notify. Successful role no-op still notifies. Self mutations may close all that account's web/SSH sessions, including this browser's serial/admin routes, before POST/result delivery. No deferred drain, proactive logout or guaranteed receipt. **401/disconnect proves neither success nor failure**: re-login with expected credentials/role and inspect through a surviving authorized route before retrying. Notification failure does not undo persistence; authoritative currentness remains the fail-safe. Ordinary navigation preserves socket/writer ownership; deliberate self revocation is distinct. +- Automatic result checking remains one-second delay, at most ten GETs/15 seconds overall, with manual uncertainty recovery and no automatic mutation retry. **No browser-shell restriction changes:** typed self/generated workflows do not enable those shell commands. SSH keys/raw database or secret export/legacy credential management are outside scope; bootstrap/recovery remain permanently UART0-only. +- The generated-password endpoint is registered independently as an optional route in `web_server.c`, bringing the budget to **23 handlers**, with six sockets/no LRU unchanged. Backend review found only the missing registration, now fixed. Route-agent lifecycle tests cover registration, optional failure isolation and restart; direct-handler tests alone are not registration proof. + +### Continuation evidence + +- Reported continuation `python3 tests/web_cookie_auth/run.py --accounts`: **PASS, nine Accounts groups plus shared auth/store/IDF adapter groups**. Includes credential parsing, timer creation/start failure, expiry/replacement/execution fences, cleanup, generation without mutation and self revocation. Database/queue/revocation/timer doubles are not target concurrency, flash or TLS proof. +- UI agent reports `python3 tests/web_ui_session/run.py`: **PASS, 57 behavior groups plus renderer/CSP checks**, four added beyond its earlier 53-group slice 2 run. Modeled DOM/fetch/timers/WebSockets do not establish real-browser/backend integration or actual CSP enforcement. +- Route agent reports `python3 tests/web_admin_transport/server_lifecycle.py`: **PASS, 15 groups**, including the corrected independent optional endpoint, failure isolation and restart. UI 57/CSP and lifecycle 15 are agent results; the parent's additional UI/lifecycle reruns have not been reported here and are not claimed as passes. +- Parent reports PASS: `python3 tests/admin_console_boundary/accounts.py`, `python3 tests/admin_console_boundary/run.py`, `python3 tests/web_auth_parse/run.py` (**294 cases**), `python3 tests/web_cookie_auth/run.py --accounts` (**9 plus shared**), `python3 tests/web_cookie_auth/run.py --serial-settings` (**10 groups**), `python3 tests/web_admin_transport/run.py --tickets` (**25 transport / 12 ticket groups**), `python3 tests/web_session_store/run.py --serial`, and diff check. +- Parent `pio run`: **PASS, 25.61 s; 95,908 B RAM / 1,694,237 B flash**. Delta versus slice 1: **+80 B RAM / +9,880 B flash**; versus final 8D.9 UX: **+200 B RAM / +25,400 B flash**. Slice 1 figures below remain historical. Static linker accounting is not runtime heap/stack proof: new timer and descriptor runtime costs, HTTPD/dispatcher stack margins and loaded heap reserves remain unmeasured/unapproved. +- This documentation task performs no source/test edits or build. Results are attributed parent/agent reports, not reruns by this documentation agent. No sanitizer validation, upload, erase, device action, asset regeneration, commit or 8D.11 work is claimed. + +### Target checklist (pending) + +1. Test desktop/mobile disposable-account create and supplied/generated password workflows: exact bytes, confirmation/context acknowledgement, expiry/cleanup and no application from Generate. Compare UART0 state and persistence after reboot. +2. Exercise other-account password/role/delete with active web/SSH sessions; verify target-only revocation and unrelated web/SSH/native USB continuity, plus navigation socket/writer preservation. +3. With a surviving administrator and UART0 recovery available, test self password/demotion/deletion, final-admin rejection and role no-op. Verify uncertain 401/disconnect handling, expected relogin and inspection before retry; missing result never proves no commit. +4. Mutate/delete/recreate selected targets through CLI and check stale rejection; exercise duplicate/full/protected failures without unintended writes or disclosure. +5. Queue credentials behind a long console prompt; observe deadline cleanup/replacement, logout/expiry, late responses, lost generation/POST acknowledgements and manual recovery without retry. Timer scheduling and executing-secret lifetime require target observation. +6. Verify fourth-route admission, injectable registration/start/stop failures, slow fragmented requests and full serial/admin/SSH/USB mix. Record loaded/cleanup internal/DMA/PSRAM free/min/largest and HTTPD/dispatcher stack margins. Host NVS doubles are not target failure evidence. +7. Obtain target acceptance separately from the completed host-tested/build-verified implementation. **Stop within 8D.10; no 8D.11 authorization or target signoff is inferred.** + +## Historical first slice (2026-09-08) + +All sections below retain slice 1 evidence, including then-current exclusions, 22-handler count, build and next-slice checklist. They do not describe current slice 2 scope or validation. + +**Account list and other-account role/delete implemented, host-tested and build-verified; target validation pending. 8D.10 remains incomplete.** The user requested 8D.10 after the 8D.9 UI refinement. Before editing, selected the plan's explicit split between list/role/delete and create/password/secret delivery. No 8D.8/8D.9 target signoff is inferred from continuation. M2 acceptance stands. + +### Available behavior + +Settings now has Serial settings and Accounts subviews. Accounts shows at most eight usernames/roles, identifies the current login, and permits confirmed role changes or deletion of another account. Changes persist immediately; there is no Apply/Save staging. Successful operations use the same best-effort targeted web/SSH revocation as CLI commands. Unrelated accounts, UART0, native USB and navigation-related serial writer ownership remain unchanged. A role no-op retains canonical CLI behavior: no database commit, but successful-command target notification still occurs. + +The UI submits once, checks acknowledged work automatically (one-second initial/inter-check delay, at most ten GET attempts and a 15-second overall deadline including revalidation), then refreshes the list on known terminal results. It retains failed/uncertain outcome messages. Errors, deadline exhaustion or lost acknowledgement require manual Check Result/Refresh; no automatic mutation retry. Navigating between domains, leaving Settings, logout/expiry/pagehide or changed session identity aborts/fences work and clears the account list/selection labels. Navigation is not backend cancellation. Re-selecting the current domain/view is a no-op. + +**Not included:** create, supplied-password replacement, generated-password delivery, own-account changes, SSH keys, bootstrap/recovery, raw database export or any password/verifier/private-key fields. Self role/delete is denied by the server as well as the UI. Existing browser-shell restrictions are unchanged. The second 8D.10 slice must explicitly design create/password and own-account credential/reconnect behavior; this first slice is not full Accounts/password UX or phase acceptance. + +## Backend contracts + +- `src/web_account_settings.{c,h}` owns one static pending/result slot. It holds only an operation ID, copied originating session/principal, target identity, action/role and deadline/state—no credential material. +- `GET /api/settings/accounts`: current admin cookie policy, no query/body, no-store. Compact projection of username, role, account ID and authentication generation; no SSH key metadata or full database snapshot. `user_database_get_accounts()` copies under the existing mutex with **zero wait**, clearing output on failure. This narrow copy does not change the existing authentication/currentness APIs or claim all authentication paths are nonblocking. +- `POST /api/settings/account-operation`: admin cookie/current principal, strict Origin/CSRF policy; maximum **256 bytes / four receive calls**. Exact ASCII flat schema: `{action, username, user_id, auth_generation}` for delete; plus `role` for role changes. IDs are nonzero unsigned 32-bit decimal integers. Unknown/duplicate fields, escaping/nesting, invalid usernames/enums, fractional/exponential/overflow/coerced numbers and extra fields reject. Unread rejected bodies close; request scratch is wiped. +- `GET /api/settings/account-operation`: bodyless current-admin read of the originating login's retained `{id, action, state}` only. Results have a **96-byte buffer** and states idle/pending/ok/failed/cancelled/stale/protected. Another admitted operation may replace a completed result; this is not durable history or an idempotent retry API. +- `admin_ssh_console_submit_account_settings()` enqueues only the ID, with **zero queue wait**, to the existing four-entry dispatcher queue. The typed union adds no queue-item size and consumes no remote console slot. One pending account operation rejects another with 503/Retry-After. The Serial and Accounts pending slots are separate but executions serialize with each other and UART0/SSH/browser-shell commands on the same dispatcher. +- On dequeue, validate originating session/principal/admin role, reject self-target, and enforce a **30-second admission deadline**. A blocked dispatcher retains the pending slot until it dequeues the request; this is not a slot-release timer or execution timeout. Admitted database work may commit and notify after the initiating login expires. Subsequent stale work rejects. HTTPD never executes account commits or transport notification. +- `user_database_delete_current()` / `user_database_set_role_current()` compare target username/account ID/authentication generation **inside the mutation mutex** before staging or commit. Missing or changed targets return stale without mutating a replacement account. Existing CLI APIs call the same implementation without conditional identity arguments. Final-admin/migrated-admin protections, role generation changes, NVS commit-before-live-install and candidate cleanup remain canonical. +- Only successful database calls trigger target-name web and SSH revocation; notification failures do not roll back a committed account. Authoritative transport currentness remains the fail-safe. Result state reports database completion, not guaranteed notification delivery. +- `web_server.c` optionally registers list GET, result GET, then mutation POST, using the existing failure-safe private adapter. Failure cannot publish mutation without both read routes; failed POST cleanup can leave at most read-only routes. Allocation failure does not disable either terminal transport. No new SDK-private access. + +## Validation performed + +Final sequential parent run, all passed: + +```sh +python3 tests/web_cookie_auth/run.py --accounts +python3 tests/admin_console_boundary/accounts.py +python3 tests/admin_console_boundary/run.py +python3 tests/web_admin_transport/server_lifecycle.py +python3 tests/web_ui_session/run.py +python3 tests/web_admin_transport/run.py --tickets +python3 tests/web_session_store/run.py --serial +pio run +git --no-pager diff --check +``` + +- Accounts HTTP: **five groups** plus shared cookie/store/IDF getter/adapter regressions. Actual parser/auth/store/handler with deterministic DB/queue/revocation doubles. Covers security/input bounds, max-width eight-account projection, failed list, slot/queue exhaustion, session isolation, obsolete/zero IDs, failure/stale/protected outcomes, deadline/session cancellation, missed account revocation, DB-currentness failure and admitted-work completion after invalidation. A failed database currentness check invalidates that cookie session, so subsequent test operations use a newly issued login. +- Canonical account tests compile production database mutation bodies and compact list getter, plus existing CLI handlers. Verify target generation/deletion/recreation rejection, no-write final-admin protection, unchanged live state and cleared candidate on NVS open/set/commit failure, and existing prompt/currentness/revocation behavior. RTOS/NVS/crypto are deterministic doubles, not actual flash or concurrency tests. +- Dispatcher boundary covers nonblocking Accounts admission and separate Serial/Accounts routing on the existing queue, alongside prior console/certificate/SSH-adapter cases. +- Server lifecycle: **14 groups**, including all three Accounts registration failure positions, failed unregister, restart recovery and unchanged transport isolation/six sockets/no LRU. +- UI: **41 behavior groups** (six new Accounts groups), plus production C renderer/headers/failure and exact inline-loader CSP checks. Covers admin-only list/schema, confirmed typed identity-bound actions, automatic completion/refresh, polling budget/manual recovery, cancellation, failure/uncertainty and session identity/401 isolation. DOM/fetch/timers/WebSockets are modeled; this is not real-browser/backend integration. +- Transport/tickets and session-store/serial integration reruns passed. Earlier in this slice, cookie `--settings`, `--serial-settings`, `--admin`, console `lifecycle.py` and `admin_ssh_policy/run.py` also passed. + +Initial test failures were corrected: authored C/JS newline escaping, outdated lifecycle test route/count expectations, and new currentness tests using the existing fake's actual invalidation control. Final results above supersede those intermediate failures. Implementer source/diff review performed; no independent-agent review or sanitizer claim. + +## Resources + +Final `pio run`: **25.00 s; 95,828 B RAM / 1,684,357 B flash**. + +- Versus final 8D.9 UX build (95,708 / 1,668,837): **+120 B RAM / +15,520 B flash**. +- Versus 8D.8 (95,580 / 1,654,529): **+248 / +29,828 B**. +- Static linker totals are not free/min/largest heap, allocation overhead, or stack high-water proof. +- Three additional optional URI descriptors bring the configured handler budget from 19 to **22**. Six HTTPD sockets/no LRU, two serial/one admin WebSockets, dispatcher depth, tasks and stack allocations remain unchanged. +- List response buffer **1,024 bytes**, operation request **256 bytes**, result **96 bytes**, at most eight compact records. Handler/dispatcher stack margins and additional descriptor/name/table runtime heap have not been measured on target. Four bounded receive calls still occupy HTTPD while receiving; full-mix responsiveness needs target validation. + +No asset regeneration, upload, erase, device action or commit. Numeric reserves/stack margins remain unapproved; the accepted intermittent full-mix admission issue is unchanged and unresolved. + +## Pending validation and handoff + +1. Confirm Settings/Accounts desktop/mobile display and role gating on hardware; compare list/roles with UART0. Verify navigation preserves existing serial/admin sockets and writer lease. +2. Change/delete disposable other accounts while they hold web/SSH sessions; verify immediate persistence, only affected-account revocation, and unrelated USB/web/SSH continuity. Reboot and compare persisted state. Do not delete recovery/needed accounts casually. +3. Select an account, mutate or delete/recreate it through CLI, then confirm the stale browser request rejects. Verify self actions are unavailable, and canonical final-admin protection still works. +4. Queue behind a long console prompt; test logout, expiry, queue deadline, busy admission, missed/lost acknowledgement and manual recovery. Check that navigation does not imply cancellation and no mutation is automatically repeated. +5. Test optional route failure/slow fragmented HTTP requests and record loaded/cleanup internal/DMA/PSRAM free/min/largest plus HTTPD/dispatcher stack margins. Host failures are not target NVS-failure evidence. +6. Next is the **second 8D.10 slice**, not 8D.11: create/password workflows, bounded transient secret handling, one-time generated-password delivery and safe own-account changes. Establish the secret-delivery/acknowledgement/revocation contract before editing. Bootstrap/recovery remain permanently UART0-only. First-slice target validation and full 8D.10 signoff remain pending. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index ad2165a..7c9884f 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -1,5 +1,11 @@ # Phase 8D — Incremental web administration plan +**Current slice 2 completion (2026-09-08):** **8D.10 implementation is complete, host-tested/build-verified, not target accepted; target validation/full signoff remain pending.** Create/password/self workflows use bounded 768-byte admission and periodic credential cleanup (30-second deadline plus one-second timer/scheduling latency); admitted executing work is not cancelled. Protected generation is separate before commit, with no retained retrieval. Self revocation may prevent results: 401/disconnect is uncertain, never grounds for automatic retry. Browser-shell restrictions remain unchanged. Review's only finding, missing generated-route registration, is fixed as an independent optional endpoint with failure isolation/restart coverage, **23 handlers/six sockets**. Parent PASS canonical accounts/boundary, parser **294**, cookie accounts **9 plus shared**, serial-settings **10**, transport **25**/tickets **12**, store/serial and diff check. UI agent **57 plus CSP** and route agent lifecycle **15** pass; these are not claims of the parent's additional UI/lifecycle reruns. Parent `pio run` **PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash**, **+80/+9,880** vs slice 1 and **+200/+25,400** vs final 8D.9 UX. Timer runtime costs and heap/stack margins remain unmeasured. [Current 8D.10 record](phase8d10_implementation.md) contains contracts/evidence/target checklist. No sanitizer validation, assets/device/commit/8D.11 work, M2 reopening, prior-phase signoff or reserve approval inferred. + +The implementation/continuation entries below are historical evidence. In particular, slice 1's exclusions, next-slice instruction, 22-handler count and build figures do not describe current slice 2. + +**Latest implementation (2026-09-08):** User-requested **8D.10 first slice is host-tested/build-verified; target pending and phase incomplete**. Pre-edit split follows the row below: Accounts list and other-account role/delete now implemented, with conditional target identity checks, existing dispatcher/target notifications and bounded automatic UI completion. Three optional routes, **22 handlers/six sockets**, **95,828 B RAM / 1,684,357 B flash**. [8D.10 record](phase8d10_implementation.md) covers tests/resources/limits. Next is the second 8D.10 slice (create/password/generated-secret/self workflows), not 8D.11. Supersedes historical stop-before-8D.10 instructions; no prior target signoff, M2 reopening or reserve approval inferred. Final 8D.9 UX baseline is recorded in [8D.9](phase8d9_implementation.md). + **Latest continuation (2026-09-07):** Separately authorized **8D.9 is implemented / reviewed / host-tested / build-verified**, with 8D.8/8D.9 target acceptance still pending. [8D.9 record](phase8d9_implementation.md): typed Serial framing/lifecycle/persistence, one session-bound operation slot on the existing dispatcher, manual bounded result recovery, 19 handlers/six sockets; final **95,708 B RAM / 1,666,725 B flash**. No new task/queue depth/stack or broker writer semantics. This supersedes older stop-before-8D.9 instructions, not M2 signoff or deferred restrictions, admission followups or unapproved reserves. Stop before separately requested 8D.10; no target signoff inferred. **Previous implementation (2026-09-07):** Separately user-authorized **8D.8 is implemented / host-tested / build-verified**, with target/browser validation and new phase signoff pending. [8D.8 record](phase8d8_implementation.md): read-only admin Settings/Serial, nonblocking typed snapshot, 17 handlers/six sockets, final 95,580 B RAM / 1,654,529 B flash; exact tests/resources/limits and target checklist recorded. This supersedes older next-8D.8/wait-for-request instructions below, not M2 signoff or evidence. Deferred restrictions, accepted unresolved admission issue and unapproved memory/stack followups remain. Stop before separately requested 8D.9; no new signoff is inferred. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b868c7b..9b09aa6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -32,6 +32,7 @@ idf_component_register( "web_security.c" "web_serial_transport.c" "web_serial_settings.c" + "web_account_settings.c" "web_admin_tickets.c" "web_admin_transport.c" "web_assets_data.c" diff --git a/src/admin_ssh_console.c b/src/admin_ssh_console.c index 2b8b4a1..51d4602 100644 --- a/src/admin_ssh_console.c +++ b/src/admin_ssh_console.c @@ -17,6 +17,7 @@ #include "secure_random.h" #include "user_database.h" #include "web_serial_settings.h" +#include "web_account_settings.h" #define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U #define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U @@ -83,6 +84,7 @@ typedef enum { ADMIN_REQUEST_UART0, ADMIN_REQUEST_DEFERRED, ADMIN_REQUEST_SERIAL_SETTINGS, + ADMIN_REQUEST_ACCOUNT_SETTINGS, } admin_request_origin_t; typedef struct { @@ -94,6 +96,7 @@ typedef struct { uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; admin_control_request_t deferred; uint32_t serial_settings_id; + uint32_t account_settings_id; }; } admin_request_t; @@ -659,6 +662,16 @@ esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id) return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; } +esp_err_t admin_ssh_console_submit_account_settings(uint32_t id) +{ + taskENTER_CRITICAL(&s_lock); + bool ready = s_dispatch_ready; + taskEXIT_CRITICAL(&s_lock); + if (!ready || !id) return ESP_ERR_INVALID_STATE; + admin_request_t request = {.origin = ADMIN_REQUEST_ACCOUNT_SETTINGS, .account_settings_id = id}; + return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; +} + static void worker_task(void *context) { (void)context; @@ -667,8 +680,9 @@ static void worker_task(void *context) if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) { continue; } - if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) { - web_serial_settings_execute(request.serial_settings_id); + if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) { + if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id); + else web_account_settings_execute(request.account_settings_id); secure_wipe(&request, sizeof(request)); continue; } diff --git a/src/admin_ssh_console.h b/src/admin_ssh_console.h index 9715f20..d958013 100644 --- a/src/admin_ssh_console.h +++ b/src/admin_ssh_console.h @@ -14,8 +14,9 @@ extern "C" { #endif -/* Nonblocking typed Serial-settings admission to the canonical dispatcher. */ +/* Nonblocking typed settings admission to the canonical dispatcher. */ esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id); +esp_err_t admin_ssh_console_submit_account_settings(uint32_t id); /* Fits the longest supported ECDSA P-256 OpenSSH key import command. */ #define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U diff --git a/src/user_database.c b/src/user_database.c index adfbe0b..5136c3d 100644 --- a/src/user_database.c +++ b/src/user_database.c @@ -295,7 +295,7 @@ static esp_err_t set_record_password(stored_user_t *user, return error; } -static esp_err_t generate_password(user_database_generated_password_t *generated) +esp_err_t user_database_generate_password_value(user_database_generated_password_t *generated) { if (generated == NULL) { return ESP_ERR_INVALID_ARG; @@ -788,6 +788,25 @@ esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot) return ESP_OK; } +esp_err_t user_database_get_accounts(user_database_accounts_t *accounts) +{ + if (accounts == NULL) return ESP_ERR_INVALID_ARG; + memset(accounts, 0, sizeof(*accounts)); + if (!s_initialized || s_mutex == NULL) return ESP_ERR_INVALID_STATE; + if (xSemaphoreTake(s_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT; + for (size_t i = 0; i < USER_DATABASE_MAX_USERS; ++i) { + const stored_user_t *user = &s_database.users[i]; + if (!user->active) continue; + user_database_account_t *out = &accounts->users[accounts->count++]; + out->user_id = user->user_id; + out->auth_generation = user->auth_generation; + out->role = (user_role_t)user->role; + memcpy(out->username, user->username, user->username_length); + } + xSemaphoreGive(s_mutex); + return ESP_OK; +} + static void fill_principal(const stored_user_t *user, user_auth_method_t method, user_principal_t *principal) { @@ -986,7 +1005,7 @@ esp_err_t user_database_create_generated( const uint8_t *username, size_t username_length, user_role_t role, user_database_generated_password_t *generated_password) { - esp_err_t error = generate_password(generated_password); + esp_err_t error = user_database_generate_password_value(generated_password); if (error == ESP_OK) { error = user_database_create(username, username_length, role, generated_password->password, @@ -1037,7 +1056,7 @@ esp_err_t user_database_bootstrap_admin(const uint8_t *password, esp_err_t user_database_bootstrap_admin_generated( user_database_generated_password_t *generated_password) { - esp_err_t error = generate_password(generated_password); + esp_err_t error = user_database_generate_password_value(generated_password); if (error == ESP_OK) { error = user_database_bootstrap_admin(generated_password->password, generated_password->password_length); @@ -1062,14 +1081,26 @@ static esp_err_t mutate_user_begin(const uint8_t *username, size_t username_leng return ESP_OK; } -esp_err_t user_database_delete(const uint8_t *username, size_t username_length) +static bool target_matches_locked(const uint8_t *username, size_t length, + const user_database_account_t *expected) +{ + if (!expected) return true; + int index = find_user(&s_database, username, length); + return index >= 0 && expected->user_id != 0 && expected->auth_generation != 0 && + s_database.users[index].user_id == expected->user_id && + s_database.users[index].auth_generation == expected->auth_generation; +} + +static esp_err_t delete_user(const uint8_t *username, size_t username_length, + const user_database_account_t *expected) { if (!s_initialized || s_mutex == NULL) { return ESP_ERR_INVALID_STATE; } xSemaphoreTake(s_mutex, portMAX_DELAY); int index; - esp_err_t error = mutate_user_begin(username, username_length, &index); + esp_err_t error = target_matches_locked(username, username_length, expected) + ? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND; if (error == ESP_OK) { const stored_user_t *user = &s_database.users[index]; bool protected_migrated_admin = @@ -1090,8 +1121,8 @@ esp_err_t user_database_delete(const uint8_t *username, size_t username_length) return error; } -esp_err_t user_database_set_role(const uint8_t *username, size_t username_length, - user_role_t role) +static esp_err_t set_role(const uint8_t *username, size_t username_length, + user_role_t role, const user_database_account_t *expected) { if (!s_initialized || s_mutex == NULL || (role != USER_ROLE_USER && role != USER_ROLE_ADMIN)) { @@ -1099,7 +1130,8 @@ esp_err_t user_database_set_role(const uint8_t *username, size_t username_length } xSemaphoreTake(s_mutex, portMAX_DELAY); int index; - esp_err_t error = mutate_user_begin(username, username_length, &index); + esp_err_t error = target_matches_locked(username, username_length, expected) + ? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND; if (error == ESP_OK) { stored_user_t *user = &s_candidate->users[index]; if (user->role == role) { @@ -1125,8 +1157,37 @@ esp_err_t user_database_set_role(const uint8_t *username, size_t username_length return error; } -esp_err_t user_database_set_password(const uint8_t *username, size_t username_length, - const uint8_t *password, size_t password_length) +esp_err_t user_database_delete(const uint8_t *username, size_t length) +{ + return delete_user(username, length, NULL); +} + +esp_err_t user_database_set_role(const uint8_t *username, size_t length, user_role_t role) +{ + return set_role(username, length, role, NULL); +} + +esp_err_t user_database_delete_current(const user_database_account_t *expected) +{ + if (!expected) return ESP_ERR_INVALID_ARG; + size_t length = strnlen(expected->username, sizeof(expected->username)); + if (!user_database_username_valid((const uint8_t *)expected->username, length)) + return ESP_ERR_INVALID_ARG; + return delete_user((const uint8_t *)expected->username, length, expected); +} + +esp_err_t user_database_set_role_current(const user_database_account_t *expected, user_role_t role) +{ + if (!expected) return ESP_ERR_INVALID_ARG; + size_t length = strnlen(expected->username, sizeof(expected->username)); + if (!user_database_username_valid((const uint8_t *)expected->username, length)) + return ESP_ERR_INVALID_ARG; + return set_role((const uint8_t *)expected->username, length, role, expected); +} + +static esp_err_t set_password(const uint8_t *username, size_t username_length, + const uint8_t *password, size_t password_length, + const user_database_account_t *expected) { if (!s_initialized || s_mutex == NULL || !user_database_password_valid(password, password_length)) { @@ -1134,7 +1195,8 @@ esp_err_t user_database_set_password(const uint8_t *username, size_t username_le } xSemaphoreTake(s_mutex, portMAX_DELAY); int index; - esp_err_t error = mutate_user_begin(username, username_length, &index); + esp_err_t error = target_matches_locked(username, username_length, expected) + ? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND; if (error == ESP_OK) { stored_user_t *user = &s_candidate->users[index]; error = set_record_password(user, password, password_length); @@ -1151,11 +1213,27 @@ esp_err_t user_database_set_password(const uint8_t *username, size_t username_le return error; } +esp_err_t user_database_set_password(const uint8_t *username, size_t username_length, + const uint8_t *password, size_t password_length) +{ + return set_password(username, username_length, password, password_length, NULL); +} + +esp_err_t user_database_set_password_current(const user_database_account_t *expected, + const uint8_t *password, size_t password_length) +{ + if (!expected) return ESP_ERR_INVALID_ARG; + size_t length = strnlen(expected->username, sizeof(expected->username)); + if (!user_database_username_valid((const uint8_t *)expected->username, length)) + return ESP_ERR_INVALID_ARG; + return set_password((const uint8_t *)expected->username, length, password, password_length, expected); +} + esp_err_t user_database_generate_password( const uint8_t *username, size_t username_length, user_database_generated_password_t *generated_password) { - esp_err_t error = generate_password(generated_password); + esp_err_t error = user_database_generate_password_value(generated_password); if (error == ESP_OK) { error = user_database_set_password(username, username_length, generated_password->password, diff --git a/src/user_database.h b/src/user_database.h index b2b23f5..1746eab 100644 --- a/src/user_database.h +++ b/src/user_database.h @@ -105,6 +105,28 @@ esp_err_t user_database_recover_from_legacy( const user_database_legacy_credentials_t *legacy); esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot); +/* Compact secret-free list, zero-wait mutex acquisition; no key material. */ +typedef struct { + uint32_t user_id, auth_generation; + user_role_t role; + char username[USER_DATABASE_USERNAME_CAPACITY + 1U]; +} user_database_account_t; +typedef struct { + size_t count; + user_database_account_t users[USER_DATABASE_MAX_USERS]; +} user_database_accounts_t; +esp_err_t user_database_get_accounts(user_database_accounts_t *accounts); +/* Compare target identity under the mutation lock, before candidate/commit. + * ESP_ERR_NOT_FOUND means absent or stale; existing account invariants apply. */ +esp_err_t user_database_delete_current(const user_database_account_t *expected); +esp_err_t user_database_set_role_current(const user_database_account_t *expected, + user_role_t role); +esp_err_t user_database_set_password_current(const user_database_account_t *expected, + const uint8_t *password, size_t password_length); +/* RNG only: no database initialization, account mutation or persistence. Caller + * owns/wipes successful output; failures clear it. Same generator as CLI. */ +esp_err_t user_database_generate_password_value(user_database_generated_password_t *generated); + esp_err_t user_database_authenticate_password( const uint8_t *username, size_t username_length, const uint8_t *password, size_t password_length, diff --git a/src/web_account_settings.c b/src/web_account_settings.c new file mode 100644 index 0000000..ad88f16 --- /dev/null +++ b/src/web_account_settings.c @@ -0,0 +1,353 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "web_account_settings.h" + +#include +#include +#include +#include "admin_ssh_console.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "secure_random.h" +#include "ssh_transport.h" +#include "web_cookie_auth.h" +#include "web_auth_parse.h" +#include "web_httpd_adapter.h" +#include "web_serial_transport.h" + +enum { IDLE, PENDING, OK, FAILED, CANCELLED, STALE, PROTECTED, DUPLICATE, FULL }; +static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "stale", "protected", "duplicate", "full"}; +typedef enum { ACTION_ROLE, ACTION_DELETE, ACTION_CREATE, ACTION_PASSWORD } account_action_t; +static const char *const s_actions[] = {"role", "delete", "create", "password"}; +typedef struct { + uint32_t id; + web_session_id_t session; + user_principal_t principal; + user_database_account_t target; + int64_t deadline; + user_role_t role; + unsigned state; + account_action_t action; + bool executing; + uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U]; + size_t password_length; +} account_operation_t; +static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; +static account_operation_t s_operation; +static uint32_t s_next_id; +static esp_timer_handle_t s_secret_timer; +static bool s_secret_timer_started; + +static bool credential_action(account_action_t action) +{ + return action == ACTION_CREATE || action == ACTION_PASSWORD; +} + +static void wipe_input(account_operation_t *operation) +{ + secure_wipe(&operation->principal, sizeof(operation->principal)); + secure_wipe(&operation->target, sizeof(operation->target)); + secure_wipe(operation->password, sizeof(operation->password)); + operation->password_length = 0; +} + +static void expire_secret(void *unused) +{ + (void)unused; + taskENTER_CRITICAL(&s_lock); + /* Inspect only the current ID/deadline, never a captured/rearmed job. A late + * tick cannot cancel a replacement before its own deadline or executing work. */ + if (s_operation.id && s_operation.state == PENDING && !s_operation.executing && + credential_action(s_operation.action) && esp_timer_get_time() >= s_operation.deadline) { + s_operation.state = CANCELLED; + wipe_input(&s_operation); + } + taskEXIT_CRITICAL(&s_lock); +} + +static bool ensure_secret_timer(void) +{ + /* HTTPD is the sole admission owner. Once started, this one firmware-lifetime + * timer is never stopped/rearmed/deleted. Expiry is best-effort scheduling, + * not hard realtime; no network/database work runs in its callback. */ + if (!s_secret_timer) { + const esp_timer_create_args_t args = {.callback = expire_secret, .name = "account-secret"}; + if (esp_timer_create(&args, &s_secret_timer) != ESP_OK) return false; + } + if (!s_secret_timer_started) { + if (esp_timer_start_periodic(s_secret_timer, 1000000ULL) != ESP_OK) return false; + s_secret_timer_started = true; + } + return true; +} + +/* Exact flat schemas. Only password accepts JSON escapes; canonical database + * policy validates the decoded bytes. No coercion/unknown/duplicate fields. */ +static bool parse(const char *body, size_t length, account_operation_t *operation) +{ + const char *keys[] = {"action", "username", "user_id", "auth_generation", "role", "password"}; + unsigned seen = 0; + size_t pos = 0; +#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos +#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0) + TAKE('{'); + for (unsigned field = 0; field < 6; ++field) { + if (field) { TAKE(','); } + TAKE('"'); + size_t start = pos; + while (pos < length && body[pos] != '"') ++pos; + if (pos == length) return false; + unsigned key = 0; + for (; key < 6; ++key) + if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break; + if (key == 6 || (seen & (1U << key))) return false; + ++pos; TAKE(':'); SPACE(); + uint32_t number = 0; + char value[USER_DATABASE_USERNAME_CAPACITY + 1] = {0}; + if (key == 2 || key == 3) { + start = pos; + while (pos < length && body[pos] >= '0' && body[pos] <= '9') { + unsigned digit = (unsigned)(body[pos++] - '0'); + if (number > (UINT32_MAX - digit) / 10U) return false; + number = number * 10U + digit; + } + if (!number || pos == start || (pos - start > 1 && body[start] == '0')) return false; + } else if (key == 5) { + if (!web_auth_parse_json_string(body, length, &pos, operation->password, + sizeof(operation->password), &operation->password_length) || + !user_database_password_valid(operation->password, operation->password_length)) return false; + } else { + TAKE('"'); start = pos; + while (pos < length && body[pos] != '"') { + if (body[pos] < ' ' || body[pos] > '~' || body[pos] == '\\' || pos - start >= sizeof(value) - 1) return false; + ++pos; + } + if (pos == length) return false; + memcpy(value, body + start, pos - start); ++pos; + } + switch (key) { + case 0: + { + unsigned action = 0; + for (; action < sizeof(s_actions) / sizeof(*s_actions); ++action) + if (!strcmp(value, s_actions[action])) break; + if (action == sizeof(s_actions) / sizeof(*s_actions)) return false; + operation->action = (account_action_t)action; + } + break; + case 1: + if (!user_database_username_valid((const uint8_t *)value, strlen(value))) return false; + memcpy(operation->target.username, value, sizeof(value)); break; + case 2: operation->target.user_id = number; break; + case 3: operation->target.auth_generation = number; break; + case 4: if (!user_role_parse(value, &operation->role)) return false; break; + } + seen |= 1U << key; + SPACE(); + if (pos < length && body[pos] == '}') break; + } + TAKE('}'); SPACE(); +#undef TAKE +#undef SPACE + const unsigned schemas[] = {31U, 15U, 51U, 47U}; + return pos == length && seen == schemas[operation->action]; +} + +void web_account_settings_execute(uint32_t id) +{ + account_operation_t operation = {0}; + taskENTER_CRITICAL(&s_lock); + bool admitted = id && s_operation.id == id && s_operation.state == PENDING && !s_operation.executing; + if (admitted) { + s_operation.executing = true; + operation = s_operation; + wipe_input(&s_operation); + } + taskEXIT_CRITICAL(&s_lock); + if (!admitted) return; + bool current = false; + esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t); + unsigned state = CANCELLED; + if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN && + esp_timer_get_time() < operation.deadline) { + /* CLI and typed mutations share this dispatcher. Target identity is also + * compared under the database mutation lock, not just at HTTP admission. */ + switch (operation.action) { + case ACTION_ROLE: error = user_database_set_role_current(&operation.target, operation.role); break; + case ACTION_DELETE: error = user_database_delete_current(&operation.target); break; + case ACTION_CREATE: + error = user_database_create((const uint8_t *)operation.target.username, + strlen(operation.target.username), operation.role, operation.password, operation.password_length); + break; + case ACTION_PASSWORD: + error = user_database_set_password_current(&operation.target, operation.password, operation.password_length); + break; + } + secure_wipe(operation.password, sizeof(operation.password)); + operation.password_length = 0; + state = error == ESP_OK ? OK : error == ESP_ERR_NOT_FOUND ? STALE : + error == ESP_ERR_INVALID_STATE ? (operation.action == ACTION_CREATE ? DUPLICATE : PROTECTED) : + error == ESP_ERR_NO_MEM && operation.action == ACTION_CREATE ? FULL : FAILED; + if (error == ESP_OK && operation.action != ACTION_CREATE) { + size_t length = strlen(operation.target.username); + (void)web_serial_transport_revoke_user((const uint8_t *)operation.target.username, length); + (void)ssh_transport_revoke_user((const uint8_t *)operation.target.username, length); + } + } + secure_wipe(operation.password, sizeof(operation.password)); + operation.password_length = 0; + taskENTER_CRITICAL(&s_lock); + if (s_operation.id == id && s_operation.state == PENDING && s_operation.executing) { + s_operation.state = state; + s_operation.executing = false; + wipe_input(&s_operation); + } + taskEXIT_CRITICAL(&s_lock); + secure_wipe(&operation, sizeof(operation)); +} + +static esp_err_t respond(httpd_req_t *request, const char *status, const char *body) +{ + esp_err_t error = httpd_resp_set_status(request, status); + if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8"); + if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff"); + if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer"); + if (error == ESP_OK) error = httpd_resp_sendstr(request, body); + return web_httpd_unread_body(request) ? ESP_FAIL : error; +} + +static esp_err_t list_accounts(httpd_req_t *request) +{ + user_database_accounts_t accounts; + if (user_database_get_accounts(&accounts) != ESP_OK) + return respond(request, "503 Service Unavailable", "{\"error\":\"accounts_unavailable\"}"); + char body[1024]; + size_t used = (size_t)snprintf(body, sizeof(body), "{\"users\":["); + for (size_t i = 0; i < accounts.count; ++i) { + const user_database_account_t *user = &accounts.users[i]; + /* Database username policy makes these ASCII strings JSON-safe. */ + int written = snprintf(body + used, sizeof(body) - used, + "%s{\"username\":\"%s\",\"user_id\":%" PRIu32 ",\"auth_generation\":%" PRIu32 ",\"role\":\"%s\"}", + i ? "," : "", user->username, user->user_id, user->auth_generation, user_role_to_string(user->role)); + if (written < 0 || (size_t)written >= sizeof(body) - used) return ESP_FAIL; + used += (size_t)written; + } + if (used + 3 > sizeof(body)) return ESP_FAIL; + memcpy(body + used, "]}", 3); + return respond(request, "200 OK", body); +} + +esp_err_t web_account_generate_password_handler(httpd_req_t *request) +{ + web_session_view_t view = {0}; + user_database_generated_password_t generated = {0}; + char response[96] = {0}; + bool allowed = false; + esp_err_t error = web_cookie_auth_require(request, true, false, &view, &allowed); + if (error != ESP_OK || !allowed) goto done; + if (view.principal.role != USER_ROLE_ADMIN) { + error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); + goto done; + } + error = user_database_generate_password_value(&generated); + if (error != ESP_OK) { + secure_wipe(&generated, sizeof(generated)); + error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}"); + goto done; + } + bool current = false; + error = web_session_store_check_principal(view.id, &view.principal, ¤t); + if (error != ESP_OK || !current) { + secure_wipe(&generated, sizeof(generated)); + error = respond(request, "401 Unauthorized", "{\"error\":\"authentication_required\"}"); + goto done; + } + int written = snprintf(response, sizeof(response), "{\"password\":\"%s\"}", (const char *)generated.password); + secure_wipe(&generated, sizeof(generated)); + error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response); +done: + secure_wipe(&generated, sizeof(generated)); + secure_wipe(response, sizeof(response)); + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); + return error; +} + +esp_err_t web_account_settings_handler(httpd_req_t *request) +{ + web_session_view_t view = {0}; + account_operation_t operation = {0}; + bool allowed = false, mutation = request->method == HTTP_POST; + esp_err_t error = mutation ? web_cookie_auth_require_json(request, 768, &view, &allowed) : + web_cookie_auth_require(request, false, false, &view, &allowed); + if (error != ESP_OK || !allowed) goto done; + if (view.principal.role != USER_ROLE_ADMIN) { + error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); + goto done; + } + if (!strcmp(request->uri, "/api/settings/accounts")) { + error = mutation ? respond(request, "400 Bad Request", "{\"error\":\"invalid_request\"}") : list_accounts(request); + goto done; + } + if (mutation) { + char type[40] = {0}, body[768]; + size_t received = 0; + bool valid = request->content_len && request->content_len <= sizeof(body) && + httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK && + (!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8")); + for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) { + int count = httpd_req_recv(request, body + received, request->content_len - received); + if (count <= 0 || (size_t)count > request->content_len - received) valid = false; + else received += (size_t)count; + } + valid = valid && received == request->content_len && parse(body, received, &operation); + secure_wipe(body, sizeof(body)); + if (!valid) { + wipe_input(&operation); + error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}"); + goto done; + } + if (credential_action(operation.action) && !ensure_secret_timer()) { + wipe_input(&operation); + error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}"); + goto done; + } + operation.session = view.id; + operation.principal = view.principal; + operation.deadline = esp_timer_get_time() + 30000000LL; + operation.state = PENDING; + taskENTER_CRITICAL(&s_lock); + bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX; + if (!busy) { operation.id = ++s_next_id; s_operation = operation; } + taskEXIT_CRITICAL(&s_lock); + if (busy || admin_ssh_console_submit_account_settings(operation.id) != ESP_OK) { + taskENTER_CRITICAL(&s_lock); + if (!busy && s_operation.id == operation.id && !s_operation.executing) + secure_wipe(&s_operation, sizeof(s_operation)); + taskEXIT_CRITICAL(&s_lock); + wipe_input(&operation); + error = httpd_resp_set_hdr(request, "Retry-After", "1"); + if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}"); + goto done; + } + } else { + taskENTER_CRITICAL(&s_lock); + if (s_operation.session == view.id) { + operation.id = s_operation.id; + operation.action = s_operation.action; + operation.state = s_operation.state; + } + taskEXIT_CRITICAL(&s_lock); + } + wipe_input(&operation); + char response[96]; + int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}", + operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]); + error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : + respond(request, mutation ? "202 Accepted" : "200 OK", response); +done: + secure_wipe(&operation, sizeof(operation)); + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); + return error; +} diff --git a/src/web_account_settings.h b/src/web_account_settings.h new file mode 100644 index 0000000..fd3efe1 --- /dev/null +++ b/src/web_account_settings.h @@ -0,0 +1,12 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#pragma once +#include +#include "esp_http_server.h" + +/* One session-bound pending/result slot. Dispatcher execution only; completed + * results are replaceable, not durable history or an idempotent retry API. */ +esp_err_t web_account_settings_handler(httpd_req_t *request); +void web_account_settings_execute(uint32_t id); +/* POST /api/settings/accounts/generate-password; bodyless admin cookie + + * Origin/CSRF. RNG only, no queued/account/persistent state or retrieval. */ +esp_err_t web_account_generate_password_handler(httpd_req_t *request); diff --git a/src/web_auth_parse.c b/src/web_auth_parse.c index f157a2b..5886eb3 100644 --- a/src/web_auth_parse.c +++ b/src/web_auth_parse.c @@ -208,6 +208,23 @@ static bool string(json_cursor_t *c, uint8_t *out, size_t capacity, size_t *leng return true; } +bool web_auth_parse_json_string(const char *body, size_t length, size_t *position, + uint8_t *output, size_t capacity, size_t *decoded_length) +{ + if (output && capacity) wipe(output, capacity); + if (decoded_length) *decoded_length = 0; + if (!body || !position || *position > length || !output || !capacity || !decoded_length) + return false; + json_cursor_t c = { (const uint8_t *)body, length, *position }; + if (!string(&c, output, capacity - 1U, decoded_length)) { + wipe(output, capacity); + *decoded_length = 0; + return false; + } + *position = c.pos; + return true; +} + bool web_auth_parse_login(const char *body, size_t length, web_auth_credentials_t *credentials) { diff --git a/src/web_auth_parse.h b/src/web_auth_parse.h index b5a1e20..21657b6 100644 --- a/src/web_auth_parse.h +++ b/src/web_auth_parse.h @@ -40,6 +40,11 @@ bool web_auth_parse_cookie(const char *header, size_t length, const char *name, * lets HTTP policy distinguish absence from malformed/ambiguous cookies. */ bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name, char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present); +/* Decode one string at *position (including optional JSON whitespace). Capacity + * includes the terminator. Failure wipes output and leaves position unchanged. + * Success output is sensitive; caller must wipe it. Same strict decoder as login. */ +bool web_auth_parse_json_string(const char *body, size_t length, size_t *position, + uint8_t *output, size_t capacity, size_t *decoded_length); /* Exactly username/password string fields, either order. JSON escapes and valid * UTF-8 accepted; unknown/duplicate fields, NUL and malformed Unicode rejected. * Database credential policy remains authoritative. Caller must wipe BOTH the diff --git a/src/web_server.c b/src/web_server.c index 325bdf2..fbfa159 100644 --- a/src/web_server.c +++ b/src/web_server.c @@ -24,6 +24,7 @@ #include "web_security.h" #include "web_serial_transport.h" #include "web_serial_settings.h" +#include "web_account_settings.h" #include "web_admin_transport.h" #include "web_session_store.h" #include "web_cookie_auth.h" @@ -388,6 +389,20 @@ static const httpd_uri_t s_serial_operation_post_uri = { .handler = web_serial_settings_handler, }; +static const httpd_uri_t s_accounts_uri = { + .uri = "/api/settings/accounts", .method = HTTP_GET, .handler = web_account_settings_handler, +}; +static const httpd_uri_t s_account_operation_get_uri = { + .uri = "/api/settings/account-operation", .method = HTTP_GET, .handler = web_account_settings_handler, +}; +static const httpd_uri_t s_account_operation_post_uri = { + .uri = "/api/settings/account-operation", .method = HTTP_POST, .handler = web_account_settings_handler, +}; +static const httpd_uri_t s_account_generate_password_uri = { + .uri = "/api/settings/accounts/generate-password", .method = HTTP_POST, + .handler = web_account_generate_password_handler, +}; + static const httpd_uri_t s_root_uri = { .uri = "/", .method = HTTP_GET, @@ -576,7 +591,7 @@ esp_err_t web_server_start(void) config.httpd.max_open_sockets = 6; config.httpd.max_uri_handlers = sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) + - sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 5U; + sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 9U; /* Exhaustion rejects new sockets, never evicts an existing serial writer. */ config.httpd.lru_purge_enable = false; config.httpd.recv_wait_timeout = 1; @@ -627,6 +642,11 @@ esp_err_t web_server_start(void) if (web_httpd_register_optional(server, &s_serial_operation_get_uri) == ESP_OK && web_httpd_register_optional(server, &s_serial_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_serial_operation_get_uri.uri, HTTP_GET); + if (web_httpd_register_optional_get(server, &s_accounts_uri) == ESP_OK && + web_httpd_register_optional_get(server, &s_account_operation_get_uri) == ESP_OK && + web_httpd_register_optional(server, &s_account_operation_post_uri) != ESP_OK) + (void)httpd_unregister_uri_handler(server, s_account_operation_get_uri.uri, HTTP_GET); + (void)web_httpd_register_optional(server, &s_account_generate_password_uri); } if (error != ESP_OK) { web_cookie_auth_stop(); diff --git a/src/web_ui.c b/src/web_ui.c index 53ffed3..776e8e7 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -178,7 +178,9 @@ static const char s_index_html[] = "
\n" "\n" "\n" "\n" "\n" @@ -281,6 +312,7 @@ static const char s_app_js[] = " element('serial-result').disabled = busy;\n" "}\n" "function clearSettings() {\n" + " clearAccounts();\n" " if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n" " stopSerialAuto(true);\n" " if (settingsAbort) settingsAbort.abort();\n" @@ -292,6 +324,7 @@ static const char s_app_js[] = " element('refresh-settings').disabled = false; settingsDetail.textContent = 'Select Refresh to read current values.';\n" "}\n" "async function refreshSettings() {\n" + " if (settingsDomain === 'accounts') return refreshAccounts();\n" " if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n" " settingsHost.hidden = false;\n" " const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n" @@ -317,6 +350,7 @@ static const char s_app_js[] = " }\n" "}\n" "async function serialOperation(action, automatic = false) {\n" + " if (settingsDomain !== 'serial') return;\n" " if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || (action && serialOperationPending)) return;\n" " if (!automatic && serialAuto) return;\n" " const detail = element('serial-operation-detail');\n" @@ -383,6 +417,163 @@ static const char s_app_js[] = " }\n" " }\n" "}\n" + "let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n" + "let secretEpoch = 0, secretAbort = null, secretTimer = null, generatedPassword = '', generatedContext = '', savedContext = '', secretExpires = 0;\n" + "function secretContext() { const t = accounts[Number(element('account-target').value)]; return JSON.stringify([element('account-purpose').value, element('account-username').value, element('account-create-role').value, t?.username, t?.user_id, t?.auth_generation]); }\n" + "function invalidateSecretRequest() { ++secretEpoch; if (secretAbort) secretAbort.abort(); secretAbort = null; savedContext = ''; element('account-password-saved').checked = false; }\n" + "function clearAccountSecret() {\n" + " invalidateSecretRequest(); window.clearTimeout(secretTimer); secretTimer = null; secretExpires = 0; generatedPassword = generatedContext = '';\n" + " for (const id of ['account-password','account-password-confirm','account-generated']) element(id).value = '';\n" + " element('account-generated-panel').hidden = true; element('account-secret-detail').textContent = '';\n" + "}\n" + "async function generateAccountPassword() {\n" + " if (!accountsLive() || accountsAbort || accountPending || secretAbort) return;\n" + " clearAccountSecret(); const context = secretContext(), epoch = secretEpoch, generation = workGeneration, controller = new AbortController(); secretAbort = controller; accountButtons();\n" + " const current = () => secretAbort === controller && secretEpoch === epoch && context === secretContext() && accountsLive();\n" + " let payload;\n" + " try {\n" + " if (!await loadSession(generation, controller.signal, false) || !current()) return;\n" + " ({payload} = await api('/api/settings/accounts/generate-password', generation, {method: 'POST', signal: controller.signal, limit: 96, current}));\n" + " if (!payload || Object.keys(payload).length !== 1 || typeof payload.password !== 'string' || !/^[A-Za-z0-9_-]{24}$/.test(payload.password)) throw new Error('Invalid generated password');\n" + " generatedPassword = payload.password; generatedContext = context; secretExpires = performance.now() + 60000;\n" + " element('account-password').value = element('account-generated').value = generatedPassword; element('account-generated-panel').hidden = false;\n" + " secretTimer = window.setTimeout(() => { clearAccountSecret(); accountButtons(); }, 60000);\n" + " } catch (error) { if (current()) { clearAccountSecret(); element('account-secret-detail').textContent = 'Password generation unavailable. Nothing applied. Retry only explicitly.'; } }\n" + " finally { if (payload) payload.password = ''; payload = null; if (secretAbort === controller) secretAbort = null; accountButtons(); }\n" + "}\n" + "element('account-purpose').value = 'create'; element('account-create-role').value = 'user';\n" + "function accountsLive() { return selected === 'settings' && settingsDomain === 'accounts' && accountRole === 'admin' && sessionVerified && !suspended && !unloading && !navigating && !loggingOut; }\n" + "function accountButtons() {\n" + " const busy = !!accountsAbort, target = accounts[Number(element('account-target').value)];\n" + " const blocked = busy || accountPending || !target;\n" + " element('account-delete').disabled = element('account-change-role').disabled = blocked;\n" + " element('account-target').disabled = element('account-role').disabled = busy || accountPending || !accounts.length;\n" + " element('account-result').disabled = element('refresh-accounts').disabled = busy;\n" + " const create = element('account-purpose').value === 'create';\n" + " element('account-username-label').hidden = element('account-create-role-label').hidden = !create;\n" + " element('account-generate').disabled = busy || accountPending || !!secretAbort || (!create && !target);\n" + " element('account-submit-password').disabled = busy || accountPending || !!secretAbort || (!create && !target);\n" + " for (const id of ['account-purpose','account-username','account-create-role','account-password','account-password-confirm','account-password-saved']) element(id).disabled = busy || accountPending;\n" + "}\n" + "function clearAccounts() {\n" + " clearAccountSecret();\n" + " if (accountsAbort) accountsAbort.abort();\n" + " accountsAbort = null; accounts = []; element('accounts-list').textContent = '';\n" + " for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = ''; option.hidden = option.disabled = true; }\n" + " if (accountPending) element('account-operation-detail').textContent = accountWarning + 'Outcome pending or unknown. Check Result on return; navigation does not cancel backend work.';\n" + " accountButtons();\n" + "}\n" + "function selectSettingsDomain(domain) {\n" + " if (!sessionVerified || accountRole !== 'admin' || selected !== 'settings' || domain === settingsDomain) return;\n" + " clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\n" + " element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts';\n" + " element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n" + " refreshSettings();\n" + "}\n" + "async function refreshAccounts() {\n" + " if (!accountsLive() || accountsAbort) return;\n" + " clearAccountSecret();\n" + " const controller = new AbortController(), generation = workGeneration; accountsAbort = controller; accountButtons();\n" + " const current = () => accountsAbort === controller && accountsLive();\n" + " element('accounts-detail').textContent = 'Reading accounts; previous list may be stale.';\n" + " try {\n" + " if (!await loadSession(generation, controller.signal, false) || !current()) return;\n" + " const {payload} = await api('/api/settings/accounts', generation, {signal: controller.signal, limit: 1024, current});\n" + " const validId = n => Number.isInteger(n) && n > 0 && n <= 4294967295;\n" + " if (!payload || Object.keys(payload).length !== 1 || !Array.isArray(payload.users) || payload.users.length > 8 ||\n" + " !payload.users.every(u => u && Object.keys(u).length === 4 && typeof u.username === 'string' && /^[a-z][a-z0-9_-]{0,15}$/.test(u.username) && validId(u.user_id) && validId(u.auth_generation) && ['user','admin'].includes(u.role)) ||\n" + " new Set(payload.users.map(u => u.username)).size !== payload.users.length) throw new Error('Invalid account list');\n" + " clearAccountSecret(); accounts = payload.users;\n" + " element('accounts-list').textContent = accounts.map(u => u.username + ' — ' + u.role + (u.username === sessionIdentity.username ? ' (you)' : '')).join('\\n');\n" + " for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = accounts[i]?.username || ''; option.hidden = option.disabled = !accounts[i]; }\n" + " element('account-target').value = '0'; element('account-role').value = accounts[0]?.role || 'user';\n" + " element('accounts-detail').textContent = accountPending ? 'List may be stale while operation outcome is pending or unknown.' : 'Account list refreshed. Select an account before changing it.';\n" + " } catch (error) { if (live(generation) && current()) element('accounts-detail').textContent = (error.status ? error.message : 'Account list unavailable or invalid.') + ' List stale. Refresh to retry.'; }\n" + " finally { if (current()) { accountsAbort = null; accountButtons(); } }\n" + "}\n" + "async function accountOperation(action) {\n" + " if (!accountsLive() || accountsAbort || (action && accountPending)) return;\n" + " let body;\n" + " if (action) {\n" + " const target = accounts[Number(element('account-target').value)], role = element('account-role').value;\n" + " const credential = action === 'create' || action === 'password';\n" + " let request;\n" + " if (credential) {\n" + " try {\n" + " const password = element('account-password').value, username = action === 'create' ? element('account-username').value : target?.username, initialRole = element('account-create-role').value;\n" + " if (secretAbort || !/^[a-z][a-z0-9_-]{0,15}$/.test(username || '') || !['user','admin'].includes(initialRole) || typeof password !== 'string' || !/^[ -~]{12,64}$/.test(password) || password !== element('account-password-confirm').value ||\n" + " (generatedPassword && (performance.now() >= secretExpires || password !== generatedPassword || generatedContext !== secretContext() || savedContext !== generatedContext || !element('account-password-saved').checked))) {\n" + " element('account-operation-detail').textContent = 'Not submitted. Check username, password length/ASCII, confirmation and generated-password saved acknowledgement; re-enter secrets.'; return;\n" + " }\n" + " request = action === 'create' ? {action, username, role: initialRole, password} : {action, username, user_id: target.user_id, auth_generation: target.auth_generation, password};\n" + " body = JSON.stringify(request);\n" + " } finally { clearAccountSecret(); if (request) request.password = ''; request = null; }\n" + " } else {\n" + " clearAccountSecret(); if (!target || !['role','delete'].includes(action) || !['user','admin'].includes(role)) return;\n" + " body = JSON.stringify({action, username: target.username, user_id: target.user_id, auth_generation: target.auth_generation, ...(action === 'role' ? {role} : {})});\n" + " }\n" + " if (encoder.encode(body).length > 768) { body = undefined; return; }\n" + " const name = action === 'create' ? element('account-username').value : target.username;\n" + " const warning = name === sessionIdentity.username ? ' ALL this account’s web/SSH sessions, including this browser serial/admin, can close immediately (even a no-op role change). A 401 or disconnect is NOT proof of success. Save the password before submitting, then re-login and inspect if the result is lost.' : '';\n" + " if (!window.confirm((action === 'delete' ? 'Delete ' : action === 'create' ? 'Create ' + element('account-create-role').value + ' account ' : action === 'password' ? 'Change password for ' : 'Change role to ' + role + ' for ') + name + '? Saved immediately; affected account sessions may be revoked.' + warning)) { body = undefined; return; }\n" + " }\n" + " const controller = new AbortController(), generation = workGeneration; accountsAbort = controller; accountButtons();\n" + " const current = () => accountsAbort === controller && accountsLive();\n" + " controller.signal.addEventListener('abort', () => { body = undefined; }, {once: true});\n" + " const detail = element('account-operation-detail'); let deadline, refresh = false, until = Infinity;\n" + " detail.textContent = accountWarning + (action ? 'Submitting once...' : 'Reading latest result...');\n" + " element('accounts-detail').textContent = 'List may be stale until operation completes and refresh succeeds.';\n" + " const read = async (method, requestBody) => {\n" + " const response = api('/api/settings/account-operation', generation, {method, body: requestBody, signal: controller.signal, limit: 96, current: () => current() && performance.now() < until}); requestBody = undefined;\n" + " const {payload: result} = await response;\n" + " if (performance.now() >= until) throw new Error('Check deadline');\n" + " if (!result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 || !['none','role','delete','create','password'].includes(result.action) ||\n" + " !['idle','pending','ok','failed','cancelled','stale','protected','duplicate','full'].includes(result.state) || ((result.id === 0) !== (result.state === 'idle')) || ((result.id === 0) !== (result.action === 'none')) ||\n" + " (method === 'POST' && (!result.id || result.action !== action || result.state !== 'pending'))) throw new Error('Invalid operation result');\n" + " if (method === 'POST') accountWarning = '';\n" + " else if (accountAwaitingAck) accountWarning = 'Acknowledgement lost: latest result may belong to another request. Inspect before retrying. ';\n" + " else if (accountId && result.id !== accountId) accountWarning = 'Previous result replaced or unavailable; outcome unknown. ';\n" + " accountId = result.id; accountPending = result.state === 'pending'; accountAwaitingAck = false;\n" + " const messages = {duplicate: 'Username already exists. Refresh before retrying.', full: 'Account capacity full. Inspect accounts before retrying.', idle: 'No retained result. Inspect accounts before retrying.', pending: 'Queued or executing...', ok: 'Account change completed and saved.', failed: 'Operation failed. Inspect accounts before retrying.', cancelled: 'Cancelled before execution: login or queue deadline stale.', stale: 'Account changed or was replaced. Refresh and select it again.', protected: 'Account is protected (including the final administrator), or database unavailable.'};\n" + " detail.textContent = accountWarning + result.action + ': ' + messages[result.state];\n" + " return result.state;\n" + " };\n" + " try {\n" + " if (!await loadSession(generation, controller.signal, false) || !current()) return;\n" + " if (action) { accountPending = true; accountAwaitingAck = true; }\n" + " const response = read(action ? 'POST' : 'GET', body); body = undefined;\n" + " let state = await response;\n" + " if (action) {\n" + " until = performance.now() + 15000; deadline = window.setTimeout(() => controller.abort(), 15000);\n" + " controller.signal.addEventListener('abort', () => window.clearTimeout(deadline), {once: true});\n" + " for (let attempt = 0; state === 'pending' && attempt < 10; ++attempt) {\n" + " await new Promise((resolve, reject) => {\n" + " const abort = () => { window.clearTimeout(timer); controller.signal.removeEventListener('abort', abort); reject(new Error('Cancelled')); };\n" + " const timer = window.setTimeout(() => { controller.signal.removeEventListener('abort', abort); resolve(); }, 1000);\n" + " controller.signal.addEventListener('abort', abort, {once: true}); if (controller.signal.aborted) abort();\n" + " });\n" + " if (!current() || performance.now() >= until) throw new Error('Check deadline');\n" + " if (!await loadSession(generation, controller.signal, false) || !current() || performance.now() >= until) throw new Error('Session changed or check deadline');\n" + " state = await read('GET');\n" + " }\n" + " }\n" + " if (state === 'pending') detail.textContent += ' Automatic checking stopped. Use Check Result; do not resubmit.';\n" + " refresh = state !== 'pending' && state !== 'idle';\n" + " } catch (error) { if (live(generation) && current()) detail.textContent = accountWarning + (error.status ? error.message : 'Outcome unknown.') + ' Use Check Result and Refresh before an explicit retry. No automatic retry.'; }\n" + " finally { body = undefined; window.clearTimeout(deadline); if (current()) { accountsAbort = null; accountButtons(); if (refresh) await refreshAccounts(); } }\n" + "}\n" + "element('settings-serial').addEventListener('click', () => selectSettingsDomain('serial'));\n" + "element('settings-accounts').addEventListener('click', () => selectSettingsDomain('accounts'));\n" + "element('refresh-accounts').addEventListener('click', refreshAccounts);\n" + "element('account-result').addEventListener('click', () => accountOperation(null));\n" + "element('account-delete').addEventListener('click', () => accountOperation('delete'));\n" + "element('account-change-role').addEventListener('click', () => accountOperation('role'));\n" + "element('account-generate').addEventListener('click', generateAccountPassword);\n" + "element('account-submit-password').addEventListener('click', () => accountOperation(element('account-purpose').value));\n" + "element('account-password-saved').addEventListener('change', () => { savedContext = element('account-password-saved').checked && generatedPassword && element('account-password').value === generatedPassword && generatedContext === secretContext() && performance.now() < secretExpires ? generatedContext : ''; });\n" + "for (const id of ['account-password','account-password-confirm']) element(id).addEventListener('input', () => { invalidateSecretRequest(); accountButtons(); });\n" + "for (const id of ['account-purpose','account-username','account-create-role','account-role']) element(id).addEventListener(id === 'account-username' ? 'input' : 'change', () => { clearAccountSecret(); accountButtons(); });\n" + "element('account-target').addEventListener('change', () => { clearAccountSecret(); element('account-role').value = accounts[Number(element('account-target').value)]?.role || 'user'; accountButtons(); });\n" "let accountRole = 'user', selected = 'serial';\n" "let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n" "let adminGeneration = 0, adminTimer = null;\n" @@ -576,9 +767,10 @@ static const char s_app_js[] = " const timeout = window.setTimeout(abort, 15000);\n" " try {\n" /* Non-CORS POST with no-referrer serializes Origin as null in browsers. */ - " const response = await fetch(path, {method, credentials: 'same-origin', mode: method === 'POST' ? 'cors' : 'same-origin',\n" + " const pendingResponse = fetch(path, {method, credentials: 'same-origin', mode: method === 'POST' ? 'cors' : 'same-origin',\n" " cache: 'no-store', redirect: 'error', signal: controller.signal,\n" " ...(method === 'POST' ? {headers: {'X-CSRF-Token': csrf, ...(body === undefined ? {} : {'Content-Type': 'application/json'})}, body: body === undefined ? '' : body} : {})});\n" + " body = undefined; const response = await pendingResponse;\n" " if (!live(generation) || controller.signal.aborted || !current()) throw new Error('Cancelled');\n" " if (response.status === 401) { login(); throw new Error('Session ended.'); }\n" " if (!response.ok) {\n" diff --git a/tests/admin_console_boundary/accounts.c b/tests/admin_console_boundary/accounts.c index c5b3b34..8d862c0 100644 --- a/tests/admin_console_boundary/accounts.c +++ b/tests/admin_console_boundary/accounts.c @@ -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,¤t)==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 #include #include @@ -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"] diff --git a/tests/admin_console_boundary/fakes.h b/tests/admin_console_boundary/fakes.h index 18150ab..3064773 100644 --- a/tests/admin_console_boundary/fakes.h +++ b/tests/admin_console_boundary/fakes.h @@ -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); diff --git a/tests/admin_console_boundary/test.c b/tests/admin_console_boundary/test.c index 60744e3..4803e84 100644 --- a/tests/admin_console_boundary/test.c +++ b/tests/admin_console_boundary/test.c @@ -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"); } diff --git a/tests/web_admin_transport/server_lifecycle.py b/tests/web_admin_transport/server_lifecycle.py index b2dd2ad..7b006f2 100644 --- a/tests/web_admin_transport/server_lifecycle.py +++ b/tests/web_admin_transport/server_lifecycle.py @@ -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; } ''' diff --git a/tests/web_auth_parse/run.py b/tests/web_auth_parse/run.py index 63f728b..d040c35 100644 --- a/tests/web_auth_parse/run.py +++ b/tests/web_auth_parse/run.py @@ -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") diff --git a/tests/web_cookie_auth/README.md b/tests/web_cookie_auth/README.md index 0164c24..09f0e39 100644 --- a/tests/web_cookie_auth/README.md +++ b/tests/web_cookie_auth/README.md @@ -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 diff --git a/tests/web_cookie_auth/account_settings_test.c b/tests/web_cookie_auth/account_settings_test.c new file mode 100644 index 0000000..f869821 --- /dev/null +++ b/tests/web_cookie_auth/account_settings_test.c @@ -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=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 +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) diff --git a/tests/web_cookie_auth/test.c b/tests/web_cookie_auth/test.c index 60fd66b..14add3f 100644 --- a/tests/web_cookie_auth/test.c +++ b/tests/web_cookie_auth/test.c @@ -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; } diff --git a/tests/web_ui_session/README.md b/tests/web_ui_session/README.md index 9c79ac8..51b2f5c 100644 --- a/tests/web_ui_session/README.md +++ b/tests/web_ui_session/README.md @@ -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 12–64 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. diff --git a/tests/web_ui_session/browser.cjs b/tests/web_ui_session/browser.cjs index 354c7df..26f56fb 100644 --- a/tests/web_ui_session/browser.cjs +++ b/tests/web_ui_session/browser.cjs @@ -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 = ''} = {}) { 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:'',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', '']) { + 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; }); diff --git a/tests/web_ui_session/run.py b/tests/web_ui_session/run.py index dfe92c5..3331be0 100644 --- a/tests/web_ui_session/run.py +++ b/tests/web_ui_session/run.py @@ -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)