Add typed account and password settings

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

Validated host suites and pio run; hardware validation remains pending.
This commit is contained in:
2026-09-08 09:27:02 +02:00
parent 42548f6334
commit 94433ef975
30 changed files with 1864 additions and 48 deletions
+8
View File
@@ -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. 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) ## 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. `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.
+4
View File
@@ -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.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) ### 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`. - **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`.
+6
View File
@@ -4,6 +4,12 @@ This file is working memory. Update it during active work and before handoff; do
## Development state ## 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 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. - **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.
+8
View File
@@ -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`. **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 ## 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. **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.
+109
View File
@@ -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.
+6
View File
@@ -1,5 +1,11 @@
# Phase 8D — Incremental web administration plan # 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. **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. **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.
+1
View File
@@ -32,6 +32,7 @@ idf_component_register(
"web_security.c" "web_security.c"
"web_serial_transport.c" "web_serial_transport.c"
"web_serial_settings.c" "web_serial_settings.c"
"web_account_settings.c"
"web_admin_tickets.c" "web_admin_tickets.c"
"web_admin_transport.c" "web_admin_transport.c"
"web_assets_data.c" "web_assets_data.c"
+16 -2
View File
@@ -17,6 +17,7 @@
#include "secure_random.h" #include "secure_random.h"
#include "user_database.h" #include "user_database.h"
#include "web_serial_settings.h" #include "web_serial_settings.h"
#include "web_account_settings.h"
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U #define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U #define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
@@ -83,6 +84,7 @@ typedef enum {
ADMIN_REQUEST_UART0, ADMIN_REQUEST_UART0,
ADMIN_REQUEST_DEFERRED, ADMIN_REQUEST_DEFERRED,
ADMIN_REQUEST_SERIAL_SETTINGS, ADMIN_REQUEST_SERIAL_SETTINGS,
ADMIN_REQUEST_ACCOUNT_SETTINGS,
} admin_request_origin_t; } admin_request_origin_t;
typedef struct { typedef struct {
@@ -94,6 +96,7 @@ typedef struct {
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
admin_control_request_t deferred; admin_control_request_t deferred;
uint32_t serial_settings_id; uint32_t serial_settings_id;
uint32_t account_settings_id;
}; };
} admin_request_t; } 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; 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) static void worker_task(void *context)
{ {
(void)context; (void)context;
@@ -667,8 +680,9 @@ static void worker_task(void *context)
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) { if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
continue; continue;
} }
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) { if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) {
web_serial_settings_execute(request.serial_settings_id); 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)); secure_wipe(&request, sizeof(request));
continue; continue;
} }
+2 -1
View File
@@ -14,8 +14,9 @@
extern "C" { extern "C" {
#endif #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_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. */ /* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U #define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
+90 -12
View File
@@ -295,7 +295,7 @@ static esp_err_t set_record_password(stored_user_t *user,
return error; 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) { if (generated == NULL) {
return ESP_ERR_INVALID_ARG; return ESP_ERR_INVALID_ARG;
@@ -788,6 +788,25 @@ esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot)
return ESP_OK; 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, static void fill_principal(const stored_user_t *user, user_auth_method_t method,
user_principal_t *principal) 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, const uint8_t *username, size_t username_length, user_role_t role,
user_database_generated_password_t *generated_password) 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) { if (error == ESP_OK) {
error = user_database_create(username, username_length, role, error = user_database_create(username, username_length, role,
generated_password->password, 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( esp_err_t user_database_bootstrap_admin_generated(
user_database_generated_password_t *generated_password) 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) { if (error == ESP_OK) {
error = user_database_bootstrap_admin(generated_password->password, error = user_database_bootstrap_admin(generated_password->password,
generated_password->password_length); 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; 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) { if (!s_initialized || s_mutex == NULL) {
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
} }
xSemaphoreTake(s_mutex, portMAX_DELAY); xSemaphoreTake(s_mutex, portMAX_DELAY);
int index; 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) { if (error == ESP_OK) {
const stored_user_t *user = &s_database.users[index]; const stored_user_t *user = &s_database.users[index];
bool protected_migrated_admin = bool protected_migrated_admin =
@@ -1090,8 +1121,8 @@ esp_err_t user_database_delete(const uint8_t *username, size_t username_length)
return error; return error;
} }
esp_err_t user_database_set_role(const uint8_t *username, size_t username_length, static esp_err_t set_role(const uint8_t *username, size_t username_length,
user_role_t role) user_role_t role, const user_database_account_t *expected)
{ {
if (!s_initialized || s_mutex == NULL || if (!s_initialized || s_mutex == NULL ||
(role != USER_ROLE_USER && role != USER_ROLE_ADMIN)) { (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); xSemaphoreTake(s_mutex, portMAX_DELAY);
int index; 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) { if (error == ESP_OK) {
stored_user_t *user = &s_candidate->users[index]; stored_user_t *user = &s_candidate->users[index];
if (user->role == role) { 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; return error;
} }
esp_err_t user_database_set_password(const uint8_t *username, size_t username_length, esp_err_t user_database_delete(const uint8_t *username, size_t length)
const uint8_t *password, size_t password_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 || if (!s_initialized || s_mutex == NULL ||
!user_database_password_valid(password, password_length)) { !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); xSemaphoreTake(s_mutex, portMAX_DELAY);
int index; 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) { if (error == ESP_OK) {
stored_user_t *user = &s_candidate->users[index]; stored_user_t *user = &s_candidate->users[index];
error = set_record_password(user, password, password_length); 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; 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( esp_err_t user_database_generate_password(
const uint8_t *username, size_t username_length, const uint8_t *username, size_t username_length,
user_database_generated_password_t *generated_password) 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) { if (error == ESP_OK) {
error = user_database_set_password(username, username_length, error = user_database_set_password(username, username_length,
generated_password->password, generated_password->password,
+22
View File
@@ -105,6 +105,28 @@ esp_err_t user_database_recover_from_legacy(
const user_database_legacy_credentials_t *legacy); const user_database_legacy_credentials_t *legacy);
esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot); 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( esp_err_t user_database_authenticate_password(
const uint8_t *username, size_t username_length, const uint8_t *username, size_t username_length,
const uint8_t *password, size_t password_length, const uint8_t *password, size_t password_length,
+353
View File
@@ -0,0 +1,353 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_account_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#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, &current);
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, &current);
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;
}
+12
View File
@@ -0,0 +1,12 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#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);
+17
View File
@@ -208,6 +208,23 @@ static bool string(json_cursor_t *c, uint8_t *out, size_t capacity, size_t *leng
return true; 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, bool web_auth_parse_login(const char *body, size_t length,
web_auth_credentials_t *credentials) web_auth_credentials_t *credentials)
{ {
+5
View File
@@ -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. */ * lets HTTP policy distinguish absence from malformed/ambiguous cookies. */
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name, bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present); 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 /* Exactly username/password string fields, either order. JSON escapes and valid
* UTF-8 accepted; unknown/duplicate fields, NUL and malformed Unicode rejected. * UTF-8 accepted; unknown/duplicate fields, NUL and malformed Unicode rejected.
* Database credential policy remains authoritative. Caller must wipe BOTH the * Database credential policy remains authoritative. Caller must wipe BOTH the
+21 -1
View File
@@ -24,6 +24,7 @@
#include "web_security.h" #include "web_security.h"
#include "web_serial_transport.h" #include "web_serial_transport.h"
#include "web_serial_settings.h" #include "web_serial_settings.h"
#include "web_account_settings.h"
#include "web_admin_transport.h" #include "web_admin_transport.h"
#include "web_session_store.h" #include "web_session_store.h"
#include "web_cookie_auth.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, .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 = { static const httpd_uri_t s_root_uri = {
.uri = "/", .uri = "/",
.method = HTTP_GET, .method = HTTP_GET,
@@ -576,7 +591,7 @@ esp_err_t web_server_start(void)
config.httpd.max_open_sockets = 6; config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers = config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) + 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. */ /* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false; config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1; 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 && 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) 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); (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) { if (error != ESP_OK) {
web_cookie_auth_stop(); web_cookie_auth_stop();
+195 -3
View File
@@ -178,7 +178,9 @@ static const char s_index_html[] =
"<div id=\"terminal\" class=\"terminal-host\"></div>\n" "<div id=\"terminal\" class=\"terminal-host\"></div>\n"
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n" "<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
"<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>" "<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>"
"<h2>Serial</h2><p class=\"connection-detail\">Working UART1 configuration below is not a saved NVS snapshot. " "<div class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button></div>"
"<div id=\"serial-settings-content\"><h2>Serial</h2><p class=\"connection-detail\">Working UART1 configuration below is not a saved NVS snapshot. "
"Navigation leaves both terminals connected and preserves the serial writer lease.</p>" "Navigation leaves both terminals connected and preserves the serial writer lease.</p>"
"<button id=\"refresh-settings\" class=\"button\" type=\"button\">Refresh</button>" "<button id=\"refresh-settings\" class=\"button\" type=\"button\">Refresh</button>"
"<p id=\"settings-detail\" class=\"connection-detail\" role=\"status\">Select Refresh to read current values.</p>" "<p id=\"settings-detail\" class=\"connection-detail\" role=\"status\">Select Refresh to read current values.</p>"
@@ -212,7 +214,36 @@ static const char s_index_html[] =
"<button id=\"serial-start\" class=\"button\" type=\"button\">Start</button>" "<button id=\"serial-start\" class=\"button\" type=\"button\">Start</button>"
"<button id=\"serial-stop\" class=\"button\" type=\"button\">Stop</button></div></div>" "<button id=\"serial-stop\" class=\"button\" type=\"button\">Stop</button></div></div>"
"<button id=\"serial-result\" class=\"button\" type=\"button\">Check Operation Result</button>" "<button id=\"serial-result\" class=\"button\" type=\"button\">Check Operation Result</button>"
"<p id=\"serial-operation-detail\" class=\"connection-detail\" role=\"status\">Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.</p>" "<p id=\"serial-operation-detail\" class=\"connection-detail\" role=\"status\">Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.</p></div>"
"<div id=\"account-settings\" hidden><h2>Accounts</h2>"
"<p class=\"connection-detail\">Role and deletion changes are saved immediately and revoke only the affected account's network sessions. The final administrator is protected. "
"Self changes can immediately close all this account's web/SSH sessions, including browser serial/admin. A disconnect or 401 is NOT proof of success; re-login and inspect before retrying.</p>"
"<button id=\"refresh-accounts\" class=\"button\" type=\"button\">Refresh accounts</button>"
"<p id=\"accounts-detail\" class=\"connection-detail\" role=\"status\">Select Refresh to read accounts.</p>"
"<pre id=\"accounts-list\"></pre><div class=\"serial-edit\">"
"<label>Account<select id=\"account-target\">"
"<option id=\"account-option-0\" value=\"0\"></option><option id=\"account-option-1\" value=\"1\"></option>"
"<option id=\"account-option-2\" value=\"2\"></option><option id=\"account-option-3\" value=\"3\"></option>"
"<option id=\"account-option-4\" value=\"4\"></option><option id=\"account-option-5\" value=\"5\"></option>"
"<option id=\"account-option-6\" value=\"6\"></option><option id=\"account-option-7\" value=\"7\"></option></select></label>"
"<label>New role<select id=\"account-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label></div>"
"<div class=\"serial-actions\"><button id=\"account-change-role\" class=\"button\" type=\"button\">Change role</button>"
"<button id=\"account-delete\" class=\"button\" type=\"button\">Delete account</button>"
"<button id=\"account-result\" class=\"button\" type=\"button\">Check Result</button></div>"
"<div class=\"serial-edit\"><label>Purpose<select id=\"account-purpose\"><option value=\"create\">Create account</option><option value=\"password\">Change selected account password</option></select></label>"
"<label id=\"account-username-label\">New username<input id=\"account-username\" maxlength=\"16\" autocomplete=\"off\"></label>"
"<label id=\"account-create-role-label\">Initial role<select id=\"account-create-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label>"
"<label>Password<input id=\"account-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\"></label>"
"<label>Confirm password<input id=\"account-password-confirm\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\"></label></div>"
"<p class=\"connection-detail\">12-64 printable ASCII characters; spaces are significant. Confirm every password. Save it before submitting, especially for your own account.</p>"
"<button id=\"account-generate\" class=\"button\" type=\"button\">Generate password (not applied)</button>"
"<div id=\"account-generated-panel\" hidden><label>Generated password — not applied yet<input id=\"account-generated\" readonly autocomplete=\"off\"></label>"
"<p>One-time display, no retrieval. Copy and save now; this display and password fields expire after 60 seconds. Enter it in Confirm password.</p>"
"<label><input id=\"account-password-saved\" type=\"checkbox\">I saved this exact password for this operation and account.</label></div>"
"<p class=\"connection-detail\">Secrets are cleared on submission and when leaving or changing this form. JavaScript cannot securely zero strings or browser-managed copies.</p>"
"<p id=\"account-secret-detail\" class=\"connection-detail\" role=\"status\"></p>"
"<button id=\"account-submit-password\" class=\"button\" type=\"button\">Submit create / password change</button>"
"<p id=\"account-operation-detail\" class=\"connection-detail\" role=\"status\">Changes are submitted once. Completion is checked automatically; uncertain results require manual recovery.</p></div>"
"</section>\n" "</section>\n"
"</section>\n" "</section>\n"
"</main>\n" "</main>\n"
@@ -281,6 +312,7 @@ static const char s_app_js[] =
" element('serial-result').disabled = busy;\n" " element('serial-result').disabled = busy;\n"
"}\n" "}\n"
"function clearSettings() {\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" " 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" " stopSerialAuto(true);\n"
" if (settingsAbort) settingsAbort.abort();\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" " element('refresh-settings').disabled = false; settingsDetail.textContent = 'Select Refresh to read current values.';\n"
"}\n" "}\n"
"async function refreshSettings() {\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" " if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
" settingsHost.hidden = false;\n" " settingsHost.hidden = false;\n"
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n" " const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
@@ -317,6 +350,7 @@ static const char s_app_js[] =
" }\n" " }\n"
"}\n" "}\n"
"async function serialOperation(action, automatic = false) {\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 (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || (action && serialOperationPending)) return;\n"
" if (!automatic && serialAuto) return;\n" " if (!automatic && serialAuto) return;\n"
" const detail = element('serial-operation-detail');\n" " const detail = element('serial-operation-detail');\n"
@@ -383,6 +417,163 @@ static const char s_app_js[] =
" }\n" " }\n"
" }\n" " }\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 accounts web/SSH sessions, including this browser serial/admin, can close immediately (even a no-op role change). A 401 or disconnect is NOT proof of success. Save the password before submitting, then re-login and inspect if the result is lost.' : '';\n"
" if (!window.confirm((action === 'delete' ? 'Delete ' : action === 'create' ? 'Create ' + element('account-create-role').value + ' account ' : action === 'password' ? 'Change password for ' : 'Change role to ' + role + ' for ') + name + '? Saved immediately; affected account sessions may be revoked.' + warning)) { body = undefined; return; }\n"
" }\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 accountRole = 'user', selected = 'serial';\n"
"let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n" "let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n"
"let adminGeneration = 0, adminTimer = 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" " const timeout = window.setTimeout(abort, 15000);\n"
" try {\n" " try {\n"
/* Non-CORS POST with no-referrer serializes Origin as null in browsers. */ /* 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" " 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" " ...(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 (!live(generation) || controller.signal.aborted || !current()) throw new Error('Cancelled');\n"
" if (response.status === 401) { login(); throw new Error('Session ended.'); }\n" " if (response.status === 401) { login(); throw new Error('Session ended.'); }\n"
" if (!response.ok) {\n" " if (!response.ok) {\n"
+101
View File
@@ -39,8 +39,109 @@ static void unchanged(const stored_database_t *before)
assert(all_zero(s_candidate,sizeof(*s_candidate))); assert(all_zero(s_candidate,sizeof(*s_candidate)));
assert(!locks); assert(!locks);
} }
static void typed_account_tests(void)
{
reset(); user_database_accounts_t list;
assert(user_database_get_accounts(&list)==ESP_OK && last_wait==0 && list.count==3);
assert(!strcmp(list.users[1].username,"other"));
user_database_account_t other=list.users[1], admin=list.users[0];
snapshot_busy=true; memset(&list,0xff,sizeof(list));
assert(user_database_get_accounts(&list)==ESP_ERR_TIMEOUT && all_zero(&list,sizeof(list)));
snapshot_busy=false;
assert(user_database_delete_current(&admin)==ESP_ERR_INVALID_STATE);
assert(user_database_set_role_current(&admin,USER_ROLE_USER)==ESP_ERR_INVALID_STATE);
assert(!writes && !commits);
assert(user_database_set_role_current(&other,USER_ROLE_ADMIN)==ESP_OK);
unsigned saved=commits;
assert(user_database_delete_current(&other)==ESP_ERR_NOT_FOUND && commits==saved);
assert(user_database_set_role_current(&other,USER_ROLE_USER)==ESP_ERR_NOT_FOUND);
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
for (fail_stage=1;fail_stage<=3;++fail_stage) {
stored_database_t before=s_database;
assert(user_database_delete_current(&other)==ESP_FAIL); unchanged(&before);
assert(user_database_set_role_current(&other,USER_ROLE_USER)==ESP_FAIL); unchanged(&before);
}
fail_stage=0; assert(user_database_delete_current(&other)==ESP_OK);
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,(const uint8_t *)"test-password",13)==ESP_OK);
assert(user_database_delete_current(&other)==ESP_ERR_NOT_FOUND);
assert(user_database_set_role_current(&other,USER_ROLE_ADMIN)==ESP_ERR_NOT_FOUND);
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
assert(user_database_delete_current(&other)==ESP_OK);
assert(all_zero(s_candidate,sizeof(*s_candidate)) && !locks);
s_initialized=false; memset(&list,0xff,sizeof(list));
assert(user_database_get_accounts(&list)==ESP_ERR_INVALID_STATE && all_zero(&list,sizeof(list)));
assert(user_database_delete_current(NULL)==ESP_ERR_INVALID_ARG);
}
static void typed_password_tests(void)
{
reset(); user_database_accounts_t list;
assert(user_database_get_accounts(&list)==ESP_OK);
user_database_account_t other=list.users[1], admin=list.users[0];
const uint8_t password[]="quote\"slash\\ space";
for (unsigned stage=1;stage<=5;++stage) {
fail_stage=stage; stored_database_t before=s_database;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_FAIL);
unchanged(&before);
}
fail_stage=0; writes=commits=0;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_OK);
assert(writes==1 && commits==1 && s_database.users[1].auth_generation==other.auth_generation+1);
assert(all_zero(s_candidate,sizeof(*s_candidate)));
stored_database_t before=s_database; unsigned rng=random_calls;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
unchanged(&before); assert(writes==1 && commits==1 && random_calls==rng);
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
assert(user_database_delete_current(&other)==ESP_OK);
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,password,sizeof(password)-1)==ESP_OK);
before=s_database; rng=random_calls;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
unchanged(&before); assert(random_calls==rng);
assert(user_database_set_password_current(NULL,password,sizeof(password)-1)==ESP_ERR_INVALID_ARG);
other.user_id=0;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
memset(other.username,'x',sizeof(other.username));
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_INVALID_ARG);
assert(user_database_set_password_current(&admin,(const uint8_t *)"short",5)==ESP_ERR_INVALID_ARG);
/* Own password is allowed even for the last administrator; old principal is stale. */
assert(user_database_set_password_current(&admin,password,sizeof(password)-1)==ESP_OK);
bool current=true; assert(user_database_principal_is_current(&actor,&current)==ESP_OK && !current);
assert(s_database.admin_count==1);
/* With a second admin, canonical self role/delete invariants allow both. */
assert(user_database_set_role((const uint8_t *)"other",5,USER_ROLE_ADMIN)==ESP_OK);
assert(user_database_get_accounts(&list)==ESP_OK); admin=list.users[0];
assert(user_database_set_role_current(&admin,USER_ROLE_USER)==ESP_OK);
assert(user_database_get_accounts(&list)==ESP_OK); admin=list.users[0];
assert(user_database_delete_current(&admin)==ESP_OK);
reset(); before=s_database; rng=random_calls;
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_ADMIN,password,sizeof(password)-1)==ESP_ERR_INVALID_STATE);
unchanged(&before); assert(!writes && !commits && rng==random_calls);
for (unsigned i=3;i<USER_DATABASE_MAX_USERS;++i) {
char name[17]; snprintf(name,sizeof(name),"account%u",i);
assert(user_database_create((const uint8_t *)name,strlen(name),USER_ROLE_USER,password,sizeof(password)-1)==ESP_OK);
}
before=s_database; rng=random_calls; unsigned saved=commits;
assert(user_database_create((const uint8_t *)"extra",5,USER_ROLE_USER,password,sizeof(password)-1)==ESP_ERR_NO_MEM);
unchanged(&before); assert(commits==saved && rng==random_calls);
/* RNG-only helper is independent of initialized storage and leaves all DB state alone. */
s_initialized=false; s_mutex=NULL;
for (unsigned mode=0;mode<2;++mode) {
user_database_generated_password_t generated; memset(&generated,0xa5,sizeof(generated));
fail_stage=mode ? 4 : 0;
assert(user_database_generate_password_value(&generated)==(mode ? ESP_FAIL : ESP_OK));
if (mode) assert(all_zero(&generated,sizeof(generated)));
else {
assert(generated.password_length==24 && strlen((const char *)generated.password)==24);
for (size_t i=0;i<24;++i) assert(strchr((const char *)s_generated_alphabet,generated.password[i]));
}
assert(!memcmp(&before,&s_database,sizeof(before)) && commits==saved && !locks);
secure_wipe(&generated,sizeof(generated));
}
assert(user_database_generate_password_value(NULL)==ESP_ERR_INVALID_ARG);
}
int main(void) int main(void)
{ {
typed_account_tests();
typed_password_tests();
const char *supported[]={ const char *supported[]={
"user add fresh user", "user add fresh admin", "user password other", "user add fresh user", "user add fresh admin", "user password other",
"user delete other --force", "user role other admin --force", "user delete other --force", "user role other admin --force",
+11 -4
View File
@@ -24,6 +24,7 @@ db = (ROOT / "src/user_database.c").read_text()
console = (ROOT / "src/user_console.c").read_text() console = (ROOT / "src/user_console.c").read_text()
admin = (ROOT / "src/admin_ssh_console.c").read_text() admin = (ROOT / "src/admin_ssh_console.c").read_text()
prelude = r''' prelude = r'''
#define _POSIX_C_SOURCE 200809L
#include <assert.h> #include <assert.h>
#include <stdbool.h> #include <stdbool.h>
#include <stdint.h> #include <stdint.h>
@@ -33,7 +34,10 @@ prelude = r'''
typedef int esp_err_t; typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE, 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_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; typedef void *SemaphoreHandle_t;
#define portMAX_DELAY 0 #define portMAX_DELAY 0
#define NVS_READWRITE 1 #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 int notify_error = ESP_OK;
static char revoked_name[17]; static char revoked_name[17];
static void secure_wipe(void *p, size_t n) { memset(p, 0, n); } 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 void xSemaphoreGive(void *m) { (void)m; assert(locks-- == 1); }
static const char *esp_err_to_name(int e) { (void)e; return "injected error"; } 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) { 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", "find_free_user", "stored_keys_equal", "validate_database", "recount",
"next_generation", "discard_candidate", "commit_candidate_locked", "initialize_user", "next_generation", "discard_candidate", "commit_candidate_locked", "initialize_user",
"user_database_principal_is_current", "create_locked", "user_database_create", "user_database_principal_is_current", "create_locked", "user_database_create",
"mutate_user_begin", "user_database_delete", "user_database_set_role", "mutate_user_begin", "target_matches_locked", "delete_user", "set_role",
"user_database_set_password"] "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", console_names = ["print_usage", "revoke_user_network_sessions", "read_password",
"show_generated_password", "mutation_currentness", "add_user", "change_password", "show_generated_password", "mutation_currentness", "add_user", "change_password",
"parse_key_index", "command_user_inner", "command_user"] "parse_key_index", "command_user_inner", "command_user"]
+2 -1
View File
@@ -31,7 +31,8 @@ typedef int *SemaphoreHandle_t;
#define pdMS_TO_TICKS(x) (x) #define pdMS_TO_TICKS(x) (x)
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U #define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
static unsigned lock_depth, ticks, runs, actions; 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 unsigned serial_settings_preceding_runs, queue_send_wait;
static void web_serial_settings_execute(uint32_t id) { static void web_serial_settings_execute(uint32_t id) {
assert(!lock_depth); assert(!lock_depth);
+11
View File
@@ -340,5 +340,16 @@ int main(void)
pump(worker_task); pump(worker_task);
assert(runs == before_serial + 5 && serial_settings_executed == 17 && !s_request_queue->count); 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"); 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"); puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
} }
+68 -13
View File
@@ -37,8 +37,8 @@ def define(path, name):
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};', uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
source, re.M | re.S) source, re.M | re.S)
# Non-array declarations have no brackets; explicit shape avoids silent omission. # Non-array declarations have no brackets; explicit shape avoids silent omission.
if len(uri_tables) != 16: if len(uri_tables) != 20:
raise RuntimeError('Review URI extraction: expected 14 descriptors and two tables') raise RuntimeError('Review URI extraction: expected 18 descriptors and two tables')
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'): state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
source.index('static esp_err_t ensure_mutex(void)')] source.index('static esp_err_t ensure_mutex(void)')]
header = (ROOT / 'src/web_server.h').read_text() 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(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler) HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler)
HANDLER(serial_settings_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 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_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; } 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) { 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(!locked && auth_live && !ssl_live); ++ssl_starts;
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable); 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->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
assert(config->tls_handshake_timeout_ms == 5000); assert(config->tls_handshake_timeout_ms == 5000);
assert(config->servercert_len == 1 && config->servercert[0] == 1); 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; } if (error == ESP_OK) { assert(registered_count < 32); registered[registered_count++] = uri; }
return error; 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) { 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")); assert(!strcmp(uri->uri, "/api/settings/serial"));
return httpd_register_uri_handler(s, uri); return httpd_register_uri_handler(s, uri);
} }
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *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(s == SERVER && auth_live && ssl_live && !locked);
assert(!strcmp(uri->uri, "/api/settings/serial-operation") && uri->handler == web_serial_settings_handler); 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; 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) { 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(!locked && s == SERVER && ssl_live && auth_live && serial_live);
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) || 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; ++unregister_calls;
for (unsigned i = 0; i < registered_count; ++i) { for (unsigned i = 0; i < registered_count; ++i) {
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) { 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; registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
unregister_fail = settings_fail = false; settings_calls = 0; clear_events(); unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
operation_calls = operation_fail_at = 0; 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 fresh_registration(void) { registration_calls = registered_count = 0; }
static void start(void) { 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"); 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); assert(route("/api/settings/serial")->handler == serial_settings_handler);
const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin"); 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); 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(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops); 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(!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) for (unsigned i = 0; i < registered_count; ++i)
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin")); assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
assert(route("/ws/serial")->handler == websocket_handler); 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")); clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds); assert(!admin_detaches && !admin_stoppeds);
registration_fail_at = 0; fresh_registration(); start(); 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); 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"); 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; 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(auth_live && ssl_live && serial_live && s_serial_transport_attached);
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned); assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
ticket = route("/api/admin/ws-ticket"); ticket = route("/api/admin/ws-ticket");
@@ -340,7 +361,7 @@ int main(void) {
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH")); clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
assert(!admin_detaches && !admin_stoppeds); assert(!admin_detaches && !admin_stoppeds);
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start(); 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"); 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; 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); assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection"); puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
reset(); settings_fail = true; start(); 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); assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
settings_fail = false; fresh_registration(); start(); settings_fail = false; fresh_registration(); start();
assert(route("/api/settings/serial")->handler == serial_settings_handler); 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"); puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
for (unsigned failure = 1; failure <= 2; ++failure) { for (unsigned failure = 1; failure <= 2; ++failure) {
reset(); operation_fail_at = failure; start(); 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); assert(auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation")); for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
assert(web_server_stop() == ESP_OK); assert(web_server_stop() == ESP_OK);
} }
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports"); 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; return 0;
} }
''' '''
+26
View File
@@ -163,6 +163,32 @@ def main():
b, out = span(body), Credentials() b, out = span(body), Credentials()
check("login", str(index), lambda: api.web_auth_parse_login(b, size(body), C.byref(out)), check("login", str(index), lambda: api.web_auth_parse_login(b, size(body), C.byref(out)),
out, expected, decoded) 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: for failure in failures:
print("FAIL:", failure) print("FAIL:", failure)
print(f"{count} cases; {len(failures)} failures") print(f"{count} cases; {len(failures)} failures")
+30
View File
@@ -16,6 +16,36 @@ This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, brows
See `docs/phase8d3_implementation.md` for source verification, other suite commands, build accounting and the target checklist. 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 ## Read-only Serial Settings
```sh ```sh
@@ -0,0 +1,377 @@
/* Production auth/store/handler, deterministic dispatcher and DB/transport doubles.
* Actual conditional database transactions are tested by accounts.py. */
#define ESP_ERR_TIMEOUT 0x107
#define ESP_ERR_NOT_FOUND 0x105
static unsigned wiped_passwords, wiped_responses, wiped_generated, wiped_bodies;
static const uint8_t *executing_password;
static void account_wipe(void *p, size_t n) {
if (n==65) ++wiped_passwords;
if (n==96) ++wiped_responses;
if (n==sizeof(user_database_generated_password_t)) ++wiped_generated;
if (n==768) ++wiped_bodies;
secure_wipe(p,n); zero(p,n);
if (p==executing_password) executing_password=NULL;
}
#define secure_wipe account_wipe
#include "account_parse_production.h"
#include "../../src/web_account_settings.c"
#undef secure_wipe
static unsigned timer_creates, timer_starts;
static bool timer_create_fail, timer_start_fail;
static void (*timer_callback)(void *);
int esp_timer_create(const esp_timer_create_args_t *args, esp_timer_handle_t *out) {
assert(!host_lock_depth && !s_secret_timer); ++timer_creates;
if (timer_create_fail) return ESP_FAIL;
timer_callback=args->callback; *out=(void *)1; return ESP_OK;
}
int esp_timer_start_periodic(esp_timer_handle_t timer, uint64_t period) {
assert(!host_lock_depth && timer==s_secret_timer && period==1000000); ++timer_starts;
return timer_start_fail ? ESP_FAIL : ESP_OK;
}
static bool dispatcher, queue_fail, list_fail;
static uint32_t queued;
static unsigned mutations, web_revokes, ssh_revokes, lists;
static esp_err_t mutation_error;
static void (*mutation_hook)(void);
static void (*queue_hook)(void);
static bool self_target;
static void check_slot_wiped(void) {
zero(s_operation.password,sizeof(s_operation.password)); assert(!s_operation.password_length);
zero(&s_operation.principal,sizeof(s_operation.principal));
zero(&s_operation.target,sizeof(s_operation.target));
}
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id) {
assert(!host_lock_depth && !dispatcher && id);
if (queue_hook) { void (*hook)(void)=queue_hook; queue_hook=NULL; hook(); }
if (queue_fail) return ESP_ERR_TIMEOUT;
queued=id; return ESP_OK;
}
esp_err_t user_database_get_accounts(user_database_accounts_t *out) {
assert(!host_lock_depth && !dispatcher); ++lists; memset(out,0,sizeof(*out));
if (list_fail) return ESP_ERR_TIMEOUT;
out->count=8;
for (unsigned i=0;i<8;++i) {
snprintf(out->users[i].username,sizeof(out->users[i].username),"account%09u",i);
out->users[i].role=USER_ROLE_ADMIN; out->users[i].user_id=UINT32_MAX-i;
out->users[i].auth_generation=UINT32_MAX;
}
return ESP_OK;
}
esp_err_t user_database_delete_current(const user_database_account_t *target) {
assert(dispatcher && !host_lock_depth && !strcmp(target->username,self_target ? "alice" : "carol"));
assert(s_operation.executing); check_slot_wiped();
assert(target->user_id==7 && target->auth_generation==2); ++mutations;
if (mutation_hook) { void (*hook)(void)=mutation_hook; mutation_hook=NULL; hook(); }
return mutation_error;
}
esp_err_t user_database_set_role_current(const user_database_account_t *target,user_role_t role) {
assert(role==USER_ROLE_ADMIN); return user_database_delete_current(target);
}
esp_err_t user_database_create(const uint8_t *u,size_t n,user_role_t role,const uint8_t *p,size_t pn) {
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,"carol",5) && role==USER_ROLE_ADMIN);
assert(user_database_password_valid(p,pn)); check_slot_wiped(); ++mutations;
if (mutation_hook) { void (*hook)(void)=mutation_hook; mutation_hook=NULL; hook(); }
return mutation_error;
}
esp_err_t user_database_set_password_current(const user_database_account_t *target,const uint8_t *p,size_t pn) {
assert(user_database_password_valid(p,pn)); executing_password=p;
return user_database_delete_current(target);
}
esp_err_t web_serial_transport_revoke_user(const uint8_t *u,size_t n) {
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,self_target ? "alice" : "carol",5));
assert(!executing_password);
if (self_target) web_session_store_invalidate_username(u,n);
++web_revokes; return ESP_FAIL;
}
esp_err_t ssh_transport_revoke_user(const uint8_t *u,size_t n) {
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,self_target ? "alice" : "carol",5)); ++ssh_revokes; return ESP_FAIL;
}
static const char deletion[]="{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}";
static const char role_body[]="{\"action\":\"role\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"role\":\"admin\"}";
static void account_begin(const issued_t *identity,const char *body) {
begin("/api/settings/account-operation",body?HTTP_POST:HTTP_GET,body); same_origin();
if (body) add("Content-Type","application/json");
if (identity) {
char cookie[100]; snprintf(cookie,sizeof(cookie),"__Host-sak-session=%s",identity->token); add("Cookie",cookie);
if (body) add("X-CSRF-Token",identity->view.csrf);
}
}
static void account_expect(const char *status) {
unsigned before=mutations;
esp_err_t error=web_account_settings_handler(&req);
assert(error==(send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
if (strcmp(status,response_status)) fprintf(stderr,"expected %s got %s\n",status,response_status);
assert(!strcmp(status,response_status) && mutations==before);
assert(strlen(output)<1024); zero(scratch,sizeof(scratch));
}
static void submit_account(const issued_t *identity,const char *body) {
account_begin(identity,body); account_expect("202 Accepted"); assert(s_operation.id==queued && s_operation.state==PENDING);
}
static void execute_account(void) { dispatcher=true; web_account_settings_execute(queued); dispatcher=false; }
static void invalidate_actor(void) { web_session_store_invalidate(s_operation.session); }
static const char create_body[]="{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"password1234\"}";
static const char password_body[]="{\"action\":\"password\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"password\":\"password1234\"}";
static void generate_begin(const issued_t *identity) {
account_begin(identity,""); req.uri="/api/settings/accounts/generate-password";
}
static void generate_expect(const char *status) {
account_operation_t before=s_operation;
unsigned ids=s_next_id, calls=mutations, response_wipes=wiped_responses, generated_wipes=wiped_generated;
esp_err_t result=web_account_generate_password_handler(&req);
assert(result==((send_fail || fail_header || aux.remaining_len) ? ESP_FAIL : ESP_OK));
if (!fail_header) assert(!strcmp(status,response_status));
assert(!memcmp(&before,&s_operation,sizeof(before)) && ids==s_next_id && mutations==calls);
assert(wiped_responses==response_wipes+1 && wiped_generated>generated_wipes);
zero(scratch,sizeof(scratch));
}
static void generated_tests(void) {
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob);
unsigned rng=rng_calls;
generate_begin(NULL); generate_expect("401 Unauthorized");
generate_begin(&user); generate_expect("403 Forbidden");
for (unsigned mode=0;mode<9;++mode) {
generate_begin(&admin);
if (mode==0) { req.content_len=aux.remaining_len=1; }
if (mode==1) req.method=HTTP_GET;
if (mode==2) req.uri="/api/settings/accounts/generate-password?x=1";
if (mode==3) add("Origin","https://evil.example");
if (mode==4) add("X-CSRF-Token","duplicate");
if (mode==5) add("Transfer-Encoding","chunked");
if (mode==6) add("Sec-Fetch-Site","cross-site");
if (mode==7) stale_user=alice.user_id;
if (mode==8) db_fail=true;
(void)web_account_generate_password_handler(&req);
assert(response_status[0]=='4' && rng_calls==rng);
stale_user=0; db_fail=false;
}
admin=mint(&alice); rng=rng_calls;
generate_begin(&admin); generate_expect("200 OK");
assert(rng_calls==rng+1 && strlen(output)==39 && !strncmp(output,"{\"password\":\"",13));
for (unsigned i=13;i<37;++i) assert(strchr((const char *)s_generated_alphabet,output[i]));
bool no_store=false;
for (unsigned i=0;i<aux.resp_hdrs_count;++i)
if (!strcmp(response_headers[i].field,"Cache-Control")) no_store=!strcmp(response_headers[i].value,"no-store");
assert(no_store);
rng_fail=true; generate_begin(&admin); generate_expect("503 Service Unavailable"); rng_fail=false;
assert(!strstr(output,"password"));
send_fail=true; generate_begin(&admin); generate_expect("200 OK"); send_fail=false;
fail_header=1; setter_calls=0; generate_begin(&admin); generate_expect(""); fail_header=0;
unsigned header_capacity=server.config.max_resp_headers;
for (unsigned capacity=0;capacity<3;++capacity) {
generate_begin(&admin); server.config.max_resp_headers=capacity;
unsigned wipes=wiped_responses, sent=sends;
assert(web_account_generate_password_handler(&req)==ESP_ERR_HTTPD_RESP_HDR);
assert(sends==sent && wiped_responses==wipes+1);
}
server.config.max_resp_headers=header_capacity;
hook_id=admin.view.id; rng_hook=invalidate_hook;
generate_begin(&admin); generate_expect("401 Unauthorized"); assert(!strstr(output,"password"));
admin=mint(&alice);
/* Generate even with the operation slot occupied; it must not touch it. */
submit_account(&admin,create_body); generate_begin(&admin); generate_expect("200 OK"); execute_account();
web_session_store_invalidate(admin.view.id);
rng=rng_calls; generate_begin(&admin); generate_expect("401 Unauthorized"); assert(rng_calls==rng);
puts("PASS Accounts generated value: real RNG helper/auth/store, bodyless/admin/currentness/Origin/CSRF, base64url/no-store, no slot/DB write, success/RNG/header/send cleanup");
}
static void expire_queued(void) { now=s_operation.deadline; timer_callback(NULL); }
static void executing_tick(void) {
assert(s_operation.executing); check_slot_wiped();
now=s_operation.deadline; timer_callback(NULL);
assert(s_operation.executing && s_operation.state==PENDING);
web_account_settings_execute(s_operation.id); /* Duplicate dispatcher delivery. */
}
static void validation_tick(void) {
assert(s_operation.executing); check_slot_wiped();
timer_callback(NULL); assert(s_operation.state==PENDING);
}
static issued_t busy_actor;
static void executing_busy_request(void) {
assert(dispatcher && s_operation.executing);
dispatcher=false; account_begin(&busy_actor,create_body); account_expect("503 Service Unavailable"); dispatcher=true;
assert(s_operation.executing); check_slot_wiped();
}
static void credential_tests(void) {
auth_reset(); issued_t admin=mint(&alice); receive_fragment=768;
unsigned before=mutations;
timer_create_fail=true; account_begin(&admin,create_body); account_expect("503 Service Unavailable"); timer_create_fail=false;
assert(!s_secret_timer && mutations==before);
timer_start_fail=true; account_begin(&admin,password_body); account_expect("503 Service Unavailable"); timer_start_fail=false;
assert(s_secret_timer && !s_secret_timer_started && mutations==before);
submit_account(&admin,password_body); assert(timer_creates==2 && timer_starts==2);
uint32_t old=queued; assert(s_operation.password_length==12);
now=s_operation.deadline-1; timer_callback(NULL); assert(s_operation.state==PENDING);
++now; timer_callback(NULL); assert(s_operation.state==CANCELLED); check_slot_wiped();
submit_account(&admin,create_body); timer_callback(NULL); assert(s_operation.state==PENDING);
dispatcher=true; web_account_settings_execute(old); dispatcher=false;
assert(s_operation.state==PENDING && mutations==before);
execute_account(); assert(mutations==before+1 && s_operation.state==OK); check_slot_wiped();
assert(timer_creates==2 && timer_starts==2);
/* Timeout between publication and queue submission leaves only a stale ID. */
queue_hook=expire_queued; account_begin(&admin,password_body); account_expect("202 Accepted");
assert(s_operation.state==CANCELLED); execute_account(); assert(mutations==before+1); check_slot_wiped();
queue_fail=true; account_begin(&admin,password_body); account_expect("503 Service Unavailable"); queue_fail=false;
zero(&s_operation,sizeof(s_operation));
submit_account(&admin,password_body); db_hook=validation_tick; execute_account();
assert(s_operation.state==OK && !executing_password); check_slot_wiped();
submit_account(&admin,password_body); mutation_hook=executing_tick; execute_account();
assert(s_operation.state==OK && !s_operation.executing && !executing_password);
/* Tick during external validation, followed by deadline check: cancelled. */
submit_account(&admin,password_body); db_hook=executing_tick; before=mutations; execute_account();
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
submit_account(&admin,password_body); web_session_store_invalidate(admin.view.id); execute_account();
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
admin=mint(&alice);
submit_account(&admin,password_body); stale_user=alice.user_id; execute_account(); stale_user=0;
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
admin=mint(&alice);
submit_account(&admin,password_body); db_fail=true; execute_account(); db_fail=false;
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
admin=mint(&alice);
const esp_err_t errors[]={ESP_ERR_NOT_FOUND,ESP_FAIL,ESP_ERR_INVALID_STATE,ESP_ERR_NO_MEM};
const unsigned states[]={STALE,FAILED,DUPLICATE,FULL};
unsigned revokes=web_revokes;
for (unsigned i=0;i<4;++i) {
mutation_error=errors[i]; submit_account(&admin,i<2?password_body:create_body); execute_account();
assert(s_operation.state==states[i] && web_revokes==revokes && !executing_password); check_slot_wiped();
}
mutation_error=ESP_OK;
send_fail=true; account_begin(&admin,password_body); account_expect("202 Accepted"); send_fail=false;
assert(s_operation.password_length==12); execute_account(); assert(s_operation.state==OK); check_slot_wiped();
/* Keep executing ownership even if another request arrives during validation. */
busy_actor=admin; submit_account(&admin,password_body); db_hook=executing_busy_request; execute_account();
assert(s_operation.state==OK); check_slot_wiped();
queue_fail=true; queue_hook=expire_queued;
account_begin(&admin,create_body); account_expect("503 Service Unavailable"); queue_fail=false;
zero(&s_operation,sizeof(s_operation));
assert(timer_creates==2 && timer_starts==2);
puts("PASS Accounts credential queue: timer create/start failure, explicit expiry, replacement/old IDs, queue failure, validation/execution ticks, stale/dead sessions, error and lost-ack cleanup");
}
static void password_parser_tests(void) {
auth_reset(); issued_t admin=mint(&alice); receive_fragment=768;
char body[800];
const char *valid[]={"password1234", "space space ", "quote\\\"slash\\\\", "\\u0020\\u0022\\u005c123456789", "slash\\/1234567"};
for (unsigned i=0;i<sizeof(valid)/sizeof(*valid);++i) {
snprintf(body,sizeof(body),"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"%s\"}",valid[i]);
account_operation_t parsed={0}; assert(parse(body,strlen(body),&parsed));
assert(user_database_password_valid(parsed.password,parsed.password_length));
if (i==2) assert(parsed.password_length==12 && !memcmp(parsed.password,"quote\"slash\\",12));
secure_wipe(&parsed,sizeof(parsed));
submit_account(&admin,body); execute_account(); assert(s_operation.state==OK);
}
const char *invalid[]={"", "12345678901", "12345678901\\n", "12345678901\\t", "12345678901\\u0000", "12345678901\\u007f", "12345678901\\u0080", "12345678901\\uD800", "12345678901\\x20"};
for (unsigned i=0;i<sizeof(invalid)/sizeof(*invalid);++i) {
snprintf(body,sizeof(body),"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"%s\"}",invalid[i]);
account_begin(&admin,body); account_expect("400 Bad Request");
}
for (unsigned n=11;n<=65;++n) {
char escaped[391];
for (unsigned i=0;i<n;++i) memcpy(escaped+i*6,"\\u0022",6);
escaped[n*6]=0;
snprintf(body,sizeof(body),"{\"action\":\"password\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"password\":\"%s\"}",escaped);
account_begin(&admin,body);
account_expect(n>=12 && n<=64 ? "202 Accepted" : "400 Bad Request");
if (n>=12 && n<=64) { assert(s_operation.password_length==n); execute_account(); }
}
const char *bad[]={
"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"password1234\",\"user_id\":7}",
"{\"action\":\"password\",\"username\":\"carol\",\"password\":\"password1234\"}",
"{\"action\":\"password\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"password\":\"password1234\",\"role\":\"admin\"}",
"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"password1234\",\"password\":\"password5678\"}"};
for (unsigned i=0;i<sizeof(bad)/sizeof(*bad);++i) { account_begin(&admin,bad[i]); account_expect("400 Bad Request"); }
for (size_t i=0;i<strlen(password_body);++i) {
account_begin(&admin,password_body); req.content_len=aux.remaining_len=i;
account_expect("400 Bad Request");
}
size_t n=strlen(create_body); memcpy(body,create_body,n); memset(body+n,' ',768-n); body[768]=0;
receive_fragment=192; submit_account(&admin,body); execute_account();
receive_fragment=191; account_begin(&admin,body); account_expect("400 Bad Request");
receive_fragment=768; recv_fail=true; account_begin(&admin,password_body); account_expect("400 Bad Request"); recv_fail=false;
puts("PASS Accounts passwords: decoded quote/backslash/space/slash/Unicode escapes, printable ASCII 12..64, exact schemas, truncations, 768-byte/four-receive bound");
}
static void self_tests(void) {
const char *bodies[]={
"{\"action\":\"role\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2,\"role\":\"admin\"}",
"{\"action\":\"delete\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2}",
"{\"action\":\"password\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2,\"password\":\"password1234\"}"};
self_target=true;
for (unsigned i=0;i<3;++i) {
auth_reset(); issued_t admin=mint(&alice), sibling=mint(&alice), user=mint(&bob);
unsigned revokes=web_revokes;
mutation_error=ESP_ERR_INVALID_STATE; submit_account(&admin,bodies[i]); execute_account();
assert(s_operation.state==PROTECTED && web_revokes==revokes);
account_begin(&admin,NULL); account_expect("200 OK"); assert(strstr(output,"protected"));
mutation_error=ESP_OK; submit_account(&admin,bodies[i]); execute_account();
assert(s_operation.state==OK && web_revokes==revokes+1 && !executing_password);
account_begin(&admin,NULL); account_expect("401 Unauthorized"); assert(!strstr(output,"\"state\""));
account_begin(&sibling,NULL); account_expect("401 Unauthorized");
bool current=false; assert(web_session_store_check_principal(user.view.id,&user.view.principal,&current)==ESP_OK && current);
}
self_target=false;
puts("PASS Accounts self role/delete/password: protected failures keep login, success immediately target-revokes all logins, stale result reads denied, unrelated user survives");
}
static void account_settings_tests(void) {
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob), other=mint(&alice); receive_fragment=64;
account_begin(NULL,deletion); account_expect("401 Unauthorized");
account_begin(&user,deletion); account_expect("403 Forbidden");
for (unsigned mode=0;mode<8;++mode) {
account_begin(&admin,deletion);
if (mode==0) req.content_len=aux.remaining_len=769;
if (mode==1) req.uri="/api/settings/account-operation?x=1";
if (mode==2) req.method=HTTP_GET;
if (mode==3) add("X-CSRF-Token","duplicate");
if (mode==4) add("Origin","https://evil.example");
if (mode==5) add("Transfer-Encoding","chunked");
if (mode==6) add("Content-Type","text/plain");
if (mode==7) add("Sec-Fetch-Site","cross-site");
unsigned before=s_next_id; (void)web_account_settings_handler(&req);
assert(response_status[0]=='4' && s_next_id==before && !mutations);
}
puts("PASS Accounts security: current cookie/admin, body/query/framing/CSRF/Origin bounds");
const char *bad[]={"{}","[]","{\"action\":\"password\"}","{\"action\":\"delete\",\"action\":\"role\"}",
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":0,\"auth_generation\":2}",
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":4294967296,\"auth_generation\":2}",
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":07,\"auth_generation\":2}",
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7e0,\"auth_generation\":2}",
"{\"action\":\"delete\",\"username\":\"c\\u0061rol\",\"user_id\":7,\"auth_generation\":2}",
"{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"role\":\"user\"}"};
for (unsigned i=0;i<sizeof(bad)/sizeof(*bad);++i) { account_begin(&admin,bad[i]); account_expect("400 Bad Request"); }
for (size_t n=0;n<strlen(role_body);++n) { account_operation_t out={0}; assert(!parse(role_body,n,&out)); }
account_operation_t parsed={0}; assert(parse(role_body,strlen(role_body),&parsed));
receive_fragment=1; account_begin(&admin,deletion); account_expect("400 Bad Request"); receive_fragment=64;
puts("PASS Accounts strict bounded schema/parser and fragmented-body rejection");
account_begin(&admin,NULL); req.uri="/api/settings/accounts"; account_expect("200 OK");
assert(strstr(output,"account000000007") && !strstr(output,"password") && !strstr(output,"key"));
list_fail=true; account_begin(&admin,NULL); req.uri="/api/settings/accounts"; account_expect("503 Service Unavailable"); list_fail=false;
unsigned reads=lists; account_begin(&user,NULL); req.uri="/api/settings/accounts"; account_expect("403 Forbidden"); assert(lists==reads);
puts("PASS Accounts list: eight bounded public projections, zero mutation and unavailable/role isolation");
queue_fail=true; account_begin(&admin,deletion); account_expect("503 Service Unavailable"); queue_fail=false;
submit_account(&admin,deletion); uint32_t old=queued;
account_begin(&other,role_body); account_expect("503 Service Unavailable");
account_begin(&other,NULL); account_expect("200 OK"); assert(strstr(output,"\"id\":0"));
account_begin(&admin,NULL); account_expect("200 OK"); assert(strstr(output,"pending"));
execute_account(); assert(s_operation.state==OK && mutations==1 && web_revokes==1 && ssh_revokes==1);
execute_account(); assert(mutations==1);
submit_account(&other,role_body); dispatcher=true; web_account_settings_execute(old); web_account_settings_execute(0); dispatcher=false;
assert(mutations==1 && s_operation.state==PENDING); execute_account(); assert(mutations==2 && web_revokes==2);
account_begin(&admin,NULL); account_expect("200 OK"); assert(strstr(output,"\"id\":0"));
puts("PASS Accounts single pending slot, session isolation, stale queued IDs and target-only best-effort notifications");
const esp_err_t errors[]={ESP_FAIL,ESP_ERR_NOT_FOUND,ESP_ERR_INVALID_STATE};
const unsigned states[]={FAILED,STALE,PROTECTED};
for (unsigned i=0;i<3;++i) { mutation_error=errors[i]; submit_account(&admin,deletion); execute_account(); assert(s_operation.state==states[i] && web_revokes==2 && ssh_revokes==2); }
mutation_error=ESP_OK;
unsigned before=mutations; submit_account(&admin,deletion); s_operation.deadline=0; execute_account(); assert(s_operation.state==CANCELLED && mutations==before);
submit_account(&admin,deletion); web_session_store_invalidate(admin.view.id); execute_account(); assert(s_operation.state==CANCELLED && mutations==before);
submit_account(&other,deletion); mutation_hook=invalidate_actor; execute_account(); assert(s_operation.state==OK && mutations==before+1 && web_revokes==3);
zero(&s_operation.principal,sizeof(s_operation.principal)); zero(&s_operation.target,sizeof(s_operation.target));
auth_reset(); admin=mint(&alice); before=mutations;
submit_account(&admin,deletion); db_fail=true; execute_account(); db_fail=false;
assert(s_operation.state==CANCELLED && mutations==before);
admin=mint(&alice);
submit_account(&admin,deletion); stale_user=alice.user_id; execute_account(); stale_user=0;
assert(s_operation.state==CANCELLED && mutations==before);
puts("PASS Accounts execution failure/stale/protected results, dequeue cancellation, missed account revocation/DB failure and admitted-work completion after expiry");
credential_tests(); password_parser_tests(); generated_tests(); self_tests();
assert(wiped_passwords && wiped_bodies && wiped_generated && wiped_responses);
}
+15
View File
@@ -45,6 +45,15 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
admin = "--admin" in sys.argv admin = "--admin" in sys.argv
settings = "--settings" in sys.argv settings = "--settings" in sys.argv
serial_settings = "--serial-settings" in sys.argv serial_settings = "--serial-settings" in sys.argv
accounts = "--accounts" in sys.argv
if accounts:
HEADERS["esp_timer.h"] += """
#include <stdbool.h>
typedef void *esp_timer_handle_t;
typedef struct { void (*callback)(void *); const char *name; bool skip_unhandled_events; } esp_timer_create_args_t;
int esp_timer_create(const esp_timer_create_args_t *, esp_timer_handle_t *);
int esp_timer_start_periodic(esp_timer_handle_t, uint64_t);
"""
if admin: if admin:
HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n" HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n"
HEADERS["esp_heap_caps.h"] = """#pragma once 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 += ' serial_service_counters_t serial_counters = {0};\n' + acquisition
settings_source += ' return snprintf(response, capacity,\n' + serial_format + ',\n' + serial_arguments + ');\n}\n' settings_source += ' return snprintf(response, capacity,\n' + serial_format + ',\n' + serial_arguments + ');\n}\n'
(tmp / 'settings_production.h').write_text(settings_source) (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: if serial_settings:
config_source = (ROOT / 'src/serial_config.c').read_text() config_source = (ROOT / 'src/serial_config.c').read_text()
names = ['serial_config_defaults', 'serial_config_validate'] 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_ADMIN"] if admin else []),
*(["-DHOST_SETTINGS"] if settings else []), *(["-DHOST_SETTINGS"] if settings else []),
*(["-DHOST_SERIAL_SETTINGS"] if serial_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", "-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
"-o", str(tmp / "test")], check=True, timeout=30) "-o", str(tmp / "test")], check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=20) subprocess.run([str(tmp / "test")], check=True, timeout=20)
+12
View File
@@ -1,7 +1,13 @@
/* Production store dependency doubles and its existing public API suite. */ /* Production store dependency doubles and its existing public API suite. */
#define main store_tests #define main store_tests
#ifdef HOST_ACCOUNTS
#define user_database_username_valid store_username_valid
#endif
#include "../web_session_store/test.c" #include "../web_session_store/test.c"
#undef main #undef main
#ifdef HOST_ACCOUNTS
#undef user_database_username_valid
#endif
#include "web_cookie_auth.h" #include "web_cookie_auth.h"
#include "web_httpd_adapter.h" #include "web_httpd_adapter.h"
#include "esp_httpd_priv.h" #include "esp_httpd_priv.h"
@@ -126,6 +132,9 @@ static void auth_reset(void) {
#ifdef HOST_SERIAL_SETTINGS #ifdef HOST_SERIAL_SETTINGS
#include "serial_settings_test.c" #include "serial_settings_test.c"
#endif #endif
#ifdef HOST_ACCOUNTS
#include "account_settings_test.c"
#endif
int main(void) { int main(void) {
assert(store_tests() == 0); auth_reset(); assert(store_tests() == 0); auth_reset();
@@ -279,6 +288,9 @@ int main(void) {
#endif #endif
#ifdef HOST_SERIAL_SETTINGS #ifdef HOST_SERIAL_SETTINGS
serial_settings_tests(); serial_settings_tests();
#endif
#ifdef HOST_ACCOUNTS
account_settings_tests();
#endif #endif
return 0; return 0;
} }
+32 -6
View File
@@ -65,7 +65,24 @@ Coverage:
- Repeated current Settings selection is a no-op during submission, between and - Repeated current Settings selection is a no-op during submission, between and
during result checks, and during completion refresh: requests, timers, visible during result checks, and during completion refresh: requests, timers, visible
values/control state, final outcome and socket/writer identity remain intact. values/control state, final outcome and socket/writer identity remain intact.
**35 Node groups total.** - Accounts: admin-only strict eight-user/1,024-byte list, no secret/key fields,
confirmed identity-bound role/delete, automatic completion/list refresh,
ten-check limit/manual recovery, stale/protected/failure and lost-acknowledgement
handling, cancellation, and 401/identity isolation. The original 41 groups remain;
the former self-denial assertion now checks enabled self actions and confirmation cancellation.
- Second slice: exact create/password JSON and CSRF, 768-byte request ceiling,
untrimmed 1264 printable ASCII passwords including spaces/quotes/backslashes,
confirmation and username validation, separate bodyless generation without
mutation/list changes, strict 24-character base64url/96-byte generation response.
- Generated acknowledgement binds value and operation/target identity; edits,
regeneration, target/purpose changes reset it. 60-second lifetime, including
delayed timer admission checks; submission/cancel/failure and all lifecycle wipes.
Late headers/streamed bodies, concurrent reconnect and newer snapshots are fenced.
- Self password/role/delete warnings and POST/poll 401 close both routes without
success claims or proactive logout. Safe duplicate/full messages and no routine
secret outputs, storage, clipboard writes or history APIs.
**57 Node groups total**, plus renderer/HTML/CSP checks, reported PASS by the UI
continuation agent (four added beyond its earlier 53-group slice 2 run).
## Automatic result-check budget ## 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 separate requests). Settings remain visible but conflicting controls are disabled
during work; old snapshots are explicitly stale during pending/uncertain work or during work; old snapshots are explicitly stale during pending/uncertain work or
a failed refresh. A successful refresh replaces the browser draft. Only Reset 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 Tests use a deterministic clock and individually fired timer callbacks, including
callbacks invoked after cancellation and fetch/body doubles that ignore abort. 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 ## Integration and known gaps
This covers 8D.3 session behavior, the 8D.6 selector, 8D.8 Settings and the 8D.9 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 Serial UI and both 8D.10 Accounts slices. Operation/generation responses are fetch
`web_serial_settings.c`, dispatcher work, serial reconfiguration or NVS persistence. The renderer 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 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 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. 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, not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior,
actual bfcache policy, cookie expiry, server revocation, or hardware serial byte actual bfcache policy, cookie expiry, server revocation, or hardware serial byte
integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout. 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 Prior 8D.6 signoff stands; current slice 2 contracts and pending target checklist are in
`docs/phase8d8_implementation.md`. No target resource reserve is claimed. Browser secret `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 references are dropped and never persisted/logged, but JavaScript cannot securely
wipe engine-managed strings. wipe engine-managed strings.
+297 -4
View File
@@ -11,9 +11,9 @@ const serialSettings = (extra = {}) => ({running: true, baud: 230400, data_bits:
const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}}); const 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 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)); }; const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) { function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = []; const 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 = []; const fits = [];
let serial = 0, now = Date.now(); let serial = 0, now = Date.now();
class Clock extends Date { static now() { return 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), requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
location: {origin: 'https://sak.local', replace: path => redirects.push(path)}}; location: {origin: 'https://sak.local', replace: path => redirects.push(path)}};
const context = vm.createContext({window, document: {getElementById(id) { 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; }, setAttribute(k, v) { this[k] = v; },
getBoundingClientRect: () => ({width: 100, height: 100}), getBoundingClientRect: () => ({width: 100, height: 100}),
addEventListener(k, fn) { this[k] = fn; }}; addEventListener(k, fn) { this[k] = fn; }};
@@ -56,9 +56,10 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
calls.push({url, ...options}); calls.push({url, ...options});
const next = queues[url].shift(); const next = queues[url].shift();
if (next !== undefined) return typeof next === 'function' ? next(options) : next; 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/status') return json({});
if (url === '/api/settings/serial') return json(serialSettings()); 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/ws-ticket') return ticket();
if (url === '/api/admin/ws-ticket') return json({ticket: '0123456789abcdef'.repeat(4), expires_in: 30}); if (url === '/api/admin/ws-ticket') return json({ticket: '0123456789abcdef'.repeat(4), expires_in: 30});
throw new Error('network unavailable'); 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['setting-baud'].textContent, '230400');
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome); assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
}); });
async function accountsBrowser() {
const b = browser({role: 'admin', username: 'alice'}); b.start(); await tick(); b.sockets[0].emit('open');
b.click('select-admin'); b.click('admin-toggle'); await tick(); b.sockets[1].emit('open');
b.click('select-settings'); await tick(); b.click('settings-accounts'); await tick();
return b;
}
const accountPath = '/api/settings/account-operation';
const accountReply = (id, state, action = 'role') => json({id, state, action});
await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => {
const u = await connected(); u.click('settings-accounts'); await tick();
assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts'));
const b = await accountsBrowser();
assert.match(b.nodes['accounts-list'].textContent, /alice.*admin.*you/);
assert.match(b.nodes['accounts-list'].textContent, /carol.*user/);
assert.ok(!b.nodes['account-delete'].disabled);
const calls=b.calls.length; b.window.confirm=()=>false; b.click('account-delete'); await tick(); assert.equal(b.calls.length,calls); b.window.confirm=()=>true;
b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); assert.ok(!b.nodes['account-delete'].disabled);
for(let i=0;i<3;++i) { b.click('settings-serial'); await tick(); b.click('settings-accounts'); await tick(); }
assert.equal(b.sockets.length,2); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
for (const bad of [{users:[{username:'<img>',role:'user',user_id:1,auth_generation:1}]}, {users:Array(9).fill({})}, {users:[],password:'SECRET'}, {users:[{username:'safe',role:'admin',user_id:0,auth_generation:1}]}]) {
b.queues['/api/settings/accounts'].push(json(bad)); b.click('refresh-accounts'); await tick();
assert.match(b.nodes['accounts-detail'].textContent,/stale/); assert.ok(!b.nodes['accounts-list'].textContent.includes('SECRET'));
}
});
await test('Accounts role/delete confirmation, typed identity, automatic result and list refresh', async () => {
for (const action of ['role','delete']) {
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); b.nodes['account-role'].value='admin';
const button=action==='role'?'account-change-role':'account-delete', before=b.calls.length;
b.window.confirm=()=>false; b.click(button); await tick(); assert.equal(b.calls.length,before);
b.window.confirm=()=>true; b.queues[accountPath].push(accountReply(10,'pending',action)); b.click(button); await tick();
const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1);
assert.deepEqual(JSON.parse(posts[0].body),{action,username:'carol',user_id:7,auth_generation:2,...(action==='role'?{role:'admin'}:{})});
assert.equal(posts[0].headers['X-CSRF-Token'],token); assert.ok(b.nodes['account-target'].disabled);
for (const state of ['pending','ok']) { b.queues[accountPath].push(accountReply(10,state,action)); b.fire(1000); await tick(); }
assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/);
assert.match(b.nodes['accounts-detail'].textContent,/refreshed/);
assert.equal(b.calls.filter(c=>c.url==='/api/settings/accounts').length,2);
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
await test('Accounts bounded checks exhaust to manual recovery without POST retry', async () => {
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
b.queues[accountPath].push(accountReply(11,'pending')); b.click('account-change-role'); await tick();
for(let i=0;i<10;++i) { b.queues[accountPath].push(accountReply(11,'pending')); b.elapse(1000); b.fire(1000); await tick(); }
assert.match(b.nodes['account-operation-detail'].textContent,/stopped.*Check Result/); assert.ok(!b.nodes['account-result'].disabled);
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='GET').length,10);
b.queues[accountPath].push(accountReply(11,'ok')); b.click('account-result'); await tick();
assert.match(b.nodes['account-operation-detail'].textContent,/completed/);
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1);
});
await test('Accounts cancellation fences pending posts, checks and refreshes on domain/view/pagehide', async () => {
for (const mode of ['domain','view','pagehide']) {
const b=await accountsBrowser(), d=deferred(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
b.queues[accountPath].push(d.promise); b.click('account-change-role'); await tick();
const request=b.calls.filter(c=>c.url===accountPath).at(-1);
if(mode==='domain') b.click('settings-serial'); else if(mode==='view') b.click('select-serial'); else b.emit('pagehide');
assert.ok(request.signal.aborted); const detail=b.nodes['account-operation-detail'].textContent;
d.resolve(accountReply(12,'pending')); await tick(); assert.equal(b.nodes['account-operation-detail'].textContent,detail);
assert.equal(b.nodes['accounts-list'].textContent,'');
assert.ok(!b.calls.some(c=>c.url===accountPath && c.method==='GET'));
}
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
b.queues[accountPath].push(accountReply(13,'pending')); b.click('account-change-role'); await tick();
b.click('settings-serial'); await tick(); assert.ok(![...b.timers.values()].some(t=>t.ms===1000 || t.ms===15000));
});
await test('Accounts timeout, stale/protected/failed outcomes, failed refresh and unknown acknowledgement', async () => {
for(const state of ['stale','protected','failed','cancelled']) {
const b=await accountsBrowser(); b.queues[accountPath].push(accountReply(14,state)); b.queues['/api/settings/accounts'].push(failure(503));
b.click('account-result'); await tick(); assert.match(b.nodes['accounts-detail'].textContent,/stale/); assert.match(b.nodes['accounts-list'].textContent,/carol/);
}
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
b.queues[accountPath].push(()=>{throw new Error('lost');}); b.click('account-change-role'); await tick();
assert.match(b.nodes['account-operation-detail'].textContent,/unknown/);
for(let i=0;i<2;++i) { b.queues[accountPath].push(accountReply(15,'ok')); b.click('account-result'); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/Acknowledgement lost/); }
b.queues[accountPath].push(accountReply(16,'pending')); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin'; b.click('account-change-role'); await tick();
const d=deferred(); b.queues[accountPath].push(d.promise); b.fire(1000); await tick();
b.elapse(15000); b.fire(15000); await tick(); d.resolve(accountReply(16,'ok')); await tick();
assert.match(b.nodes['account-operation-detail'].textContent,/unknown/); assert.ok(!b.nodes['account-result'].disabled);
});
await test('Accounts 401 and identity changes close routes without adopting stale list', async () => {
for(const identity of [false,true]) {
const b=await accountsBrowser();
if(identity) b.queues['/api/session'].push(session({role:'admin',username:'replacement'}));
else b.queues['/api/settings/accounts'].push(failure(401));
b.click('refresh-accounts'); await tick(); assert.deepEqual(b.redirects,[identity?'/':'/login']);
assert.ok(b.sockets.every(s=>s.closed)); assert.equal(b.nodes['accounts-list'].textContent,'');
}
});
await test('Routine actions never confirm; Reset cancellation has no request or state change', async () => { 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 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; }; 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(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); assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 6);
}); });
const generatePath = '/api/settings/accounts/generate-password', secret = 'Abcdefghijklmnopqrst_-12';
function draft(b, purpose = 'create', password = ' typed "pass\\word ') {
b.nodes['account-purpose'].value = purpose; b.nodes['account-purpose'].change();
if (purpose === 'create') { b.nodes['account-username'].value = 'new_account'; b.nodes['account-username'].input(); }
b.nodes['account-password'].value = b.nodes['account-password-confirm'].value = password;
}
function cleanSecret(b) {
for (const id of ['account-password','account-password-confirm','account-generated']) assert.equal(b.nodes[id].value, '', id);
assert.equal(b.nodes['account-password-saved'].checked, false); assert.ok(b.nodes['account-generated-panel'].hidden);
}
function noSecretOutput(b, value = secret) {
for (const [id, node] of Object.entries(b.nodes)) assert.ok(!node.textContent.includes(value), id);
}
async function generated(b) {
b.queues[generatePath].push(json({password: secret})); b.click('account-generate'); await tick();
assert.equal(b.nodes['account-generated'].value, secret);
b.nodes['account-password-confirm'].value = secret; b.nodes['account-password-confirm'].input();
b.nodes['account-password-saved'].checked = true; b.nodes['account-password-saved'].change();
}
await test('Create/password exact JSON, escaped printable ASCII and bounds, CSRF and isolated sockets', async () => {
for (const purpose of ['create','password']) for (const password of [' '.repeat(12), ' typed "pass\\word ', '\\'.repeat(64)]) {
const b = await accountsBrowser(); draft(b, purpose, password);
b.nodes['account-target'].value = '1'; b.nodes['account-create-role'].value = 'admin';
b.queues[accountPath].push(accountReply(20, 'pending', purpose)); b.click('account-submit-password'); cleanSecret(b); await tick();
const post = b.calls.find(c => c.url === accountPath && c.method === 'POST'); assert.ok(post);
assert.deepEqual(JSON.parse(post.body), purpose === 'create' ? {action:purpose, username:'new_account', role:'admin', password} : {action:purpose, username:'carol', user_id:7, auth_generation:2, password});
assert.ok(Buffer.byteLength(post.body) <= 768); assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
b.queues[accountPath].push(accountReply(20, 'ok', purpose)); b.fire(1000); await tick();
noSecretOutput(b, password); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
await test('Credential validation rejects short/long/non-ASCII/mismatch and invalid usernames; every attempt wipes', async () => {
for (const password of ['a'.repeat(11), 'a'.repeat(65), 'é'.repeat(12), 'abcde\nfghijklm', 'abcde\u007ffghijklm']) {
const b=await accountsBrowser(); draft(b, 'create', password); b.click('account-submit-password'); await tick(); cleanSecret(b);
assert.ok(!b.calls.some(c=>c.url===accountPath));
}
for (const name of ['', 'Aname', 'a'.repeat(17), 'a b', '<img>']) {
const b=await accountsBrowser(); draft(b); b.nodes['account-username'].value=name; b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
const b=await accountsBrowser(); draft(b); b.nodes['account-password-confirm'].value='different password'; b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
});
await test('Explicit generation is bodyless CSRF, bounded, separate from account list and mutation slot', async () => {
const b=await accountsBrowser(); draft(b); const listReads=b.calls.filter(c=>c.url==='/api/settings/accounts').length;
assert.ok(!b.calls.some(c=>c.url===generatePath)); await generated(b);
const post=b.calls.find(c=>c.url===generatePath); assert.equal(post.body,''); assert.equal(post.headers['X-CSRF-Token'],token); assert.equal(post.headers['Content-Type'],undefined);
assert.equal(b.calls.filter(c=>c.url==='/api/settings/accounts').length,listReads); assert.ok(!b.calls.some(c=>c.url===accountPath)); noSecretOutput(b);
assert.ok([...b.timers.values()].some(t=>t.ms===60000));
b.queues[accountPath].push(accountReply(21,'pending','create')); b.click('account-submit-password'); cleanSecret(b); await tick();
assert.equal(JSON.parse(b.calls.find(c=>c.url===accountPath).body).password,secret);
});
await test('Generated acknowledgement binds exact value and intent; edits and regeneration reset it', async () => {
for (const edit of ['unchecked','password','confirm','username','role','target','purpose','regenerate','silent-target','silent-password']) {
const b=await accountsBrowser(); draft(b); await generated(b);
if(edit==='unchecked') b.nodes['account-password-saved'].checked=false;
if(edit==='password') { b.nodes['account-password'].value='different password'; b.nodes['account-password'].input(); }
if(edit==='confirm') b.nodes['account-password-confirm'].input();
if(edit==='username') { b.nodes['account-username'].value='another'; b.nodes['account-username'].input(); }
if(edit==='role') { b.nodes['account-create-role'].value='admin'; b.nodes['account-create-role'].change(); }
if(edit==='target' || edit==='silent-target') { b.nodes['account-target'].value='1'; if(edit==='target') b.nodes['account-target'].change(); }
if(edit==='purpose') { b.nodes['account-purpose'].value='password'; b.nodes['account-purpose'].change(); }
if(edit==='regenerate') { b.queues[generatePath].push(json({password:secret})); b.click('account-generate'); await tick(); }
if(edit==='silent-password') b.nodes['account-password'].value=b.nodes['account-password-confirm'].value='different password';
if(!edit.startsWith('silent')) assert.equal(b.nodes['account-password-saved'].checked,false);
b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
});
await test('Generated lifetime wipes at 60 seconds and expired delayed timer cannot authorize submission', async () => {
for(const timer of [true,false]) {
const b=await accountsBrowser(); draft(b); await generated(b); b.elapse(60000);
if(timer) b.fire(60000); else b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
});
await test('All secret lifecycle cleanup: view/domain/target/purpose/refresh/pagehide/logout/identity/401', async () => {
for(const mode of ['view','domain','target','purpose','refresh','pagehide','logout','identity','401']) {
const b=await accountsBrowser(); draft(b); await generated(b);
if(mode==='view') b.click('select-serial');
if(mode==='domain') b.click('settings-serial');
if(mode==='target') { b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); }
if(mode==='purpose') { b.nodes['account-purpose'].value='password'; b.nodes['account-purpose'].change(); }
if(mode==='refresh') b.click('refresh-accounts');
if(mode==='pagehide') b.emit('pagehide');
if(mode==='logout') b.click('sign-out');
if(mode==='identity' || mode==='401') { b.queues['/api/session'].push(mode==='identity'?session({role:'admin',username:'newadmin'}):failure(401)); b.click('refresh-accounts'); }
await tick(); cleanSecret(b); noSecretOutput(b); assert.ok(![...b.timers.values()].some(t=>t.ms===60000));
}
});
await test('Late generation headers and streamed bodies cannot resurrect secrets after form cancellation', async () => {
for(const body of [false,true]) for(const mode of ['target','edit','purpose','domain','pagehide','logout','refresh']) {
const b=await accountsBrowser(); draft(b); const d=deferred(); let stream;
b.queues[generatePath].push(body?new Response(new ReadableStream({start(c){stream=c;}})):d.promise);
b.click('account-generate'); await tick(); const post=b.calls.find(c=>c.url===generatePath);
if(mode==='target') b.nodes['account-target'].change();
if(mode==='edit') b.nodes['account-password'].input();
if(mode==='purpose') b.nodes['account-purpose'].change();
if(mode==='domain') b.click('settings-serial');
if(mode==='pagehide') b.emit('pagehide');
if(mode==='logout') b.click('sign-out');
if(mode==='refresh') b.click('refresh-accounts');
assert.ok(post.signal.aborted);
if(body) { stream.enqueue(new TextEncoder().encode(JSON.stringify({password:secret}))); stream.close(); } else d.resolve(json({password:secret}));
await tick(); cleanSecret(b); noSecretOutput(b); assert.equal(b.calls.filter(c=>c.url===generatePath).length,1); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
});
await test('Generation errors/schema/96-byte overflow/timeouts never echo response or retry', async () => {
for(const response of [failure(503), failure(403), json({password:secret,extra:1}),json({password:'!'.repeat(24)}),json({password:'x'.repeat(25)}),new Response(' '.repeat(97)),json({password:secret+' '.repeat(100)})]) {
const b=await accountsBrowser(); draft(b); b.queues[generatePath].push(response); b.click('account-generate'); await tick(); cleanSecret(b); noSecretOutput(b); noSecretOutput(b,'SECRET ERROR BODY');
assert.match(b.nodes['account-secret-detail'].textContent,/Nothing applied/); assert.equal(b.calls.filter(c=>c.url===generatePath).length,1);
}
const b=await accountsBrowser(); draft(b); const d=deferred(); b.queues[generatePath].push(d.promise); b.click('account-generate'); await tick(); b.fire(15000); d.resolve(json({password:secret})); await tick(); cleanSecret(b);
});
await test('Generation timeout releases controls on fetch abort; explicit retry is isolated from serial/admin', async () => {
const b = await accountsBrowser(); draft(b);
b.queues[generatePath].push(options => new Promise((_, reject) => {
options.signal.addEventListener('abort', () => reject(new Error('SECRET timeout')), {once:true});
}));
b.click('account-generate'); await tick(); assert.ok(b.nodes['account-generate'].disabled);
b.fire(15000); await tick(); cleanSecret(b); noSecretOutput(b, 'SECRET timeout');
assert.ok(!b.nodes['account-generate'].disabled && !b.nodes['account-submit-password'].disabled);
assert.match(b.nodes['account-secret-detail'].textContent, /Nothing applied/);
assert.equal(b.calls.filter(c => c.url === generatePath).length, 1);
assert.ok(!b.calls.some(c => c.url === accountPath));
await generated(b);
assert.equal(b.calls.filter(c => c.url === generatePath).length, 2);
assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
});
await test('Generation endpoint 401 wipes secrets and closes both routes once without a mutation', async () => {
const b = await accountsBrowser(); draft(b); await generated(b);
b.queues[generatePath].push(failure(401)); b.click('account-generate'); await tick();
cleanSecret(b); noSecretOutput(b); noSecretOutput(b, 'SECRET ERROR BODY');
assert.deepEqual(b.redirects, ['/login']); assert.ok(b.sockets.every(s => s.closed));
assert.equal(b.timers.size, 0); assert.ok(!b.calls.some(c => c.url === accountPath));
b.window.sakSessionExpired(); assert.deepEqual(b.redirects, ['/login']);
});
await test('Credential pre-submit session cancellation fences late responses and never sends the password', async () => {
for (const purpose of ['create', 'password']) for (const mode of ['view', 'pagehide', 'identity', '401']) {
const b = await accountsBrowser(); draft(b, purpose); await generated(b);
const pending = deferred(); b.queues['/api/session'].push(pending.promise);
b.click('account-submit-password'); cleanSecret(b); await tick();
const check = b.calls.filter(c => c.url === '/api/session').at(-1);
if (mode === 'view') b.click('select-serial');
if (mode === 'pagehide') b.emit('pagehide');
pending.resolve(mode === 'identity' ? session({role:'admin', username:'replacement'}) :
mode === '401' ? failure(401) : session({role:'admin', username:'alice'}));
await tick(); cleanSecret(b); noSecretOutput(b);
assert.ok(check.signal.aborted); assert.ok(!b.calls.some(c => c.url === accountPath));
assert.deepEqual(b.redirects, mode === 'identity' ? ['/'] : mode === '401' ? ['/login'] : []);
assert.ok(b.sockets.every(s => mode === 'view' ? !s.closed : s.closed));
}
});
await test('Self password/role/delete confirmation, no preemptive logout; 401 closes without success claim', async () => {
for(const action of ['password','role','delete']) for(const stage of ['post','poll']) {
const b=await accountsBrowser(); let warning=''; b.window.confirm=m=>{warning=m;return true;};
if(action==='password') { draft(b,'password'); await generated(b); }
const button=action==='password'?'account-submit-password':action==='role'?'account-change-role':'account-delete';
b.queues[accountPath].push(stage==='post'?failure(401):accountReply(25,'pending',action)); b.click(button); cleanSecret(b);
assert.ok(b.sockets.every(s=>!s.closed)); await tick();
assert.match(warning,/ALL.*web\/SSH.*browser serial\/admin/); assert.match(warning,/401.*NOT proof/); assert.match(warning,/even a no-op role/); assert.ok(!warning.includes(secret));
if(stage==='poll') { assert.ok(b.sockets.every(s=>!s.closed)); b.queues['/api/session'].push(failure(401)); b.fire(1000); await tick(); }
assert.deepEqual(b.redirects,['/login']); assert.ok(b.sockets.every(s=>s.closed)); cleanSecret(b); noSecretOutput(b);
assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/completed and saved/); assert.ok(!b.calls.some(c=>c.url==='/api/logout'));
}
});
await test('Cancelled confirmation and failed credential POST wipe; duplicate/full safe result messages', async () => {
for(const cancel of [true,false]) {
const b=await accountsBrowser(); draft(b); await generated(b); b.window.confirm=()=>!cancel;
b.queues[accountPath].push(failure(503)); b.click('account-submit-password'); cleanSecret(b); await tick(); noSecretOutput(b); assert.equal(b.calls.filter(c=>c.url===accountPath).length,cancel?0:1);
}
for(const state of ['duplicate','full']) { const b=await accountsBrowser(); b.queues[accountPath].push(accountReply(27,state,'create')); b.click('account-result'); await tick(); assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/undefined|unknown/); }
});
await test('Rejected self mutations retain both routes and lease without logout or success claims', async () => {
for (const action of ['password', 'role', 'delete']) for (const outcome of [403, 'protected', 'failed', 'stale']) {
const b = await accountsBrowser();
b.sockets[0].emit('message', {data:JSON.stringify({type:'hello', clientId:8, writerId:8, role:'writer'})});
if (action === 'password') { draft(b, 'password'); await generated(b); }
b.queues[accountPath].push(outcome === 403 ? failure(403) : accountReply(28, 'pending', action));
b.click(action === 'password' ? 'account-submit-password' : action === 'role' ? 'account-change-role' : 'account-delete');
await tick();
if (outcome !== 403) { b.queues[accountPath].push(accountReply(28, outcome, action)); b.fire(1000); await tick(); }
cleanSecret(b); noSecretOutput(b); assert.deepEqual(b.redirects, []);
assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
assert.equal(b.nodes['writer-id'].textContent, '8'); assert.equal(b.nodes['release-control'].disabled, false);
assert.doesNotMatch(b.nodes['account-operation-detail'].textContent, /completed and saved/);
assert.ok(!b.calls.some(c => c.url === '/api/logout'));
assert.equal(b.calls.filter(c => c.url === accountPath && c.method === 'POST').length, 1);
}
});
await test('Generation session checks cannot strand reconnect; newer admission fences old generation', async () => {
const b=await accountsBrowser(); draft(b); const old=deferred(); b.queues['/api/session'].push(old.promise);
b.click('account-generate'); await tick(); b.click('connection-toggle'); b.click('connection-toggle'); await tick();
old.resolve(session({role:'admin',username:'alice'})); await tick();
assert.equal(b.sockets.length,3); assert.ok(!b.sockets[1].closed); assert.ok(!b.calls.some(c=>c.url===generatePath)); cleanSecret(b);
await generated(b); assert.equal(b.nodes['account-generated'].value,secret);
const c=await accountsBrowser(); draft(c); const reconnect=deferred(); c.click('connection-toggle'); c.queues['/api/session'].push(reconnect.promise); c.click('connection-toggle'); await tick();
await generated(c); reconnect.resolve(session({role:'admin',username:'alice'})); await tick(); assert.equal(c.sockets.length,3); assert.equal(c.nodes['account-generated'].value,secret);
});
await test('Old generation/refresh responses cannot overwrite new target snapshot or generated value', async () => {
const b=await accountsBrowser(); draft(b); const old=deferred(); b.queues[generatePath].push(old.promise); b.click('account-generate'); await tick();
b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); await generated(b);
old.resolve(json({password:'x'.repeat(24)})); await tick(); assert.equal(b.nodes['account-generated'].value,secret); assert.equal(b.nodes['account-password-saved'].checked,true);
const stale=deferred(); b.queues['/api/settings/accounts'].push(stale.promise); b.click('refresh-accounts'); await tick(); cleanSecret(b);
b.click('settings-serial'); await tick(); b.click('settings-accounts'); await tick(); draft(b); await generated(b);
stale.resolve(json({users:[{username:'replaced',role:'user',user_id:90,auth_generation:99}]})); await tick();
assert.match(b.nodes['accounts-list'].textContent,/alice/); assert.doesNotMatch(b.nodes['accounts-list'].textContent,/replaced/); assert.equal(b.nodes['account-generated'].value,secret);
});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`); console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; }); })().catch(error => { console.error(error); process.exitCode = 1; });
+7 -1
View File
@@ -78,7 +78,13 @@ esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
assert (rendered['html'].index('id="terminal-title"') < assert (rendered['html'].index('id="terminal-title"') <
rendered['html'].index('id="admin-toggle"') < rendered['html'].index('id="admin-toggle"') <
rendered['html'].index('id="terminal-selector"')) 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 assert forbidden not in rendered['script'] + rendered['loader'], forbidden
(tmp / 'rendered.json').write_text(json.dumps(rendered)) (tmp / 'rendered.json').write_text(json.dumps(rendered))
subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30) subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30)