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:
@@ -152,6 +152,14 @@ The final administrator cannot be deleted or demoted. UART0 is trusted for initi
|
||||
|
||||
NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials, legacy recovery credentials, and TLS/SSH private keys remain recoverable under physical flash extraction.
|
||||
|
||||
## Typed Accounts settings (8D.10)
|
||||
|
||||
Current slice 2 extends the same dispatcher slot to create/password and self role/delete/password. Mutation bodies are 768 bytes/four receives; results stay secret-free, 96 bytes, replaceable and session-bound. Conditional password mutation checks identity under the canonical database mutex. A one-second firmware-lifetime ESP timer cancels/wipes non-executing queued credentials at their 30-second deadline plus timer/scheduling latency; dequeue wipes shared inputs after copying, and dispatcher-local credentials persist until admitted work returns. This is not a hard execution/erasure deadline. Separate bodyless admin/Origin/CSRF POST `/api/settings/accounts/generate-password` returns a 24-character value before any commit, without retained retrieval. UI generation has a 60-second best-effort lifetime and context-bound saved acknowledgement before separate submission; JavaScript cannot securely wipe strings. Self revocation may prevent final response/result access; disconnect/401 proves neither success nor cancellation. Browser-shell restrictions and UART0-only bootstrap/recovery remain unchanged. The generated endpoint is independently optionally registered, with failure isolation/restart coverage and 23 handlers. Implementation is complete, host-tested/build-verified; target validation/signoff remains pending. Parent build: 25.61 s, 95,908 B RAM / 1,694,237 B flash; timer runtime costs and stack/heap margins remain unmeasured. No task/stack/queue depth/socket expansion or 8D.11 work. Current contracts and attributed host evidence: `docs/phase8d10_implementation.md`.
|
||||
|
||||
**Historical slice 1 architecture (superseded scope/counts, retained evidence):**
|
||||
|
||||
`web_account_settings` supplies an optional admin-only compact account list and one session-bound role/delete operation/result slot, separate from Serial's slot but executed on the same dispatcher queue. HTTPD authorizes/parses/queues; the dispatcher revalidates the initiating login/admin and 30-second dequeue deadline, then calls conditional database mutations and best-effort target web/SSH revocation after success. `user_database_get_accounts()` copies at most eight username/role/ID/auth-generation records under the existing mutex with zero wait and no key/password fields. `user_database_delete_current()` and `user_database_set_role_current()` compare target identity under the mutation lock and share canonical CLI commit/invariant logic; stale selection never intentionally mutates a replacement account. Results are replaceable, not durable/idempotent, and already-admitted work can complete after logout. Self-target, create/password/generated-secret workflows remain unavailable in this slice; bootstrap/recovery remain UART0-only. Accounts UI confirms mutations, retains visible stale lists/outcomes during bounded auto-check/refresh and fences navigation/session changes without changing broker ownership. Three optional routes bring HTTPD handlers to 22; six sockets/no LRU and existing tasks/stacks/queue depth remain. See `docs/phase8d10_implementation.md` for limits and pending target checks.
|
||||
|
||||
## Typed Serial settings (8D.9)
|
||||
|
||||
`web_serial_settings` admits strict bounded admin cookie/Origin/CSRF JSON into one static session-bound operation/result slot, queuing only a non-reused ID on the existing administration dispatcher. HTTPD never runs serial/NVS mutations. The dispatcher checks session/principal currentness and a 30-second dequeue deadline before canonical Apply/Start/Stop/Save/Load/Defaults/Reset APIs; already admitted work may complete after logout. A blocked dispatcher retains the slot, not a timed job cancellation. Results are login-isolated and replaceable after completion; no durable history/idempotent retry guarantee. After acknowledgement the UI checks at one-second intervals, bounded to 10 GET attempts and a 15-second overall deadline including session checks, then automatically refreshes working values for known terminal outcomes. Errors/exhaustion use manual recovery; no automatic mutation retries or navigation resumption. Settings remain visible with stale/pending labels; only Reset confirms saved-NVS overwrite. Selecting the current view is a no-op. Settings UI retains uncertain-result warnings, explicit RAM/NVS/discard explanations and both terminal sockets/lease. Two optional exact GET/POST registrations bring the URI budget to 19, with six sockets and unchanged tasks/stacks/queue depth. `/api/status` also uses the zero-wait serial snapshot and emits `running:null` when unavailable. See `docs/phase8d9_implementation.md` for resource and target-pending evidence.
|
||||
|
||||
@@ -78,6 +78,10 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
|
||||
- 8D.9: `web_serial_settings.{c,h}` owns strict 256-byte typed mutation admission and one session-bound pending/result slot. Existing `admin_ssh_console` dispatcher consumes only an ID, revalidates currentness/dequeue deadline and calls canonical serial APIs. `web_server.c` adds optional GET/POST `/api/settings/serial-operation` (19 handlers total); `web_cookie_auth_require_json()` retains Origin/CSRF/admin policy, private optional registration supports exact GET/POST. UI adds explicit framing/lifecycle/persistence with automatic completion checks (1 s, at most 10 GETs/15 s overall), refresh on known terminal results and manual uncertainty recovery without socket/lease changes. Settings stay visible/stale while pending; only Reset confirms NVS overwrite; selecting the current view is a no-op. `/api/status` uses a consistent zero-wait serial snapshot (`running:null` when unavailable). Tests: cookie `--serial-settings` (10 groups), `--settings` (6), UI (35 after UX refinement), console boundary and lifecycle (13). Build verified, target/signoff pending; bounds and failure contracts: `docs/phase8d9_implementation.md`. Supersedes 8D.8's no-8D.9 status above.
|
||||
|
||||
- 8D.10 first slice: `web_account_settings.{c,h}` owns compact admin account list and one session-bound other-account role/delete operation slot. `user_database_get_accounts()` is a zero-wait key/secret-free projection; `*_current()` role/delete wrappers compare target ID/auth generation under the canonical mutation lock. Existing dispatcher routes IDs; successful calls target-revoke web/SSH. Optional GET `/api/settings/accounts`, GET/POST `/api/settings/account-operation` raise handlers to 22, sockets/tasks/stacks/queue depth unchanged. UI Accounts subview preserves terminal/lease semantics, confirms mutations and auto-checks/refreshes with manual uncertainty recovery. Tests: cookie `--accounts` (5), canonical accounts, dispatcher, lifecycle (14), UI (41 + CSP). Target pending; create/password/generated-secret/self changes remain next slice, 8D.10 incomplete. Record: `docs/phase8d10_implementation.md`.
|
||||
|
||||
- **Current 8D.10 slice 2 (supersedes first-slice exclusions above):** `web_account_settings.{c,h}` adds create/password/self and separate bodyless POST `/api/settings/accounts/generate-password`; `user_database_set_password_current()` shares mutation-lock target checks and canonical commit logic, `user_database_generate_password_value()` generates without mutation. 768-byte/four-receive admission, 96-byte secret-free results; one-second periodic timer cancels/wipes queued non-executing credentials after 30 seconds plus scheduling latency, while dispatcher wipes executing locals on return. Generation has no retained retrieval; UI uses 60-second context-bound acknowledgement before separate submission. Self revocation can deny result retrieval; 401/disconnect is uncertain. Browser-shell restrictions unchanged. Missing generated-route registration found in review is fixed: independent optional endpoint, 23 handlers, failure isolation/restart coverage. Implementation complete, host-tested/build-verified; target/signoff pending. Parent PASS canonical accounts/boundary, parser 294, cookie accounts 9/shared and serial-settings 10, transport 25/tickets 12, store/serial and diff check; UI agent PASS 57/CSP, route agent lifecycle 15. Parent build 25.61 s, 95,908 B RAM / 1,694,237 B flash (+80/+9,880 vs slice 1; +200/+25,400 vs final 8D.9 UX). Timer runtime costs/stack margins remain unmeasured; no 8D.11. Exact evidence attribution: `docs/phase8d10_implementation.md`.
|
||||
|
||||
### Browser admin backend (8D.5)
|
||||
|
||||
- **8D.7 current status (2026-09-07): implemented scope validated; M2 explicitly signed off by the user ("Jupp, sign M2 off").** Supersedes M2-open/target-pending/continuation statements in the historical slices below; accepted M2 does not require revalidation. User verified certificate rotation and web start/stop via UART0/SSH admin/web admin, restarting after browser stop via another route; full mix without broker drops up to 230400 baud after external adapter correction is user-reported. Intermittent supported two serial + one admin admission failures, recently not recurring, are accepted nonblocking, not fixed. Browser self/generated/key/legacy-credential and other owner command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Numeric memory reserves/stack margins remain unapproved; no full parity or individual unreported checklist passes. Next: separately requested 8D.8 read-only settings entry and Serial page; sign-off alone authorizes no implementation. Evidence: `docs/phase8d7_implementation.md`.
|
||||
|
||||
@@ -4,6 +4,12 @@ This file is working memory. Update it during active work and before handoff; do
|
||||
|
||||
## Development state
|
||||
|
||||
- **Current 8D.10 slice 2 complete, host-tested/build-verified (2026-09-08); target/signoff pending, not target accepted:** Create/password/generated-value/self workflows use the existing dispatcher slot, 768-byte/four-receive admission and canonical mutation-lock identity checks. One-second periodic timer cancels/wipes non-executing credentials at 30 seconds plus scheduling latency; admitted work is not cancelled and locals wipe after return. Generation is separate before commit, with no retained retrieval; UI 60-second lifetime/context-bound acknowledgement and best-effort secret clearing. Immediate self revocation can lose POST/results: 401/disconnect is uncertain, inspect after relogin before retry. Browser-shell restrictions unchanged. Review's only finding, missing generated-route registration, is fixed as an independent optional endpoint with failure isolation/restart coverage: **23 handlers**, six sockets/no LRU unchanged. Parent PASS canonical `accounts.py`, boundary `run.py`, parser **294**, cookie `--accounts` **9 plus shared**, `--serial-settings` **10**, transport **25**/tickets **12**, store `--serial` and diff check. UI agent **57 plus CSP** (four added beyond 53); route agent lifecycle **15** pass. These UI/lifecycle results are agent-attributed, not claims of the parent's additional reruns. Parent `pio run` **PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash**, **+80/+9,880** vs slice 1 and **+200/+25,400** vs final 8D.9 UX. New timer runtime costs, heap reserves and stack margins remain unmeasured/unapproved. Record/checklist: `docs/phase8d10_implementation.md`; slice 1 below remains historical. This update is documentation-only; no source/test/build action by this documentation agent. No sanitizer validation, device/assets/commit/8D.11 action, full 8D.10 target signoff or prior-phase signoff/reserve approval inferred.
|
||||
|
||||
The following slice 1 entry is explicit historical evidence; its unavailable/next-slice statements and build/counts do not describe current slice 2.
|
||||
|
||||
- **8D.10 first slice complete (2026-09-08), host-tested/build-verified; target pending, phase incomplete:** User requested 8D.10; selected plan's pre-edit list/role/delete versus create/password split. `web_account_settings.{c,h}` provides admin-only compact list and other-account role/delete on one session-bound slot, executed by the existing dispatcher. Zero-wait `user_database_get_accounts()` and mutation-lock identity checks preserve canonical final-admin/NVS semantics and reject stale/recreated targets; successful calls target-revoke web/SSH. Three optional routes, 22 handler budget, six sockets/no LRU, unchanged tasks/stacks/queue depth. Accounts subview has confirmations, bounded auto-completion/refresh (10 GETs/15 s), uncertainty recovery and navigation/session fencing without broker lease effects. Final parent cookie `--accounts` (5 groups/shared auth), canonical accounts, console boundary, server lifecycle (14), UI (41 + CSP), transport/tickets and store/serial pass; prior settings/serial/admin/policy/lifecycle reruns also pass. Final `pio run` **25.00 s / 95,828 B RAM / 1,684,357 B flash**, **+120 / +15,520 B** vs final 8D.9 UX; diff check PASS. Exact evidence/contracts/target checklist: `docs/phase8d10_implementation.md`. No independent review/sanitizer/device/assets/commit action or reserve/prior-phase signoff. Next is second 8D.10 slice: create/password, one-time generated secrets and safe own-account changes; these remain unavailable. No 8D.11 work or full 8D.10 completion.
|
||||
|
||||
- **8D.9 UX refinement (2026-09-08), host-tested/build-verified; target pending:** User reported successful Apply required awkward manual Check Result then Refresh and approved automatic flow/removing routine popups. `web_ui.c` now keeps settings visible/stale while pending, checks acknowledged operations every 1 s up to 10 GET attempts/15 s overall, then refreshes working values while preserving outcome. Errors/exhaustion/lost ack use manual recovery; no POST retry, no resume after navigation. Only Reset retains a saved-NVS overwrite confirmation; inline discard semantics remain. Current-view selection is a no-op so repeated Settings clicks cannot cancel work. Parent UI **35 groups + renderer/CSP**, `pio run` **10.91 s / 95,708 B RAM / 1,668,837 B flash**, diff check PASS; **0 / +2,112 B** vs original 8D.9. No backend/assets/device/commit action or new signoff. Details/evidence limits in `docs/phase8d9_implementation.md`; target automatic-completion, failure/manual-recovery and full-mix latency checks pending.
|
||||
|
||||
- **8D.9 continuation complete (2026-09-07), implemented / reviewed / host-tested / build-verified; target/signoff pending:** Preserved inherited implementation; typed Apply/Start/Stop/Save/Load/Defaults/Reset uses one session-bound slot and the existing dispatcher queue, 256-byte JSON POST and 96-byte explicit GET results. No new tasks/stacks/queue depth or broker lease semantics; 19 URI handlers, six sockets/no LRU. Fixed persistent uncertain-result warnings and consistent zero-wait `/api/status` serial state (`running:null` when unavailable). Final parent Serial 10, Settings/status 6, UI 27/CSP, console boundary and lifecycle 13 groups pass; additional transport/tickets/store/admin/account/lifecycle regressions passed during review. Final `pio run` 23.73 s, **95,708 B RAM / 1,666,725 B flash**, **+128 / +12,196 B** vs 8D.8. Deadline is dequeue admission only; blocked dispatcher retains pending slot, admitted NVS work may finish after logout, completed results can be replaced. Exact contracts/tests/limits and pending target checklist: `docs/phase8d9_implementation.md`. No target/device/asset/commit action, reserve approval or 8D.8/8D.9 signoff. Stop before separately requested 8D.10; M2 acceptance and existing deferred restrictions/admission followups stand.
|
||||
|
||||
@@ -106,6 +106,14 @@ Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutab
|
||||
|
||||
**8D.9:** HTTPD performs bounded typed admission/result reads only; serial reconfiguration and NVS execute on the existing dispatcher so CLI commands cannot interleave. One global pending slot rejects concurrent work; copied session/principal plus non-reused ID fence stale queued work. A 30-second deadline is checked on dequeue, not a cancellation timer or execution limit. Admitted mutations may finish after revocation; completed results can be replaced. Keep explicit uncertain-outcome recovery and never automatically retry mutations. Apply/Defaults are RAM-only, Save persists working device state rather than browser drafts, and Reset follows canonical apply/persist/best-effort-rollback ordering. Navigation preserves broker clients/writer lease, while explicit serial reconfiguration can discard serial-service pending data. No generic command runner/job history is exposed. See `src/web_serial_settings.{c,h}` and `docs/phase8d9_implementation.md`.
|
||||
|
||||
## Typed account selection is checked inside the database mutation lock
|
||||
|
||||
**Current 8D.10 slice 2:** Extend conditional identity checks to password replacement; create uses canonical duplicate/capacity/commit policy. Keep generation separate from commit: the protected bodyless generated-value POST returns one transient value, performs no mutation and retains no retrieval state. Browser saved acknowledgement is context-bound UX, not delivery proof or server authorization. Queued credentials require a one-second periodic timer to cancel/wipe non-executing work at the 30-second deadline plus scheduling latency; execution copies then wipes shared inputs, with local wiping after admitted database work returns. Neither timer nor logout cancels admitted commits. Self password/role/delete uses immediate canonical target revocation, not deferred acknowledgement: 401/disconnect is uncertain and requires relogin/inspection before any explicit retry. Generation is independently optionally registered, preserving failure isolation and restart behavior at 23 handlers. No shell restriction change, secret result/history, new executor or 8D.11 work. Implementation is complete, host-tested/build-verified, not target accepted; timer runtime costs remain unmeasured. See contracts, build and attributed test evidence in `docs/phase8d10_implementation.md`.
|
||||
|
||||
The following first-slice exclusions are historical and superseded by slice 2:
|
||||
|
||||
**8D.10 first slice:** Accounts HTTPD routes expose a compact zero-wait list without password/key data and submit role/delete IDs to the existing dispatcher. Do not use the larger blocking CLI snapshot on HTTPD. Initiating-session currentness is checked before operation admission; target username/account ID/auth generation is compared under the database lock before candidate staging. Conditional and CLI mutations share invariant/commit logic. Notify only the target's web/SSH sessions after successful calls; notification failure does not undo persistence. Separate bounded Serial/Accounts slots do not create another executor. Completed results remain replaceable, no mutation auto-retry, and navigation is not cancellation. Self-target and create/password/generated-secret delivery are intentionally excluded until the next slice defines safe delivery/reconnect semantics. `src/web_account_settings.{c,h}`, `src/user_database.{c,h}`, `docs/phase8d10_implementation.md`.
|
||||
|
||||
## Browser authentication has a narrow version-pinned HTTPD boundary
|
||||
|
||||
**8D.8 read-only settings:** Reuse bodyless GET cookie/current-admin policy and the existing bounded browser API/errors; no CSRF mutation semantics on a read. Obtain working serial config/running atomically with a zero-wait existing serial mutex, never block HTTPD on stop/reconfiguration or inspect NVS. Navigation changes view/input only, preserving both terminal sockets/lease/output; Settings session validation must not supersede serial-admission checks. One optional exact-GET URI raises only handler capacity to 17. The private adapter stages both descriptor/name allocations before publishing, avoiding the installed 5.5.0 public registration's freed table pointer on strdup failure. Serialized startup/exact matcher only, normal HTTPD allocation/free ownership; re-audit this boundary on SDK changes. Existing public registration callers are not refactored by this phase.
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,11 @@
|
||||
# Phase 8D — Incremental web administration plan
|
||||
|
||||
**Current slice 2 completion (2026-09-08):** **8D.10 implementation is complete, host-tested/build-verified, not target accepted; target validation/full signoff remain pending.** Create/password/self workflows use bounded 768-byte admission and periodic credential cleanup (30-second deadline plus one-second timer/scheduling latency); admitted executing work is not cancelled. Protected generation is separate before commit, with no retained retrieval. Self revocation may prevent results: 401/disconnect is uncertain, never grounds for automatic retry. Browser-shell restrictions remain unchanged. Review's only finding, missing generated-route registration, is fixed as an independent optional endpoint with failure isolation/restart coverage, **23 handlers/six sockets**. Parent PASS canonical accounts/boundary, parser **294**, cookie accounts **9 plus shared**, serial-settings **10**, transport **25**/tickets **12**, store/serial and diff check. UI agent **57 plus CSP** and route agent lifecycle **15** pass; these are not claims of the parent's additional UI/lifecycle reruns. Parent `pio run` **PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash**, **+80/+9,880** vs slice 1 and **+200/+25,400** vs final 8D.9 UX. Timer runtime costs and heap/stack margins remain unmeasured. [Current 8D.10 record](phase8d10_implementation.md) contains contracts/evidence/target checklist. No sanitizer validation, assets/device/commit/8D.11 work, M2 reopening, prior-phase signoff or reserve approval inferred.
|
||||
|
||||
The implementation/continuation entries below are historical evidence. In particular, slice 1's exclusions, next-slice instruction, 22-handler count and build figures do not describe current slice 2.
|
||||
|
||||
**Latest implementation (2026-09-08):** User-requested **8D.10 first slice is host-tested/build-verified; target pending and phase incomplete**. Pre-edit split follows the row below: Accounts list and other-account role/delete now implemented, with conditional target identity checks, existing dispatcher/target notifications and bounded automatic UI completion. Three optional routes, **22 handlers/six sockets**, **95,828 B RAM / 1,684,357 B flash**. [8D.10 record](phase8d10_implementation.md) covers tests/resources/limits. Next is the second 8D.10 slice (create/password/generated-secret/self workflows), not 8D.11. Supersedes historical stop-before-8D.10 instructions; no prior target signoff, M2 reopening or reserve approval inferred. Final 8D.9 UX baseline is recorded in [8D.9](phase8d9_implementation.md).
|
||||
|
||||
**Latest continuation (2026-09-07):** Separately authorized **8D.9 is implemented / reviewed / host-tested / build-verified**, with 8D.8/8D.9 target acceptance still pending. [8D.9 record](phase8d9_implementation.md): typed Serial framing/lifecycle/persistence, one session-bound operation slot on the existing dispatcher, manual bounded result recovery, 19 handlers/six sockets; final **95,708 B RAM / 1,666,725 B flash**. No new task/queue depth/stack or broker writer semantics. This supersedes older stop-before-8D.9 instructions, not M2 signoff or deferred restrictions, admission followups or unapproved reserves. Stop before separately requested 8D.10; no target signoff inferred.
|
||||
|
||||
**Previous implementation (2026-09-07):** Separately user-authorized **8D.8 is implemented / host-tested / build-verified**, with target/browser validation and new phase signoff pending. [8D.8 record](phase8d8_implementation.md): read-only admin Settings/Serial, nonblocking typed snapshot, 17 handlers/six sockets, final 95,580 B RAM / 1,654,529 B flash; exact tests/resources/limits and target checklist recorded. This supersedes older next-8D.8/wait-for-request instructions below, not M2 signoff or evidence. Deferred restrictions, accepted unresolved admission issue and unapproved memory/stack followups remain. Stop before separately requested 8D.9; no new signoff is inferred.
|
||||
|
||||
@@ -32,6 +32,7 @@ idf_component_register(
|
||||
"web_security.c"
|
||||
"web_serial_transport.c"
|
||||
"web_serial_settings.c"
|
||||
"web_account_settings.c"
|
||||
"web_admin_tickets.c"
|
||||
"web_admin_transport.c"
|
||||
"web_assets_data.c"
|
||||
|
||||
+16
-2
@@ -17,6 +17,7 @@
|
||||
#include "secure_random.h"
|
||||
#include "user_database.h"
|
||||
#include "web_serial_settings.h"
|
||||
#include "web_account_settings.h"
|
||||
|
||||
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
|
||||
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
|
||||
@@ -83,6 +84,7 @@ typedef enum {
|
||||
ADMIN_REQUEST_UART0,
|
||||
ADMIN_REQUEST_DEFERRED,
|
||||
ADMIN_REQUEST_SERIAL_SETTINGS,
|
||||
ADMIN_REQUEST_ACCOUNT_SETTINGS,
|
||||
} admin_request_origin_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -94,6 +96,7 @@ typedef struct {
|
||||
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
admin_control_request_t deferred;
|
||||
uint32_t serial_settings_id;
|
||||
uint32_t account_settings_id;
|
||||
};
|
||||
} admin_request_t;
|
||||
|
||||
@@ -659,6 +662,16 @@ esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id)
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready || !id) return ESP_ERR_INVALID_STATE;
|
||||
admin_request_t request = {.origin = ADMIN_REQUEST_ACCOUNT_SETTINGS, .account_settings_id = id};
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
static void worker_task(void *context)
|
||||
{
|
||||
(void)context;
|
||||
@@ -667,8 +680,9 @@ static void worker_task(void *context)
|
||||
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
|
||||
continue;
|
||||
}
|
||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) {
|
||||
web_serial_settings_execute(request.serial_settings_id);
|
||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) {
|
||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id);
|
||||
else web_account_settings_execute(request.account_settings_id);
|
||||
secure_wipe(&request, sizeof(request));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Nonblocking typed Serial-settings admission to the canonical dispatcher. */
|
||||
/* Nonblocking typed settings admission to the canonical dispatcher. */
|
||||
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id);
|
||||
|
||||
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
|
||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||
|
||||
+90
-12
@@ -295,7 +295,7 @@ static esp_err_t set_record_password(stored_user_t *user,
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t generate_password(user_database_generated_password_t *generated)
|
||||
esp_err_t user_database_generate_password_value(user_database_generated_password_t *generated)
|
||||
{
|
||||
if (generated == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
@@ -788,6 +788,25 @@ esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t user_database_get_accounts(user_database_accounts_t *accounts)
|
||||
{
|
||||
if (accounts == NULL) return ESP_ERR_INVALID_ARG;
|
||||
memset(accounts, 0, sizeof(*accounts));
|
||||
if (!s_initialized || s_mutex == NULL) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
for (size_t i = 0; i < USER_DATABASE_MAX_USERS; ++i) {
|
||||
const stored_user_t *user = &s_database.users[i];
|
||||
if (!user->active) continue;
|
||||
user_database_account_t *out = &accounts->users[accounts->count++];
|
||||
out->user_id = user->user_id;
|
||||
out->auth_generation = user->auth_generation;
|
||||
out->role = (user_role_t)user->role;
|
||||
memcpy(out->username, user->username, user->username_length);
|
||||
}
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void fill_principal(const stored_user_t *user, user_auth_method_t method,
|
||||
user_principal_t *principal)
|
||||
{
|
||||
@@ -986,7 +1005,7 @@ esp_err_t user_database_create_generated(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
user_database_generated_password_t *generated_password)
|
||||
{
|
||||
esp_err_t error = generate_password(generated_password);
|
||||
esp_err_t error = user_database_generate_password_value(generated_password);
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_create(username, username_length, role,
|
||||
generated_password->password,
|
||||
@@ -1037,7 +1056,7 @@ esp_err_t user_database_bootstrap_admin(const uint8_t *password,
|
||||
esp_err_t user_database_bootstrap_admin_generated(
|
||||
user_database_generated_password_t *generated_password)
|
||||
{
|
||||
esp_err_t error = generate_password(generated_password);
|
||||
esp_err_t error = user_database_generate_password_value(generated_password);
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_bootstrap_admin(generated_password->password,
|
||||
generated_password->password_length);
|
||||
@@ -1062,14 +1081,26 @@ static esp_err_t mutate_user_begin(const uint8_t *username, size_t username_leng
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t user_database_delete(const uint8_t *username, size_t username_length)
|
||||
static bool target_matches_locked(const uint8_t *username, size_t length,
|
||||
const user_database_account_t *expected)
|
||||
{
|
||||
if (!expected) return true;
|
||||
int index = find_user(&s_database, username, length);
|
||||
return index >= 0 && expected->user_id != 0 && expected->auth_generation != 0 &&
|
||||
s_database.users[index].user_id == expected->user_id &&
|
||||
s_database.users[index].auth_generation == expected->auth_generation;
|
||||
}
|
||||
|
||||
static esp_err_t delete_user(const uint8_t *username, size_t username_length,
|
||||
const user_database_account_t *expected)
|
||||
{
|
||||
if (!s_initialized || s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
int index;
|
||||
esp_err_t error = mutate_user_begin(username, username_length, &index);
|
||||
esp_err_t error = target_matches_locked(username, username_length, expected)
|
||||
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
|
||||
if (error == ESP_OK) {
|
||||
const stored_user_t *user = &s_database.users[index];
|
||||
bool protected_migrated_admin =
|
||||
@@ -1090,8 +1121,8 @@ esp_err_t user_database_delete(const uint8_t *username, size_t username_length)
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_set_role(const uint8_t *username, size_t username_length,
|
||||
user_role_t role)
|
||||
static esp_err_t set_role(const uint8_t *username, size_t username_length,
|
||||
user_role_t role, const user_database_account_t *expected)
|
||||
{
|
||||
if (!s_initialized || s_mutex == NULL ||
|
||||
(role != USER_ROLE_USER && role != USER_ROLE_ADMIN)) {
|
||||
@@ -1099,7 +1130,8 @@ esp_err_t user_database_set_role(const uint8_t *username, size_t username_length
|
||||
}
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
int index;
|
||||
esp_err_t error = mutate_user_begin(username, username_length, &index);
|
||||
esp_err_t error = target_matches_locked(username, username_length, expected)
|
||||
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
|
||||
if (error == ESP_OK) {
|
||||
stored_user_t *user = &s_candidate->users[index];
|
||||
if (user->role == role) {
|
||||
@@ -1125,8 +1157,37 @@ esp_err_t user_database_set_role(const uint8_t *username, size_t username_length
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_set_password(const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length)
|
||||
esp_err_t user_database_delete(const uint8_t *username, size_t length)
|
||||
{
|
||||
return delete_user(username, length, NULL);
|
||||
}
|
||||
|
||||
esp_err_t user_database_set_role(const uint8_t *username, size_t length, user_role_t role)
|
||||
{
|
||||
return set_role(username, length, role, NULL);
|
||||
}
|
||||
|
||||
esp_err_t user_database_delete_current(const user_database_account_t *expected)
|
||||
{
|
||||
if (!expected) return ESP_ERR_INVALID_ARG;
|
||||
size_t length = strnlen(expected->username, sizeof(expected->username));
|
||||
if (!user_database_username_valid((const uint8_t *)expected->username, length))
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
return delete_user((const uint8_t *)expected->username, length, expected);
|
||||
}
|
||||
|
||||
esp_err_t user_database_set_role_current(const user_database_account_t *expected, user_role_t role)
|
||||
{
|
||||
if (!expected) return ESP_ERR_INVALID_ARG;
|
||||
size_t length = strnlen(expected->username, sizeof(expected->username));
|
||||
if (!user_database_username_valid((const uint8_t *)expected->username, length))
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
return set_role((const uint8_t *)expected->username, length, role, expected);
|
||||
}
|
||||
|
||||
static esp_err_t set_password(const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_database_account_t *expected)
|
||||
{
|
||||
if (!s_initialized || s_mutex == NULL ||
|
||||
!user_database_password_valid(password, password_length)) {
|
||||
@@ -1134,7 +1195,8 @@ esp_err_t user_database_set_password(const uint8_t *username, size_t username_le
|
||||
}
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
int index;
|
||||
esp_err_t error = mutate_user_begin(username, username_length, &index);
|
||||
esp_err_t error = target_matches_locked(username, username_length, expected)
|
||||
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
|
||||
if (error == ESP_OK) {
|
||||
stored_user_t *user = &s_candidate->users[index];
|
||||
error = set_record_password(user, password, password_length);
|
||||
@@ -1151,11 +1213,27 @@ esp_err_t user_database_set_password(const uint8_t *username, size_t username_le
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_set_password(const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length)
|
||||
{
|
||||
return set_password(username, username_length, password, password_length, NULL);
|
||||
}
|
||||
|
||||
esp_err_t user_database_set_password_current(const user_database_account_t *expected,
|
||||
const uint8_t *password, size_t password_length)
|
||||
{
|
||||
if (!expected) return ESP_ERR_INVALID_ARG;
|
||||
size_t length = strnlen(expected->username, sizeof(expected->username));
|
||||
if (!user_database_username_valid((const uint8_t *)expected->username, length))
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
return set_password((const uint8_t *)expected->username, length, password, password_length, expected);
|
||||
}
|
||||
|
||||
esp_err_t user_database_generate_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
user_database_generated_password_t *generated_password)
|
||||
{
|
||||
esp_err_t error = generate_password(generated_password);
|
||||
esp_err_t error = user_database_generate_password_value(generated_password);
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_set_password(username, username_length,
|
||||
generated_password->password,
|
||||
|
||||
@@ -105,6 +105,28 @@ esp_err_t user_database_recover_from_legacy(
|
||||
const user_database_legacy_credentials_t *legacy);
|
||||
esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot);
|
||||
|
||||
/* Compact secret-free list, zero-wait mutex acquisition; no key material. */
|
||||
typedef struct {
|
||||
uint32_t user_id, auth_generation;
|
||||
user_role_t role;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
||||
} user_database_account_t;
|
||||
typedef struct {
|
||||
size_t count;
|
||||
user_database_account_t users[USER_DATABASE_MAX_USERS];
|
||||
} user_database_accounts_t;
|
||||
esp_err_t user_database_get_accounts(user_database_accounts_t *accounts);
|
||||
/* Compare target identity under the mutation lock, before candidate/commit.
|
||||
* ESP_ERR_NOT_FOUND means absent or stale; existing account invariants apply. */
|
||||
esp_err_t user_database_delete_current(const user_database_account_t *expected);
|
||||
esp_err_t user_database_set_role_current(const user_database_account_t *expected,
|
||||
user_role_t role);
|
||||
esp_err_t user_database_set_password_current(const user_database_account_t *expected,
|
||||
const uint8_t *password, size_t password_length);
|
||||
/* RNG only: no database initialization, account mutation or persistence. Caller
|
||||
* owns/wipes successful output; failures clear it. Same generator as CLI. */
|
||||
esp_err_t user_database_generate_password_value(user_database_generated_password_t *generated);
|
||||
|
||||
esp_err_t user_database_authenticate_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
|
||||
@@ -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, ¤t);
|
||||
unsigned state = CANCELLED;
|
||||
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
||||
esp_timer_get_time() < operation.deadline) {
|
||||
/* CLI and typed mutations share this dispatcher. Target identity is also
|
||||
* compared under the database mutation lock, not just at HTTP admission. */
|
||||
switch (operation.action) {
|
||||
case ACTION_ROLE: error = user_database_set_role_current(&operation.target, operation.role); break;
|
||||
case ACTION_DELETE: error = user_database_delete_current(&operation.target); break;
|
||||
case ACTION_CREATE:
|
||||
error = user_database_create((const uint8_t *)operation.target.username,
|
||||
strlen(operation.target.username), operation.role, operation.password, operation.password_length);
|
||||
break;
|
||||
case ACTION_PASSWORD:
|
||||
error = user_database_set_password_current(&operation.target, operation.password, operation.password_length);
|
||||
break;
|
||||
}
|
||||
secure_wipe(operation.password, sizeof(operation.password));
|
||||
operation.password_length = 0;
|
||||
state = error == ESP_OK ? OK : error == ESP_ERR_NOT_FOUND ? STALE :
|
||||
error == ESP_ERR_INVALID_STATE ? (operation.action == ACTION_CREATE ? DUPLICATE : PROTECTED) :
|
||||
error == ESP_ERR_NO_MEM && operation.action == ACTION_CREATE ? FULL : FAILED;
|
||||
if (error == ESP_OK && operation.action != ACTION_CREATE) {
|
||||
size_t length = strlen(operation.target.username);
|
||||
(void)web_serial_transport_revoke_user((const uint8_t *)operation.target.username, length);
|
||||
(void)ssh_transport_revoke_user((const uint8_t *)operation.target.username, length);
|
||||
}
|
||||
}
|
||||
secure_wipe(operation.password, sizeof(operation.password));
|
||||
operation.password_length = 0;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.id == id && s_operation.state == PENDING && s_operation.executing) {
|
||||
s_operation.state = state;
|
||||
s_operation.executing = false;
|
||||
wipe_input(&s_operation);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
}
|
||||
|
||||
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(request, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
static esp_err_t list_accounts(httpd_req_t *request)
|
||||
{
|
||||
user_database_accounts_t accounts;
|
||||
if (user_database_get_accounts(&accounts) != ESP_OK)
|
||||
return respond(request, "503 Service Unavailable", "{\"error\":\"accounts_unavailable\"}");
|
||||
char body[1024];
|
||||
size_t used = (size_t)snprintf(body, sizeof(body), "{\"users\":[");
|
||||
for (size_t i = 0; i < accounts.count; ++i) {
|
||||
const user_database_account_t *user = &accounts.users[i];
|
||||
/* Database username policy makes these ASCII strings JSON-safe. */
|
||||
int written = snprintf(body + used, sizeof(body) - used,
|
||||
"%s{\"username\":\"%s\",\"user_id\":%" PRIu32 ",\"auth_generation\":%" PRIu32 ",\"role\":\"%s\"}",
|
||||
i ? "," : "", user->username, user->user_id, user->auth_generation, user_role_to_string(user->role));
|
||||
if (written < 0 || (size_t)written >= sizeof(body) - used) return ESP_FAIL;
|
||||
used += (size_t)written;
|
||||
}
|
||||
if (used + 3 > sizeof(body)) return ESP_FAIL;
|
||||
memcpy(body + used, "]}", 3);
|
||||
return respond(request, "200 OK", body);
|
||||
}
|
||||
|
||||
esp_err_t web_account_generate_password_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
user_database_generated_password_t generated = {0};
|
||||
char response[96] = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
error = user_database_generate_password_value(&generated);
|
||||
if (error != ESP_OK) {
|
||||
secure_wipe(&generated, sizeof(generated));
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
bool current = false;
|
||||
error = web_session_store_check_principal(view.id, &view.principal, ¤t);
|
||||
if (error != ESP_OK || !current) {
|
||||
secure_wipe(&generated, sizeof(generated));
|
||||
error = respond(request, "401 Unauthorized", "{\"error\":\"authentication_required\"}");
|
||||
goto done;
|
||||
}
|
||||
int written = snprintf(response, sizeof(response), "{\"password\":\"%s\"}", (const char *)generated.password);
|
||||
secure_wipe(&generated, sizeof(generated));
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&generated, sizeof(generated));
|
||||
secure_wipe(response, sizeof(response));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_account_settings_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
account_operation_t operation = {0};
|
||||
bool allowed = false, mutation = request->method == HTTP_POST;
|
||||
esp_err_t error = mutation ? web_cookie_auth_require_json(request, 768, &view, &allowed) :
|
||||
web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
if (!strcmp(request->uri, "/api/settings/accounts")) {
|
||||
error = mutation ? respond(request, "400 Bad Request", "{\"error\":\"invalid_request\"}") : list_accounts(request);
|
||||
goto done;
|
||||
}
|
||||
if (mutation) {
|
||||
char type[40] = {0}, body[768];
|
||||
size_t received = 0;
|
||||
bool valid = request->content_len && request->content_len <= sizeof(body) &&
|
||||
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
|
||||
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
|
||||
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
|
||||
int count = httpd_req_recv(request, body + received, request->content_len - received);
|
||||
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
|
||||
else received += (size_t)count;
|
||||
}
|
||||
valid = valid && received == request->content_len && parse(body, received, &operation);
|
||||
secure_wipe(body, sizeof(body));
|
||||
if (!valid) {
|
||||
wipe_input(&operation);
|
||||
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}");
|
||||
goto done;
|
||||
}
|
||||
if (credential_action(operation.action) && !ensure_secret_timer()) {
|
||||
wipe_input(&operation);
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
operation.session = view.id;
|
||||
operation.principal = view.principal;
|
||||
operation.deadline = esp_timer_get_time() + 30000000LL;
|
||||
operation.state = PENDING;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
|
||||
if (!busy) { operation.id = ++s_next_id; s_operation = operation; }
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (busy || admin_ssh_console_submit_account_settings(operation.id) != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!busy && s_operation.id == operation.id && !s_operation.executing)
|
||||
secure_wipe(&s_operation, sizeof(s_operation));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
wipe_input(&operation);
|
||||
error = httpd_resp_set_hdr(request, "Retry-After", "1");
|
||||
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
|
||||
goto done;
|
||||
}
|
||||
} else {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.session == view.id) {
|
||||
operation.id = s_operation.id;
|
||||
operation.action = s_operation.action;
|
||||
operation.state = s_operation.state;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
wipe_input(&operation);
|
||||
char response[96];
|
||||
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
|
||||
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
||||
respond(request, mutation ? "202 Accepted" : "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -208,6 +208,23 @@ static bool string(json_cursor_t *c, uint8_t *out, size_t capacity, size_t *leng
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_json_string(const char *body, size_t length, size_t *position,
|
||||
uint8_t *output, size_t capacity, size_t *decoded_length)
|
||||
{
|
||||
if (output && capacity) wipe(output, capacity);
|
||||
if (decoded_length) *decoded_length = 0;
|
||||
if (!body || !position || *position > length || !output || !capacity || !decoded_length)
|
||||
return false;
|
||||
json_cursor_t c = { (const uint8_t *)body, length, *position };
|
||||
if (!string(&c, output, capacity - 1U, decoded_length)) {
|
||||
wipe(output, capacity);
|
||||
*decoded_length = 0;
|
||||
return false;
|
||||
}
|
||||
*position = c.pos;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_login(const char *body, size_t length,
|
||||
web_auth_credentials_t *credentials)
|
||||
{
|
||||
|
||||
@@ -40,6 +40,11 @@ bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
|
||||
* lets HTTP policy distinguish absence from malformed/ambiguous cookies. */
|
||||
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present);
|
||||
/* Decode one string at *position (including optional JSON whitespace). Capacity
|
||||
* includes the terminator. Failure wipes output and leaves position unchanged.
|
||||
* Success output is sensitive; caller must wipe it. Same strict decoder as login. */
|
||||
bool web_auth_parse_json_string(const char *body, size_t length, size_t *position,
|
||||
uint8_t *output, size_t capacity, size_t *decoded_length);
|
||||
/* Exactly username/password string fields, either order. JSON escapes and valid
|
||||
* UTF-8 accepted; unknown/duplicate fields, NUL and malformed Unicode rejected.
|
||||
* Database credential policy remains authoritative. Caller must wipe BOTH the
|
||||
|
||||
+21
-1
@@ -24,6 +24,7 @@
|
||||
#include "web_security.h"
|
||||
#include "web_serial_transport.h"
|
||||
#include "web_serial_settings.h"
|
||||
#include "web_account_settings.h"
|
||||
#include "web_admin_transport.h"
|
||||
#include "web_session_store.h"
|
||||
#include "web_cookie_auth.h"
|
||||
@@ -388,6 +389,20 @@ static const httpd_uri_t s_serial_operation_post_uri = {
|
||||
.handler = web_serial_settings_handler,
|
||||
};
|
||||
|
||||
static const httpd_uri_t s_accounts_uri = {
|
||||
.uri = "/api/settings/accounts", .method = HTTP_GET, .handler = web_account_settings_handler,
|
||||
};
|
||||
static const httpd_uri_t s_account_operation_get_uri = {
|
||||
.uri = "/api/settings/account-operation", .method = HTTP_GET, .handler = web_account_settings_handler,
|
||||
};
|
||||
static const httpd_uri_t s_account_operation_post_uri = {
|
||||
.uri = "/api/settings/account-operation", .method = HTTP_POST, .handler = web_account_settings_handler,
|
||||
};
|
||||
static const httpd_uri_t s_account_generate_password_uri = {
|
||||
.uri = "/api/settings/accounts/generate-password", .method = HTTP_POST,
|
||||
.handler = web_account_generate_password_handler,
|
||||
};
|
||||
|
||||
static const httpd_uri_t s_root_uri = {
|
||||
.uri = "/",
|
||||
.method = HTTP_GET,
|
||||
@@ -576,7 +591,7 @@ esp_err_t web_server_start(void)
|
||||
config.httpd.max_open_sockets = 6;
|
||||
config.httpd.max_uri_handlers =
|
||||
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
|
||||
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 5U;
|
||||
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 9U;
|
||||
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
|
||||
config.httpd.lru_purge_enable = false;
|
||||
config.httpd.recv_wait_timeout = 1;
|
||||
@@ -627,6 +642,11 @@ esp_err_t web_server_start(void)
|
||||
if (web_httpd_register_optional(server, &s_serial_operation_get_uri) == ESP_OK &&
|
||||
web_httpd_register_optional(server, &s_serial_operation_post_uri) != ESP_OK)
|
||||
(void)httpd_unregister_uri_handler(server, s_serial_operation_get_uri.uri, HTTP_GET);
|
||||
if (web_httpd_register_optional_get(server, &s_accounts_uri) == ESP_OK &&
|
||||
web_httpd_register_optional_get(server, &s_account_operation_get_uri) == ESP_OK &&
|
||||
web_httpd_register_optional(server, &s_account_operation_post_uri) != ESP_OK)
|
||||
(void)httpd_unregister_uri_handler(server, s_account_operation_get_uri.uri, HTTP_GET);
|
||||
(void)web_httpd_register_optional(server, &s_account_generate_password_uri);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
web_cookie_auth_stop();
|
||||
|
||||
+195
-3
@@ -178,7 +178,9 @@ static const char s_index_html[] =
|
||||
"<div id=\"terminal\" class=\"terminal-host\"></div>\n"
|
||||
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
|
||||
"<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>"
|
||||
"<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>"
|
||||
@@ -212,7 +214,36 @@ static const char s_index_html[] =
|
||||
"<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-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"
|
||||
"</main>\n"
|
||||
@@ -281,6 +312,7 @@ static const char s_app_js[] =
|
||||
" element('serial-result').disabled = busy;\n"
|
||||
"}\n"
|
||||
"function clearSettings() {\n"
|
||||
" clearAccounts();\n"
|
||||
" if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n"
|
||||
" stopSerialAuto(true);\n"
|
||||
" if (settingsAbort) settingsAbort.abort();\n"
|
||||
@@ -292,6 +324,7 @@ static const char s_app_js[] =
|
||||
" element('refresh-settings').disabled = false; settingsDetail.textContent = 'Select Refresh to read current values.';\n"
|
||||
"}\n"
|
||||
"async function refreshSettings() {\n"
|
||||
" if (settingsDomain === 'accounts') return refreshAccounts();\n"
|
||||
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
|
||||
" settingsHost.hidden = false;\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
|
||||
@@ -317,6 +350,7 @@ static const char s_app_js[] =
|
||||
" }\n"
|
||||
"}\n"
|
||||
"async function serialOperation(action, automatic = false) {\n"
|
||||
" if (settingsDomain !== 'serial') return;\n"
|
||||
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || (action && serialOperationPending)) return;\n"
|
||||
" if (!automatic && serialAuto) return;\n"
|
||||
" const detail = element('serial-operation-detail');\n"
|
||||
@@ -383,6 +417,163 @@ static const char s_app_js[] =
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n"
|
||||
"let secretEpoch = 0, secretAbort = null, secretTimer = null, generatedPassword = '', generatedContext = '', savedContext = '', secretExpires = 0;\n"
|
||||
"function secretContext() { const t = accounts[Number(element('account-target').value)]; return JSON.stringify([element('account-purpose').value, element('account-username').value, element('account-create-role').value, t?.username, t?.user_id, t?.auth_generation]); }\n"
|
||||
"function invalidateSecretRequest() { ++secretEpoch; if (secretAbort) secretAbort.abort(); secretAbort = null; savedContext = ''; element('account-password-saved').checked = false; }\n"
|
||||
"function clearAccountSecret() {\n"
|
||||
" invalidateSecretRequest(); window.clearTimeout(secretTimer); secretTimer = null; secretExpires = 0; generatedPassword = generatedContext = '';\n"
|
||||
" for (const id of ['account-password','account-password-confirm','account-generated']) element(id).value = '';\n"
|
||||
" element('account-generated-panel').hidden = true; element('account-secret-detail').textContent = '';\n"
|
||||
"}\n"
|
||||
"async function generateAccountPassword() {\n"
|
||||
" if (!accountsLive() || accountsAbort || accountPending || secretAbort) return;\n"
|
||||
" clearAccountSecret(); const context = secretContext(), epoch = secretEpoch, generation = workGeneration, controller = new AbortController(); secretAbort = controller; accountButtons();\n"
|
||||
" const current = () => secretAbort === controller && secretEpoch === epoch && context === secretContext() && accountsLive();\n"
|
||||
" let payload;\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
||||
" ({payload} = await api('/api/settings/accounts/generate-password', generation, {method: 'POST', signal: controller.signal, limit: 96, current}));\n"
|
||||
" if (!payload || Object.keys(payload).length !== 1 || typeof payload.password !== 'string' || !/^[A-Za-z0-9_-]{24}$/.test(payload.password)) throw new Error('Invalid generated password');\n"
|
||||
" generatedPassword = payload.password; generatedContext = context; secretExpires = performance.now() + 60000;\n"
|
||||
" element('account-password').value = element('account-generated').value = generatedPassword; element('account-generated-panel').hidden = false;\n"
|
||||
" secretTimer = window.setTimeout(() => { clearAccountSecret(); accountButtons(); }, 60000);\n"
|
||||
" } catch (error) { if (current()) { clearAccountSecret(); element('account-secret-detail').textContent = 'Password generation unavailable. Nothing applied. Retry only explicitly.'; } }\n"
|
||||
" finally { if (payload) payload.password = ''; payload = null; if (secretAbort === controller) secretAbort = null; accountButtons(); }\n"
|
||||
"}\n"
|
||||
"element('account-purpose').value = 'create'; element('account-create-role').value = 'user';\n"
|
||||
"function accountsLive() { return selected === 'settings' && settingsDomain === 'accounts' && accountRole === 'admin' && sessionVerified && !suspended && !unloading && !navigating && !loggingOut; }\n"
|
||||
"function accountButtons() {\n"
|
||||
" const busy = !!accountsAbort, target = accounts[Number(element('account-target').value)];\n"
|
||||
" const blocked = busy || accountPending || !target;\n"
|
||||
" element('account-delete').disabled = element('account-change-role').disabled = blocked;\n"
|
||||
" element('account-target').disabled = element('account-role').disabled = busy || accountPending || !accounts.length;\n"
|
||||
" element('account-result').disabled = element('refresh-accounts').disabled = busy;\n"
|
||||
" const create = element('account-purpose').value === 'create';\n"
|
||||
" element('account-username-label').hidden = element('account-create-role-label').hidden = !create;\n"
|
||||
" element('account-generate').disabled = busy || accountPending || !!secretAbort || (!create && !target);\n"
|
||||
" element('account-submit-password').disabled = busy || accountPending || !!secretAbort || (!create && !target);\n"
|
||||
" for (const id of ['account-purpose','account-username','account-create-role','account-password','account-password-confirm','account-password-saved']) element(id).disabled = busy || accountPending;\n"
|
||||
"}\n"
|
||||
"function clearAccounts() {\n"
|
||||
" clearAccountSecret();\n"
|
||||
" if (accountsAbort) accountsAbort.abort();\n"
|
||||
" accountsAbort = null; accounts = []; element('accounts-list').textContent = '';\n"
|
||||
" for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = ''; option.hidden = option.disabled = true; }\n"
|
||||
" if (accountPending) element('account-operation-detail').textContent = accountWarning + 'Outcome pending or unknown. Check Result on return; navigation does not cancel backend work.';\n"
|
||||
" accountButtons();\n"
|
||||
"}\n"
|
||||
"function selectSettingsDomain(domain) {\n"
|
||||
" if (!sessionVerified || accountRole !== 'admin' || selected !== 'settings' || domain === settingsDomain) return;\n"
|
||||
" clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\n"
|
||||
" element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts';\n"
|
||||
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
|
||||
" refreshSettings();\n"
|
||||
"}\n"
|
||||
"async function refreshAccounts() {\n"
|
||||
" if (!accountsLive() || accountsAbort) return;\n"
|
||||
" clearAccountSecret();\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; accountsAbort = controller; accountButtons();\n"
|
||||
" const current = () => accountsAbort === controller && accountsLive();\n"
|
||||
" element('accounts-detail').textContent = 'Reading accounts; previous list may be stale.';\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
||||
" const {payload} = await api('/api/settings/accounts', generation, {signal: controller.signal, limit: 1024, current});\n"
|
||||
" const validId = n => Number.isInteger(n) && n > 0 && n <= 4294967295;\n"
|
||||
" if (!payload || Object.keys(payload).length !== 1 || !Array.isArray(payload.users) || payload.users.length > 8 ||\n"
|
||||
" !payload.users.every(u => u && Object.keys(u).length === 4 && typeof u.username === 'string' && /^[a-z][a-z0-9_-]{0,15}$/.test(u.username) && validId(u.user_id) && validId(u.auth_generation) && ['user','admin'].includes(u.role)) ||\n"
|
||||
" new Set(payload.users.map(u => u.username)).size !== payload.users.length) throw new Error('Invalid account list');\n"
|
||||
" clearAccountSecret(); accounts = payload.users;\n"
|
||||
" element('accounts-list').textContent = accounts.map(u => u.username + ' — ' + u.role + (u.username === sessionIdentity.username ? ' (you)' : '')).join('\\n');\n"
|
||||
" for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = accounts[i]?.username || ''; option.hidden = option.disabled = !accounts[i]; }\n"
|
||||
" element('account-target').value = '0'; element('account-role').value = accounts[0]?.role || 'user';\n"
|
||||
" element('accounts-detail').textContent = accountPending ? 'List may be stale while operation outcome is pending or unknown.' : 'Account list refreshed. Select an account before changing it.';\n"
|
||||
" } catch (error) { if (live(generation) && current()) element('accounts-detail').textContent = (error.status ? error.message : 'Account list unavailable or invalid.') + ' List stale. Refresh to retry.'; }\n"
|
||||
" finally { if (current()) { accountsAbort = null; accountButtons(); } }\n"
|
||||
"}\n"
|
||||
"async function accountOperation(action) {\n"
|
||||
" if (!accountsLive() || accountsAbort || (action && accountPending)) return;\n"
|
||||
" let body;\n"
|
||||
" if (action) {\n"
|
||||
" const target = accounts[Number(element('account-target').value)], role = element('account-role').value;\n"
|
||||
" const credential = action === 'create' || action === 'password';\n"
|
||||
" let request;\n"
|
||||
" if (credential) {\n"
|
||||
" try {\n"
|
||||
" const password = element('account-password').value, username = action === 'create' ? element('account-username').value : target?.username, initialRole = element('account-create-role').value;\n"
|
||||
" if (secretAbort || !/^[a-z][a-z0-9_-]{0,15}$/.test(username || '') || !['user','admin'].includes(initialRole) || typeof password !== 'string' || !/^[ -~]{12,64}$/.test(password) || password !== element('account-password-confirm').value ||\n"
|
||||
" (generatedPassword && (performance.now() >= secretExpires || password !== generatedPassword || generatedContext !== secretContext() || savedContext !== generatedContext || !element('account-password-saved').checked))) {\n"
|
||||
" element('account-operation-detail').textContent = 'Not submitted. Check username, password length/ASCII, confirmation and generated-password saved acknowledgement; re-enter secrets.'; return;\n"
|
||||
" }\n"
|
||||
" request = action === 'create' ? {action, username, role: initialRole, password} : {action, username, user_id: target.user_id, auth_generation: target.auth_generation, password};\n"
|
||||
" body = JSON.stringify(request);\n"
|
||||
" } finally { clearAccountSecret(); if (request) request.password = ''; request = null; }\n"
|
||||
" } else {\n"
|
||||
" clearAccountSecret(); if (!target || !['role','delete'].includes(action) || !['user','admin'].includes(role)) return;\n"
|
||||
" body = JSON.stringify({action, username: target.username, user_id: target.user_id, auth_generation: target.auth_generation, ...(action === 'role' ? {role} : {})});\n"
|
||||
" }\n"
|
||||
" if (encoder.encode(body).length > 768) { body = undefined; return; }\n"
|
||||
" const name = action === 'create' ? element('account-username').value : target.username;\n"
|
||||
" const warning = name === sessionIdentity.username ? ' ALL this account’s web/SSH sessions, including this browser serial/admin, can close immediately (even a no-op role change). A 401 or disconnect is NOT proof of success. Save the password before submitting, then re-login and inspect if the result is lost.' : '';\n"
|
||||
" if (!window.confirm((action === 'delete' ? 'Delete ' : action === 'create' ? 'Create ' + element('account-create-role').value + ' account ' : action === 'password' ? 'Change password for ' : 'Change role to ' + role + ' for ') + name + '? Saved immediately; affected account sessions may be revoked.' + warning)) { body = undefined; return; }\n"
|
||||
" }\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; accountsAbort = controller; accountButtons();\n"
|
||||
" const current = () => accountsAbort === controller && accountsLive();\n"
|
||||
" controller.signal.addEventListener('abort', () => { body = undefined; }, {once: true});\n"
|
||||
" const detail = element('account-operation-detail'); let deadline, refresh = false, until = Infinity;\n"
|
||||
" detail.textContent = accountWarning + (action ? 'Submitting once...' : 'Reading latest result...');\n"
|
||||
" element('accounts-detail').textContent = 'List may be stale until operation completes and refresh succeeds.';\n"
|
||||
" const read = async (method, requestBody) => {\n"
|
||||
" const response = api('/api/settings/account-operation', generation, {method, body: requestBody, signal: controller.signal, limit: 96, current: () => current() && performance.now() < until}); requestBody = undefined;\n"
|
||||
" const {payload: result} = await response;\n"
|
||||
" if (performance.now() >= until) throw new Error('Check deadline');\n"
|
||||
" if (!result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 || !['none','role','delete','create','password'].includes(result.action) ||\n"
|
||||
" !['idle','pending','ok','failed','cancelled','stale','protected','duplicate','full'].includes(result.state) || ((result.id === 0) !== (result.state === 'idle')) || ((result.id === 0) !== (result.action === 'none')) ||\n"
|
||||
" (method === 'POST' && (!result.id || result.action !== action || result.state !== 'pending'))) throw new Error('Invalid operation result');\n"
|
||||
" if (method === 'POST') accountWarning = '';\n"
|
||||
" else if (accountAwaitingAck) accountWarning = 'Acknowledgement lost: latest result may belong to another request. Inspect before retrying. ';\n"
|
||||
" else if (accountId && result.id !== accountId) accountWarning = 'Previous result replaced or unavailable; outcome unknown. ';\n"
|
||||
" accountId = result.id; accountPending = result.state === 'pending'; accountAwaitingAck = false;\n"
|
||||
" const messages = {duplicate: 'Username already exists. Refresh before retrying.', full: 'Account capacity full. Inspect accounts before retrying.', idle: 'No retained result. Inspect accounts before retrying.', pending: 'Queued or executing...', ok: 'Account change completed and saved.', failed: 'Operation failed. Inspect accounts before retrying.', cancelled: 'Cancelled before execution: login or queue deadline stale.', stale: 'Account changed or was replaced. Refresh and select it again.', protected: 'Account is protected (including the final administrator), or database unavailable.'};\n"
|
||||
" detail.textContent = accountWarning + result.action + ': ' + messages[result.state];\n"
|
||||
" return result.state;\n"
|
||||
" };\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
||||
" if (action) { accountPending = true; accountAwaitingAck = true; }\n"
|
||||
" const response = read(action ? 'POST' : 'GET', body); body = undefined;\n"
|
||||
" let state = await response;\n"
|
||||
" if (action) {\n"
|
||||
" until = performance.now() + 15000; deadline = window.setTimeout(() => controller.abort(), 15000);\n"
|
||||
" controller.signal.addEventListener('abort', () => window.clearTimeout(deadline), {once: true});\n"
|
||||
" for (let attempt = 0; state === 'pending' && attempt < 10; ++attempt) {\n"
|
||||
" await new Promise((resolve, reject) => {\n"
|
||||
" const abort = () => { window.clearTimeout(timer); controller.signal.removeEventListener('abort', abort); reject(new Error('Cancelled')); };\n"
|
||||
" const timer = window.setTimeout(() => { controller.signal.removeEventListener('abort', abort); resolve(); }, 1000);\n"
|
||||
" controller.signal.addEventListener('abort', abort, {once: true}); if (controller.signal.aborted) abort();\n"
|
||||
" });\n"
|
||||
" if (!current() || performance.now() >= until) throw new Error('Check deadline');\n"
|
||||
" if (!await loadSession(generation, controller.signal, false) || !current() || performance.now() >= until) throw new Error('Session changed or check deadline');\n"
|
||||
" state = await read('GET');\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" if (state === 'pending') detail.textContent += ' Automatic checking stopped. Use Check Result; do not resubmit.';\n"
|
||||
" refresh = state !== 'pending' && state !== 'idle';\n"
|
||||
" } catch (error) { if (live(generation) && current()) detail.textContent = accountWarning + (error.status ? error.message : 'Outcome unknown.') + ' Use Check Result and Refresh before an explicit retry. No automatic retry.'; }\n"
|
||||
" finally { body = undefined; window.clearTimeout(deadline); if (current()) { accountsAbort = null; accountButtons(); if (refresh) await refreshAccounts(); } }\n"
|
||||
"}\n"
|
||||
"element('settings-serial').addEventListener('click', () => selectSettingsDomain('serial'));\n"
|
||||
"element('settings-accounts').addEventListener('click', () => selectSettingsDomain('accounts'));\n"
|
||||
"element('refresh-accounts').addEventListener('click', refreshAccounts);\n"
|
||||
"element('account-result').addEventListener('click', () => accountOperation(null));\n"
|
||||
"element('account-delete').addEventListener('click', () => accountOperation('delete'));\n"
|
||||
"element('account-change-role').addEventListener('click', () => accountOperation('role'));\n"
|
||||
"element('account-generate').addEventListener('click', generateAccountPassword);\n"
|
||||
"element('account-submit-password').addEventListener('click', () => accountOperation(element('account-purpose').value));\n"
|
||||
"element('account-password-saved').addEventListener('change', () => { savedContext = element('account-password-saved').checked && generatedPassword && element('account-password').value === generatedPassword && generatedContext === secretContext() && performance.now() < secretExpires ? generatedContext : ''; });\n"
|
||||
"for (const id of ['account-password','account-password-confirm']) element(id).addEventListener('input', () => { invalidateSecretRequest(); accountButtons(); });\n"
|
||||
"for (const id of ['account-purpose','account-username','account-create-role','account-role']) element(id).addEventListener(id === 'account-username' ? 'input' : 'change', () => { clearAccountSecret(); accountButtons(); });\n"
|
||||
"element('account-target').addEventListener('change', () => { clearAccountSecret(); element('account-role').value = accounts[Number(element('account-target').value)]?.role || 'user'; accountButtons(); });\n"
|
||||
"let accountRole = 'user', selected = 'serial';\n"
|
||||
"let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n"
|
||||
"let adminGeneration = 0, adminTimer = null;\n"
|
||||
@@ -576,9 +767,10 @@ static const char s_app_js[] =
|
||||
" const timeout = window.setTimeout(abort, 15000);\n"
|
||||
" try {\n"
|
||||
/* Non-CORS POST with no-referrer serializes Origin as null in browsers. */
|
||||
" const response = await fetch(path, {method, credentials: 'same-origin', mode: method === 'POST' ? 'cors' : 'same-origin',\n"
|
||||
" const pendingResponse = fetch(path, {method, credentials: 'same-origin', mode: method === 'POST' ? 'cors' : 'same-origin',\n"
|
||||
" cache: 'no-store', redirect: 'error', signal: controller.signal,\n"
|
||||
" ...(method === 'POST' ? {headers: {'X-CSRF-Token': csrf, ...(body === undefined ? {} : {'Content-Type': 'application/json'})}, body: body === undefined ? '' : body} : {})});\n"
|
||||
" body = undefined; const response = await pendingResponse;\n"
|
||||
" if (!live(generation) || controller.signal.aborted || !current()) throw new Error('Cancelled');\n"
|
||||
" if (response.status === 401) { login(); throw new Error('Session ended.'); }\n"
|
||||
" if (!response.ok) {\n"
|
||||
|
||||
@@ -39,8 +39,109 @@ static void unchanged(const stored_database_t *before)
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)));
|
||||
assert(!locks);
|
||||
}
|
||||
static void typed_account_tests(void)
|
||||
{
|
||||
reset(); user_database_accounts_t list;
|
||||
assert(user_database_get_accounts(&list)==ESP_OK && last_wait==0 && list.count==3);
|
||||
assert(!strcmp(list.users[1].username,"other"));
|
||||
user_database_account_t other=list.users[1], admin=list.users[0];
|
||||
snapshot_busy=true; memset(&list,0xff,sizeof(list));
|
||||
assert(user_database_get_accounts(&list)==ESP_ERR_TIMEOUT && all_zero(&list,sizeof(list)));
|
||||
snapshot_busy=false;
|
||||
assert(user_database_delete_current(&admin)==ESP_ERR_INVALID_STATE);
|
||||
assert(user_database_set_role_current(&admin,USER_ROLE_USER)==ESP_ERR_INVALID_STATE);
|
||||
assert(!writes && !commits);
|
||||
assert(user_database_set_role_current(&other,USER_ROLE_ADMIN)==ESP_OK);
|
||||
unsigned saved=commits;
|
||||
assert(user_database_delete_current(&other)==ESP_ERR_NOT_FOUND && commits==saved);
|
||||
assert(user_database_set_role_current(&other,USER_ROLE_USER)==ESP_ERR_NOT_FOUND);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
|
||||
for (fail_stage=1;fail_stage<=3;++fail_stage) {
|
||||
stored_database_t before=s_database;
|
||||
assert(user_database_delete_current(&other)==ESP_FAIL); unchanged(&before);
|
||||
assert(user_database_set_role_current(&other,USER_ROLE_USER)==ESP_FAIL); unchanged(&before);
|
||||
}
|
||||
fail_stage=0; assert(user_database_delete_current(&other)==ESP_OK);
|
||||
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,(const uint8_t *)"test-password",13)==ESP_OK);
|
||||
assert(user_database_delete_current(&other)==ESP_ERR_NOT_FOUND);
|
||||
assert(user_database_set_role_current(&other,USER_ROLE_ADMIN)==ESP_ERR_NOT_FOUND);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
|
||||
assert(user_database_delete_current(&other)==ESP_OK);
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)) && !locks);
|
||||
s_initialized=false; memset(&list,0xff,sizeof(list));
|
||||
assert(user_database_get_accounts(&list)==ESP_ERR_INVALID_STATE && all_zero(&list,sizeof(list)));
|
||||
assert(user_database_delete_current(NULL)==ESP_ERR_INVALID_ARG);
|
||||
}
|
||||
static void typed_password_tests(void)
|
||||
{
|
||||
reset(); user_database_accounts_t list;
|
||||
assert(user_database_get_accounts(&list)==ESP_OK);
|
||||
user_database_account_t other=list.users[1], admin=list.users[0];
|
||||
const uint8_t password[]="quote\"slash\\ space";
|
||||
for (unsigned stage=1;stage<=5;++stage) {
|
||||
fail_stage=stage; stored_database_t before=s_database;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_FAIL);
|
||||
unchanged(&before);
|
||||
}
|
||||
fail_stage=0; writes=commits=0;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_OK);
|
||||
assert(writes==1 && commits==1 && s_database.users[1].auth_generation==other.auth_generation+1);
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)));
|
||||
stored_database_t before=s_database; unsigned rng=random_calls;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
|
||||
unchanged(&before); assert(writes==1 && commits==1 && random_calls==rng);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
|
||||
assert(user_database_delete_current(&other)==ESP_OK);
|
||||
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,password,sizeof(password)-1)==ESP_OK);
|
||||
before=s_database; rng=random_calls;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
|
||||
unchanged(&before); assert(random_calls==rng);
|
||||
assert(user_database_set_password_current(NULL,password,sizeof(password)-1)==ESP_ERR_INVALID_ARG);
|
||||
other.user_id=0;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
|
||||
memset(other.username,'x',sizeof(other.username));
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_INVALID_ARG);
|
||||
assert(user_database_set_password_current(&admin,(const uint8_t *)"short",5)==ESP_ERR_INVALID_ARG);
|
||||
/* Own password is allowed even for the last administrator; old principal is stale. */
|
||||
assert(user_database_set_password_current(&admin,password,sizeof(password)-1)==ESP_OK);
|
||||
bool current=true; assert(user_database_principal_is_current(&actor,¤t)==ESP_OK && !current);
|
||||
assert(s_database.admin_count==1);
|
||||
/* With a second admin, canonical self role/delete invariants allow both. */
|
||||
assert(user_database_set_role((const uint8_t *)"other",5,USER_ROLE_ADMIN)==ESP_OK);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); admin=list.users[0];
|
||||
assert(user_database_set_role_current(&admin,USER_ROLE_USER)==ESP_OK);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); admin=list.users[0];
|
||||
assert(user_database_delete_current(&admin)==ESP_OK);
|
||||
reset(); before=s_database; rng=random_calls;
|
||||
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_ADMIN,password,sizeof(password)-1)==ESP_ERR_INVALID_STATE);
|
||||
unchanged(&before); assert(!writes && !commits && rng==random_calls);
|
||||
for (unsigned i=3;i<USER_DATABASE_MAX_USERS;++i) {
|
||||
char name[17]; snprintf(name,sizeof(name),"account%u",i);
|
||||
assert(user_database_create((const uint8_t *)name,strlen(name),USER_ROLE_USER,password,sizeof(password)-1)==ESP_OK);
|
||||
}
|
||||
before=s_database; rng=random_calls; unsigned saved=commits;
|
||||
assert(user_database_create((const uint8_t *)"extra",5,USER_ROLE_USER,password,sizeof(password)-1)==ESP_ERR_NO_MEM);
|
||||
unchanged(&before); assert(commits==saved && rng==random_calls);
|
||||
/* RNG-only helper is independent of initialized storage and leaves all DB state alone. */
|
||||
s_initialized=false; s_mutex=NULL;
|
||||
for (unsigned mode=0;mode<2;++mode) {
|
||||
user_database_generated_password_t generated; memset(&generated,0xa5,sizeof(generated));
|
||||
fail_stage=mode ? 4 : 0;
|
||||
assert(user_database_generate_password_value(&generated)==(mode ? ESP_FAIL : ESP_OK));
|
||||
if (mode) assert(all_zero(&generated,sizeof(generated)));
|
||||
else {
|
||||
assert(generated.password_length==24 && strlen((const char *)generated.password)==24);
|
||||
for (size_t i=0;i<24;++i) assert(strchr((const char *)s_generated_alphabet,generated.password[i]));
|
||||
}
|
||||
assert(!memcmp(&before,&s_database,sizeof(before)) && commits==saved && !locks);
|
||||
secure_wipe(&generated,sizeof(generated));
|
||||
}
|
||||
assert(user_database_generate_password_value(NULL)==ESP_ERR_INVALID_ARG);
|
||||
}
|
||||
int main(void)
|
||||
{
|
||||
typed_account_tests();
|
||||
typed_password_tests();
|
||||
const char *supported[]={
|
||||
"user add fresh user", "user add fresh admin", "user password other",
|
||||
"user delete other --force", "user role other admin --force",
|
||||
|
||||
@@ -24,6 +24,7 @@ db = (ROOT / "src/user_database.c").read_text()
|
||||
console = (ROOT / "src/user_console.c").read_text()
|
||||
admin = (ROOT / "src/admin_ssh_console.c").read_text()
|
||||
prelude = r'''
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
@@ -33,7 +34,10 @@ prelude = r'''
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
|
||||
ESP_ERR_NO_MEM, ESP_ERR_NOT_FOUND, ESP_ERR_NOT_ALLOWED,
|
||||
ESP_ERR_INVALID_RESPONSE, ESP_ERR_INVALID_VERSION };
|
||||
ESP_ERR_INVALID_RESPONSE, ESP_ERR_INVALID_VERSION, ESP_ERR_TIMEOUT };
|
||||
#define pdTRUE 1
|
||||
static bool snapshot_busy;
|
||||
static int last_wait;
|
||||
typedef void *SemaphoreHandle_t;
|
||||
#define portMAX_DELAY 0
|
||||
#define NVS_READWRITE 1
|
||||
@@ -45,7 +49,7 @@ static bool owner_current = true, remote = true, web = true, mismatch, cancel_pr
|
||||
static int notify_error = ESP_OK;
|
||||
static char revoked_name[17];
|
||||
static void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
|
||||
static void xSemaphoreTake(void *m, int t) { (void)m; (void)t; assert(!locks++); }
|
||||
static int xSemaphoreTake(void *m, int t) { (void)m; last_wait=t; if (snapshot_busy) return 0; assert(!locks++); return pdTRUE; }
|
||||
static void xSemaphoreGive(void *m) { (void)m; assert(locks-- == 1); }
|
||||
static const char *esp_err_to_name(int e) { (void)e; return "injected error"; }
|
||||
static int nvs_open(const char *ns, int mode, int *h) {
|
||||
@@ -143,8 +147,11 @@ db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
|
||||
"find_free_user", "stored_keys_equal", "validate_database", "recount",
|
||||
"next_generation", "discard_candidate", "commit_candidate_locked", "initialize_user",
|
||||
"user_database_principal_is_current", "create_locked", "user_database_create",
|
||||
"mutate_user_begin", "user_database_delete", "user_database_set_role",
|
||||
"user_database_set_password"]
|
||||
"mutate_user_begin", "target_matches_locked", "delete_user", "set_role",
|
||||
"user_database_delete", "user_database_set_role", "user_database_get_accounts",
|
||||
"user_database_delete_current", "user_database_set_role_current",
|
||||
"set_password", "user_database_set_password", "user_database_set_password_current",
|
||||
"user_database_generate_password_value"]
|
||||
console_names = ["print_usage", "revoke_user_network_sessions", "read_password",
|
||||
"show_generated_password", "mutation_currentness", "add_user", "change_password",
|
||||
"parse_key_index", "command_user_inner", "command_user"]
|
||||
|
||||
@@ -31,7 +31,8 @@ typedef int *SemaphoreHandle_t;
|
||||
#define pdMS_TO_TICKS(x) (x)
|
||||
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
|
||||
static unsigned lock_depth, ticks, runs, actions;
|
||||
static uint32_t serial_settings_executed;
|
||||
static uint32_t serial_settings_executed, account_settings_executed;
|
||||
static void web_account_settings_execute(uint32_t id) { assert(!lock_depth); account_settings_executed = id; }
|
||||
static unsigned serial_settings_preceding_runs, queue_send_wait;
|
||||
static void web_serial_settings_execute(uint32_t id) {
|
||||
assert(!lock_depth);
|
||||
|
||||
@@ -340,5 +340,16 @@ int main(void)
|
||||
pump(worker_task);
|
||||
assert(runs == before_serial + 5 && serial_settings_executed == 17 && !s_request_queue->count);
|
||||
puts("PASS: typed Serial admission uses zero wait on success/full queue, preserves all four queued UART requests and FIFO execution, no command-string dispatch");
|
||||
assert(admin_ssh_console_submit_account_settings(0) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = false;
|
||||
assert(admin_ssh_console_submit_account_settings(1) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = true; queue_full = true;
|
||||
assert(admin_ssh_console_submit_account_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
|
||||
queue_full = false;
|
||||
assert(admin_ssh_console_submit_serial_settings(21) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_account_settings(22) == ESP_OK && queue_send_wait == 0);
|
||||
pump(worker_task);
|
||||
assert(serial_settings_executed == 21 && account_settings_executed == 22 && runs == before_serial + 5);
|
||||
puts("PASS: typed Accounts uses same bounded queue with nonblocking admission and isolated dispatcher routing");
|
||||
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ def define(path, name):
|
||||
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
|
||||
source, re.M | re.S)
|
||||
# Non-array declarations have no brackets; explicit shape avoids silent omission.
|
||||
if len(uri_tables) != 16:
|
||||
raise RuntimeError('Review URI extraction: expected 14 descriptors and two tables')
|
||||
if len(uri_tables) != 20:
|
||||
raise RuntimeError('Review URI extraction: expected 18 descriptors and two tables')
|
||||
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
|
||||
source.index('static esp_err_t ensure_mutex(void)')]
|
||||
header = (ROOT / 'src/web_server.h').read_text()
|
||||
@@ -108,7 +108,11 @@ HANDLER(root_handler) HANDLER(status_handler) HANDLER(ticket_handler)
|
||||
HANDLER(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
|
||||
HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler)
|
||||
HANDLER(serial_settings_handler)
|
||||
HANDLER(web_serial_settings_handler)
|
||||
HANDLER(web_serial_settings_handler) HANDLER(web_account_settings_handler)
|
||||
HANDLER(web_account_generate_password_handler)
|
||||
static unsigned account_calls, account_fail_at;
|
||||
static unsigned generation_calls;
|
||||
static bool generation_fail;
|
||||
static esp_err_t route_error_handler(httpd_req_t *r, httpd_err_code_t c) { (void)r; (void)c; assert(0); return ESP_FAIL; }
|
||||
static esp_err_t web_serial_transport_init(void) { assert(!locked); ++serial_inits; return serial_init_error; }
|
||||
static esp_err_t web_cookie_auth_start(void) { assert(!locked); ++auth_starts; auth_live = auth_error == ESP_OK; return auth_error; }
|
||||
@@ -120,7 +124,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
|
||||
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
|
||||
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
|
||||
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
|
||||
assert(config->httpd.max_uri_handlers == 19 && config->port_secure == 443);
|
||||
assert(config->httpd.max_uri_handlers == 23 && config->port_secure == 443);
|
||||
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
|
||||
assert(config->tls_handshake_timeout_ms == 5000);
|
||||
assert(config->servercert_len == 1 && config->servercert[0] == 1);
|
||||
@@ -149,11 +153,26 @@ static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t
|
||||
if (error == ESP_OK) { assert(registered_count < 32); registered[registered_count++] = uri; }
|
||||
return error;
|
||||
}
|
||||
static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked && uri->handler == web_account_settings_handler);
|
||||
if (++account_calls == account_fail_at) return ESP_ERR_NO_MEM;
|
||||
registered[registered_count++] = uri; return ESP_OK;
|
||||
}
|
||||
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(uri->method == HTTP_GET);
|
||||
if (uri->handler == web_account_settings_handler) return account_register(s, uri);
|
||||
assert(!strcmp(uri->uri, "/api/settings/serial"));
|
||||
return httpd_register_uri_handler(s, uri);
|
||||
}
|
||||
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
if (uri->handler == web_account_generate_password_handler) {
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked);
|
||||
assert(!strcmp(uri->uri, "/api/settings/accounts/generate-password") && uri->method == HTTP_POST);
|
||||
assert(!uri->is_websocket); ++generation_calls;
|
||||
if (generation_fail) return ESP_ERR_NO_MEM;
|
||||
registered[registered_count++] = uri; return ESP_OK;
|
||||
}
|
||||
if (uri->handler == web_account_settings_handler) return account_register(s, uri);
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked);
|
||||
assert(!strcmp(uri->uri, "/api/settings/serial-operation") && uri->handler == web_serial_settings_handler);
|
||||
if (++operation_calls == operation_fail_at) return ESP_ERR_NO_MEM;
|
||||
@@ -163,7 +182,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
|
||||
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
|
||||
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
|
||||
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
|
||||
(!strcmp(uri, "/api/settings/serial-operation") && method == HTTP_GET));
|
||||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation")) && method == HTTP_GET));
|
||||
++unregister_calls;
|
||||
for (unsigned i = 0; i < registered_count; ++i) {
|
||||
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
|
||||
@@ -228,6 +247,7 @@ static void reset(void) {
|
||||
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
|
||||
unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
|
||||
operation_calls = operation_fail_at = 0;
|
||||
account_calls = account_fail_at = generation_calls = 0; generation_fail = false;
|
||||
}
|
||||
static void fresh_registration(void) { registration_calls = registered_count = 0; }
|
||||
static void start(void) {
|
||||
@@ -261,7 +281,8 @@ int main(void) {
|
||||
}
|
||||
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
||||
|
||||
reset(); start(); assert(registered_count == 19 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
reset(); start(); assert(registered_count == 23 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin");
|
||||
assert(ticket->method == HTTP_POST && ticket->handler == web_admin_transport_ticket_handler && !ticket->is_websocket);
|
||||
@@ -313,7 +334,7 @@ int main(void) {
|
||||
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
|
||||
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
|
||||
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
|
||||
assert(registered_count == 17 && unregister_calls == failure - 17);
|
||||
assert(registered_count == 21 && unregister_calls == failure - 17);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
|
||||
assert(route("/ws/serial")->handler == websocket_handler);
|
||||
@@ -322,13 +343,13 @@ int main(void) {
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||
assert(!admin_detaches && !admin_stoppeds);
|
||||
registration_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 19 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(registered_count == 23 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
|
||||
}
|
||||
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
|
||||
|
||||
reset(); registration_fail_at = 18; unregister_fail = true;
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 18);
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 22);
|
||||
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
|
||||
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
|
||||
ticket = route("/api/admin/ws-ticket");
|
||||
@@ -340,7 +361,7 @@ int main(void) {
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||
assert(!admin_detaches && !admin_stoppeds);
|
||||
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 19 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
assert(registered_count == 23 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
|
||||
|
||||
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
|
||||
@@ -362,7 +383,7 @@ int main(void) {
|
||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
|
||||
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
|
||||
reset(); settings_fail = true; start();
|
||||
assert(settings_calls == 1 && registered_count == 18);
|
||||
assert(settings_calls == 1 && registered_count == 22);
|
||||
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
|
||||
settings_fail = false; fresh_registration(); start();
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
@@ -370,13 +391,47 @@ int main(void) {
|
||||
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
|
||||
for (unsigned failure = 1; failure <= 2; ++failure) {
|
||||
reset(); operation_fail_at = failure; start();
|
||||
assert(registered_count == 17 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(registered_count == 21 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
|
||||
puts("13 lifecycle groups passed (16 required fatal positions, 5 optional routes, plus failed unregister)");
|
||||
for (unsigned failure = 1; failure <= 3; ++failure) {
|
||||
reset(); account_calls = 0; account_fail_at = failure; start();
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 20 : 21));
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
|
||||
assert(registered_count == 23 && account_calls == 3);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
|
||||
assert(registered_count == 22 && auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
|
||||
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
|
||||
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
|
||||
reset(); generation_fail = true; start();
|
||||
assert(generation_calls == 1 && registered_count == 22 && account_calls == 3);
|
||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
unsigned account_mutations = 0;
|
||||
for (unsigned i = 0; i < registered_count; ++i) {
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/accounts/generate-password"));
|
||||
if (!strcmp(registered[i]->uri, "/api/settings/account-operation") && registered[i]->method == HTTP_POST)
|
||||
++account_mutations;
|
||||
}
|
||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||
generation_fail = false; fresh_registration(); start();
|
||||
assert(generation_calls == 2 && registered_count == 23);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
|
||||
puts("15 lifecycle groups passed (16 required fatal positions, 9 optional routes, plus failed unregister)");
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -163,6 +163,32 @@ def main():
|
||||
b, out = span(body), Credentials()
|
||||
check("login", str(index), lambda: api.web_auth_parse_login(b, size(body), C.byref(out)),
|
||||
out, expected, decoded)
|
||||
api.web_auth_parse_json_string.argtypes = [C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t),
|
||||
C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)]
|
||||
api.web_auth_parse_json_string.restype = C.c_bool
|
||||
strings = [(b' "quote\\\"slash\\\\ space",', 65, b'quote"slash\\ space'),
|
||||
(b'"\\u0020\\u0022\\u005c"', 4, b' "\\'),
|
||||
(b'""', 1, b''), (b'"a"', 1, None),
|
||||
(b'"' + b'\\u0022' * 64 + b'"', 65, b'"' * 64),
|
||||
(b'"' + b'\\u0022' * 65 + b'"', 65, None),
|
||||
(b'"\\u0000"', 65, None), (b'"abc\\q"', 65, None),
|
||||
(b'"abc\\ud800"', 65, None), (b'"abc\xff"', 65, None),
|
||||
(None, 65, None)]
|
||||
complete = b'"secret\\\"value"'
|
||||
strings += [(complete[:i], 65, None) for i in range(len(complete))]
|
||||
for index, (body, capacity, expected) in enumerate(strings):
|
||||
prefix = b'prefix:'
|
||||
raw = None if body is None else prefix + body
|
||||
data, out = span(raw), C.create_string_buffer(capacity)
|
||||
position, length = C.c_size_t(len(prefix)), C.c_size_t(999)
|
||||
check("string", str(index),
|
||||
lambda: api.web_auth_parse_json_string(data, size(raw), C.byref(position), out, capacity, C.byref(length)),
|
||||
out, expected, lambda x: x.value)
|
||||
if expected is None:
|
||||
if position.value != len(prefix) or length.value != 0:
|
||||
failures.append(f"string: {index}: failure changed cursor or retained length")
|
||||
elif length.value != len(expected) or raw[position.value - 1:position.value] != b'"':
|
||||
failures.append(f"string: {index}: incorrect length/cursor")
|
||||
for failure in failures:
|
||||
print("FAIL:", failure)
|
||||
print(f"{count} cases; {len(failures)} failures")
|
||||
|
||||
@@ -16,6 +16,36 @@ This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, brows
|
||||
|
||||
See `docs/phase8d3_implementation.md` for source verification, other suite commands, build accounting and the target checklist.
|
||||
|
||||
## Accounts (8D.10)
|
||||
|
||||
```sh
|
||||
python3 tests/web_cookie_auth/run.py --accounts
|
||||
python3 tests/admin_console_boundary/accounts.py
|
||||
```
|
||||
|
||||
The first command adds nine account HTTP/operation groups using production
|
||||
cookie/store/handler/parser and the canonical generated-value helper. Queue,
|
||||
database mutations, timer scheduling and revocation are doubles; authorization
|
||||
is real. Covers max-width eight-account projection, strict 768-byte/four-receive
|
||||
credential schemas, decoded printable-ASCII passwords, bodyless generated-value
|
||||
authorization/currentness/no-mutation/cleanup, timer creation/start failure,
|
||||
queued expiry/replacement/executing fences, self success revocation and protected
|
||||
failure, pending/result isolation, stale IDs, submission/execution failure,
|
||||
target-only notifications, session invalidation and missed revocation/DB failure.
|
||||
Parent reports PASS for these nine groups plus shared regressions. Direct-handler
|
||||
tests do not prove route registration; the missing registration is now fixed as
|
||||
an independent optional endpoint (23 handlers), and the route agent reports 15
|
||||
lifecycle groups passing for registration, failure isolation and restart.
|
||||
Implementation is host-tested/build-verified (parent `pio run` PASS, 25.61 s,
|
||||
95,908 B RAM / 1,694,237 B flash), not target accepted. New timer runtime costs
|
||||
remain unmeasured. No sanitizer validation or device/asset/commit/8D.11 action.
|
||||
The second command separately exercises production conditional database mutation
|
||||
and zero-wait list bodies with NVS/RTOS doubles, including last-admin protection,
|
||||
target generation/recreation checks and commit-failure cleanup. It retains the
|
||||
canonical CLI account tests. These are not end-to-end RTOS/flash/TLS tests.
|
||||
See `docs/phase8d10_implementation.md` for current contracts, historical slice 1
|
||||
evidence and pending target checks. Timer doubles do not prove hard cleanup latency.
|
||||
|
||||
## Read-only Serial Settings
|
||||
|
||||
```sh
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
/* Production auth/store/handler, deterministic dispatcher and DB/transport doubles.
|
||||
* Actual conditional database transactions are tested by accounts.py. */
|
||||
#define ESP_ERR_TIMEOUT 0x107
|
||||
#define ESP_ERR_NOT_FOUND 0x105
|
||||
static unsigned wiped_passwords, wiped_responses, wiped_generated, wiped_bodies;
|
||||
static const uint8_t *executing_password;
|
||||
static void account_wipe(void *p, size_t n) {
|
||||
if (n==65) ++wiped_passwords;
|
||||
if (n==96) ++wiped_responses;
|
||||
if (n==sizeof(user_database_generated_password_t)) ++wiped_generated;
|
||||
if (n==768) ++wiped_bodies;
|
||||
secure_wipe(p,n); zero(p,n);
|
||||
if (p==executing_password) executing_password=NULL;
|
||||
}
|
||||
#define secure_wipe account_wipe
|
||||
#include "account_parse_production.h"
|
||||
#include "../../src/web_account_settings.c"
|
||||
#undef secure_wipe
|
||||
|
||||
static unsigned timer_creates, timer_starts;
|
||||
static bool timer_create_fail, timer_start_fail;
|
||||
static void (*timer_callback)(void *);
|
||||
int esp_timer_create(const esp_timer_create_args_t *args, esp_timer_handle_t *out) {
|
||||
assert(!host_lock_depth && !s_secret_timer); ++timer_creates;
|
||||
if (timer_create_fail) return ESP_FAIL;
|
||||
timer_callback=args->callback; *out=(void *)1; return ESP_OK;
|
||||
}
|
||||
int esp_timer_start_periodic(esp_timer_handle_t timer, uint64_t period) {
|
||||
assert(!host_lock_depth && timer==s_secret_timer && period==1000000); ++timer_starts;
|
||||
return timer_start_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
|
||||
static bool dispatcher, queue_fail, list_fail;
|
||||
static uint32_t queued;
|
||||
static unsigned mutations, web_revokes, ssh_revokes, lists;
|
||||
static esp_err_t mutation_error;
|
||||
static void (*mutation_hook)(void);
|
||||
static void (*queue_hook)(void);
|
||||
static bool self_target;
|
||||
static void check_slot_wiped(void) {
|
||||
zero(s_operation.password,sizeof(s_operation.password)); assert(!s_operation.password_length);
|
||||
zero(&s_operation.principal,sizeof(s_operation.principal));
|
||||
zero(&s_operation.target,sizeof(s_operation.target));
|
||||
}
|
||||
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id) {
|
||||
assert(!host_lock_depth && !dispatcher && id);
|
||||
if (queue_hook) { void (*hook)(void)=queue_hook; queue_hook=NULL; hook(); }
|
||||
if (queue_fail) return ESP_ERR_TIMEOUT;
|
||||
queued=id; return ESP_OK;
|
||||
}
|
||||
esp_err_t user_database_get_accounts(user_database_accounts_t *out) {
|
||||
assert(!host_lock_depth && !dispatcher); ++lists; memset(out,0,sizeof(*out));
|
||||
if (list_fail) return ESP_ERR_TIMEOUT;
|
||||
out->count=8;
|
||||
for (unsigned i=0;i<8;++i) {
|
||||
snprintf(out->users[i].username,sizeof(out->users[i].username),"account%09u",i);
|
||||
out->users[i].role=USER_ROLE_ADMIN; out->users[i].user_id=UINT32_MAX-i;
|
||||
out->users[i].auth_generation=UINT32_MAX;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t user_database_delete_current(const user_database_account_t *target) {
|
||||
assert(dispatcher && !host_lock_depth && !strcmp(target->username,self_target ? "alice" : "carol"));
|
||||
assert(s_operation.executing); check_slot_wiped();
|
||||
assert(target->user_id==7 && target->auth_generation==2); ++mutations;
|
||||
if (mutation_hook) { void (*hook)(void)=mutation_hook; mutation_hook=NULL; hook(); }
|
||||
return mutation_error;
|
||||
}
|
||||
esp_err_t user_database_set_role_current(const user_database_account_t *target,user_role_t role) {
|
||||
assert(role==USER_ROLE_ADMIN); return user_database_delete_current(target);
|
||||
}
|
||||
esp_err_t user_database_create(const uint8_t *u,size_t n,user_role_t role,const uint8_t *p,size_t pn) {
|
||||
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,"carol",5) && role==USER_ROLE_ADMIN);
|
||||
assert(user_database_password_valid(p,pn)); check_slot_wiped(); ++mutations;
|
||||
if (mutation_hook) { void (*hook)(void)=mutation_hook; mutation_hook=NULL; hook(); }
|
||||
return mutation_error;
|
||||
}
|
||||
esp_err_t user_database_set_password_current(const user_database_account_t *target,const uint8_t *p,size_t pn) {
|
||||
assert(user_database_password_valid(p,pn)); executing_password=p;
|
||||
return user_database_delete_current(target);
|
||||
}
|
||||
esp_err_t web_serial_transport_revoke_user(const uint8_t *u,size_t n) {
|
||||
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,self_target ? "alice" : "carol",5));
|
||||
assert(!executing_password);
|
||||
if (self_target) web_session_store_invalidate_username(u,n);
|
||||
++web_revokes; return ESP_FAIL;
|
||||
}
|
||||
esp_err_t ssh_transport_revoke_user(const uint8_t *u,size_t n) {
|
||||
assert(dispatcher && !host_lock_depth && n==5 && !memcmp(u,self_target ? "alice" : "carol",5)); ++ssh_revokes; return ESP_FAIL;
|
||||
}
|
||||
static const char deletion[]="{\"action\":\"delete\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2}";
|
||||
static const char role_body[]="{\"action\":\"role\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"role\":\"admin\"}";
|
||||
static void account_begin(const issued_t *identity,const char *body) {
|
||||
begin("/api/settings/account-operation",body?HTTP_POST:HTTP_GET,body); same_origin();
|
||||
if (body) add("Content-Type","application/json");
|
||||
if (identity) {
|
||||
char cookie[100]; snprintf(cookie,sizeof(cookie),"__Host-sak-session=%s",identity->token); add("Cookie",cookie);
|
||||
if (body) add("X-CSRF-Token",identity->view.csrf);
|
||||
}
|
||||
}
|
||||
static void account_expect(const char *status) {
|
||||
unsigned before=mutations;
|
||||
esp_err_t error=web_account_settings_handler(&req);
|
||||
assert(error==(send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
|
||||
if (strcmp(status,response_status)) fprintf(stderr,"expected %s got %s\n",status,response_status);
|
||||
assert(!strcmp(status,response_status) && mutations==before);
|
||||
assert(strlen(output)<1024); zero(scratch,sizeof(scratch));
|
||||
}
|
||||
static void submit_account(const issued_t *identity,const char *body) {
|
||||
account_begin(identity,body); account_expect("202 Accepted"); assert(s_operation.id==queued && s_operation.state==PENDING);
|
||||
}
|
||||
static void execute_account(void) { dispatcher=true; web_account_settings_execute(queued); dispatcher=false; }
|
||||
static void invalidate_actor(void) { web_session_store_invalidate(s_operation.session); }
|
||||
static const char create_body[]="{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"password1234\"}";
|
||||
static const char password_body[]="{\"action\":\"password\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"password\":\"password1234\"}";
|
||||
static void generate_begin(const issued_t *identity) {
|
||||
account_begin(identity,""); req.uri="/api/settings/accounts/generate-password";
|
||||
}
|
||||
static void generate_expect(const char *status) {
|
||||
account_operation_t before=s_operation;
|
||||
unsigned ids=s_next_id, calls=mutations, response_wipes=wiped_responses, generated_wipes=wiped_generated;
|
||||
esp_err_t result=web_account_generate_password_handler(&req);
|
||||
assert(result==((send_fail || fail_header || aux.remaining_len) ? ESP_FAIL : ESP_OK));
|
||||
if (!fail_header) assert(!strcmp(status,response_status));
|
||||
assert(!memcmp(&before,&s_operation,sizeof(before)) && ids==s_next_id && mutations==calls);
|
||||
assert(wiped_responses==response_wipes+1 && wiped_generated>generated_wipes);
|
||||
zero(scratch,sizeof(scratch));
|
||||
}
|
||||
static void generated_tests(void) {
|
||||
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob);
|
||||
unsigned rng=rng_calls;
|
||||
generate_begin(NULL); generate_expect("401 Unauthorized");
|
||||
generate_begin(&user); generate_expect("403 Forbidden");
|
||||
for (unsigned mode=0;mode<9;++mode) {
|
||||
generate_begin(&admin);
|
||||
if (mode==0) { req.content_len=aux.remaining_len=1; }
|
||||
if (mode==1) req.method=HTTP_GET;
|
||||
if (mode==2) req.uri="/api/settings/accounts/generate-password?x=1";
|
||||
if (mode==3) add("Origin","https://evil.example");
|
||||
if (mode==4) add("X-CSRF-Token","duplicate");
|
||||
if (mode==5) add("Transfer-Encoding","chunked");
|
||||
if (mode==6) add("Sec-Fetch-Site","cross-site");
|
||||
if (mode==7) stale_user=alice.user_id;
|
||||
if (mode==8) db_fail=true;
|
||||
(void)web_account_generate_password_handler(&req);
|
||||
assert(response_status[0]=='4' && rng_calls==rng);
|
||||
stale_user=0; db_fail=false;
|
||||
}
|
||||
admin=mint(&alice); rng=rng_calls;
|
||||
generate_begin(&admin); generate_expect("200 OK");
|
||||
assert(rng_calls==rng+1 && strlen(output)==39 && !strncmp(output,"{\"password\":\"",13));
|
||||
for (unsigned i=13;i<37;++i) assert(strchr((const char *)s_generated_alphabet,output[i]));
|
||||
bool no_store=false;
|
||||
for (unsigned i=0;i<aux.resp_hdrs_count;++i)
|
||||
if (!strcmp(response_headers[i].field,"Cache-Control")) no_store=!strcmp(response_headers[i].value,"no-store");
|
||||
assert(no_store);
|
||||
rng_fail=true; generate_begin(&admin); generate_expect("503 Service Unavailable"); rng_fail=false;
|
||||
assert(!strstr(output,"password"));
|
||||
send_fail=true; generate_begin(&admin); generate_expect("200 OK"); send_fail=false;
|
||||
fail_header=1; setter_calls=0; generate_begin(&admin); generate_expect(""); fail_header=0;
|
||||
unsigned header_capacity=server.config.max_resp_headers;
|
||||
for (unsigned capacity=0;capacity<3;++capacity) {
|
||||
generate_begin(&admin); server.config.max_resp_headers=capacity;
|
||||
unsigned wipes=wiped_responses, sent=sends;
|
||||
assert(web_account_generate_password_handler(&req)==ESP_ERR_HTTPD_RESP_HDR);
|
||||
assert(sends==sent && wiped_responses==wipes+1);
|
||||
}
|
||||
server.config.max_resp_headers=header_capacity;
|
||||
hook_id=admin.view.id; rng_hook=invalidate_hook;
|
||||
generate_begin(&admin); generate_expect("401 Unauthorized"); assert(!strstr(output,"password"));
|
||||
admin=mint(&alice);
|
||||
/* Generate even with the operation slot occupied; it must not touch it. */
|
||||
submit_account(&admin,create_body); generate_begin(&admin); generate_expect("200 OK"); execute_account();
|
||||
web_session_store_invalidate(admin.view.id);
|
||||
rng=rng_calls; generate_begin(&admin); generate_expect("401 Unauthorized"); assert(rng_calls==rng);
|
||||
puts("PASS Accounts generated value: real RNG helper/auth/store, bodyless/admin/currentness/Origin/CSRF, base64url/no-store, no slot/DB write, success/RNG/header/send cleanup");
|
||||
}
|
||||
static void expire_queued(void) { now=s_operation.deadline; timer_callback(NULL); }
|
||||
static void executing_tick(void) {
|
||||
assert(s_operation.executing); check_slot_wiped();
|
||||
now=s_operation.deadline; timer_callback(NULL);
|
||||
assert(s_operation.executing && s_operation.state==PENDING);
|
||||
web_account_settings_execute(s_operation.id); /* Duplicate dispatcher delivery. */
|
||||
}
|
||||
static void validation_tick(void) {
|
||||
assert(s_operation.executing); check_slot_wiped();
|
||||
timer_callback(NULL); assert(s_operation.state==PENDING);
|
||||
}
|
||||
static issued_t busy_actor;
|
||||
static void executing_busy_request(void) {
|
||||
assert(dispatcher && s_operation.executing);
|
||||
dispatcher=false; account_begin(&busy_actor,create_body); account_expect("503 Service Unavailable"); dispatcher=true;
|
||||
assert(s_operation.executing); check_slot_wiped();
|
||||
}
|
||||
static void credential_tests(void) {
|
||||
auth_reset(); issued_t admin=mint(&alice); receive_fragment=768;
|
||||
unsigned before=mutations;
|
||||
timer_create_fail=true; account_begin(&admin,create_body); account_expect("503 Service Unavailable"); timer_create_fail=false;
|
||||
assert(!s_secret_timer && mutations==before);
|
||||
timer_start_fail=true; account_begin(&admin,password_body); account_expect("503 Service Unavailable"); timer_start_fail=false;
|
||||
assert(s_secret_timer && !s_secret_timer_started && mutations==before);
|
||||
submit_account(&admin,password_body); assert(timer_creates==2 && timer_starts==2);
|
||||
uint32_t old=queued; assert(s_operation.password_length==12);
|
||||
now=s_operation.deadline-1; timer_callback(NULL); assert(s_operation.state==PENDING);
|
||||
++now; timer_callback(NULL); assert(s_operation.state==CANCELLED); check_slot_wiped();
|
||||
submit_account(&admin,create_body); timer_callback(NULL); assert(s_operation.state==PENDING);
|
||||
dispatcher=true; web_account_settings_execute(old); dispatcher=false;
|
||||
assert(s_operation.state==PENDING && mutations==before);
|
||||
execute_account(); assert(mutations==before+1 && s_operation.state==OK); check_slot_wiped();
|
||||
assert(timer_creates==2 && timer_starts==2);
|
||||
/* Timeout between publication and queue submission leaves only a stale ID. */
|
||||
queue_hook=expire_queued; account_begin(&admin,password_body); account_expect("202 Accepted");
|
||||
assert(s_operation.state==CANCELLED); execute_account(); assert(mutations==before+1); check_slot_wiped();
|
||||
queue_fail=true; account_begin(&admin,password_body); account_expect("503 Service Unavailable"); queue_fail=false;
|
||||
zero(&s_operation,sizeof(s_operation));
|
||||
submit_account(&admin,password_body); db_hook=validation_tick; execute_account();
|
||||
assert(s_operation.state==OK && !executing_password); check_slot_wiped();
|
||||
submit_account(&admin,password_body); mutation_hook=executing_tick; execute_account();
|
||||
assert(s_operation.state==OK && !s_operation.executing && !executing_password);
|
||||
/* Tick during external validation, followed by deadline check: cancelled. */
|
||||
submit_account(&admin,password_body); db_hook=executing_tick; before=mutations; execute_account();
|
||||
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
|
||||
submit_account(&admin,password_body); web_session_store_invalidate(admin.view.id); execute_account();
|
||||
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
|
||||
admin=mint(&alice);
|
||||
submit_account(&admin,password_body); stale_user=alice.user_id; execute_account(); stale_user=0;
|
||||
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
|
||||
admin=mint(&alice);
|
||||
submit_account(&admin,password_body); db_fail=true; execute_account(); db_fail=false;
|
||||
assert(s_operation.state==CANCELLED && mutations==before); check_slot_wiped();
|
||||
admin=mint(&alice);
|
||||
const esp_err_t errors[]={ESP_ERR_NOT_FOUND,ESP_FAIL,ESP_ERR_INVALID_STATE,ESP_ERR_NO_MEM};
|
||||
const unsigned states[]={STALE,FAILED,DUPLICATE,FULL};
|
||||
unsigned revokes=web_revokes;
|
||||
for (unsigned i=0;i<4;++i) {
|
||||
mutation_error=errors[i]; submit_account(&admin,i<2?password_body:create_body); execute_account();
|
||||
assert(s_operation.state==states[i] && web_revokes==revokes && !executing_password); check_slot_wiped();
|
||||
}
|
||||
mutation_error=ESP_OK;
|
||||
send_fail=true; account_begin(&admin,password_body); account_expect("202 Accepted"); send_fail=false;
|
||||
assert(s_operation.password_length==12); execute_account(); assert(s_operation.state==OK); check_slot_wiped();
|
||||
/* Keep executing ownership even if another request arrives during validation. */
|
||||
busy_actor=admin; submit_account(&admin,password_body); db_hook=executing_busy_request; execute_account();
|
||||
assert(s_operation.state==OK); check_slot_wiped();
|
||||
queue_fail=true; queue_hook=expire_queued;
|
||||
account_begin(&admin,create_body); account_expect("503 Service Unavailable"); queue_fail=false;
|
||||
zero(&s_operation,sizeof(s_operation));
|
||||
assert(timer_creates==2 && timer_starts==2);
|
||||
puts("PASS Accounts credential queue: timer create/start failure, explicit expiry, replacement/old IDs, queue failure, validation/execution ticks, stale/dead sessions, error and lost-ack cleanup");
|
||||
}
|
||||
static void password_parser_tests(void) {
|
||||
auth_reset(); issued_t admin=mint(&alice); receive_fragment=768;
|
||||
char body[800];
|
||||
const char *valid[]={"password1234", "space space ", "quote\\\"slash\\\\", "\\u0020\\u0022\\u005c123456789", "slash\\/1234567"};
|
||||
for (unsigned i=0;i<sizeof(valid)/sizeof(*valid);++i) {
|
||||
snprintf(body,sizeof(body),"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"%s\"}",valid[i]);
|
||||
account_operation_t parsed={0}; assert(parse(body,strlen(body),&parsed));
|
||||
assert(user_database_password_valid(parsed.password,parsed.password_length));
|
||||
if (i==2) assert(parsed.password_length==12 && !memcmp(parsed.password,"quote\"slash\\",12));
|
||||
secure_wipe(&parsed,sizeof(parsed));
|
||||
submit_account(&admin,body); execute_account(); assert(s_operation.state==OK);
|
||||
}
|
||||
const char *invalid[]={"", "12345678901", "12345678901\\n", "12345678901\\t", "12345678901\\u0000", "12345678901\\u007f", "12345678901\\u0080", "12345678901\\uD800", "12345678901\\x20"};
|
||||
for (unsigned i=0;i<sizeof(invalid)/sizeof(*invalid);++i) {
|
||||
snprintf(body,sizeof(body),"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"%s\"}",invalid[i]);
|
||||
account_begin(&admin,body); account_expect("400 Bad Request");
|
||||
}
|
||||
for (unsigned n=11;n<=65;++n) {
|
||||
char escaped[391];
|
||||
for (unsigned i=0;i<n;++i) memcpy(escaped+i*6,"\\u0022",6);
|
||||
escaped[n*6]=0;
|
||||
snprintf(body,sizeof(body),"{\"action\":\"password\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"password\":\"%s\"}",escaped);
|
||||
account_begin(&admin,body);
|
||||
account_expect(n>=12 && n<=64 ? "202 Accepted" : "400 Bad Request");
|
||||
if (n>=12 && n<=64) { assert(s_operation.password_length==n); execute_account(); }
|
||||
}
|
||||
const char *bad[]={
|
||||
"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"password1234\",\"user_id\":7}",
|
||||
"{\"action\":\"password\",\"username\":\"carol\",\"password\":\"password1234\"}",
|
||||
"{\"action\":\"password\",\"username\":\"carol\",\"user_id\":7,\"auth_generation\":2,\"password\":\"password1234\",\"role\":\"admin\"}",
|
||||
"{\"action\":\"create\",\"username\":\"carol\",\"role\":\"admin\",\"password\":\"password1234\",\"password\":\"password5678\"}"};
|
||||
for (unsigned i=0;i<sizeof(bad)/sizeof(*bad);++i) { account_begin(&admin,bad[i]); account_expect("400 Bad Request"); }
|
||||
for (size_t i=0;i<strlen(password_body);++i) {
|
||||
account_begin(&admin,password_body); req.content_len=aux.remaining_len=i;
|
||||
account_expect("400 Bad Request");
|
||||
}
|
||||
size_t n=strlen(create_body); memcpy(body,create_body,n); memset(body+n,' ',768-n); body[768]=0;
|
||||
receive_fragment=192; submit_account(&admin,body); execute_account();
|
||||
receive_fragment=191; account_begin(&admin,body); account_expect("400 Bad Request");
|
||||
receive_fragment=768; recv_fail=true; account_begin(&admin,password_body); account_expect("400 Bad Request"); recv_fail=false;
|
||||
puts("PASS Accounts passwords: decoded quote/backslash/space/slash/Unicode escapes, printable ASCII 12..64, exact schemas, truncations, 768-byte/four-receive bound");
|
||||
}
|
||||
static void self_tests(void) {
|
||||
const char *bodies[]={
|
||||
"{\"action\":\"role\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2,\"role\":\"admin\"}",
|
||||
"{\"action\":\"delete\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2}",
|
||||
"{\"action\":\"password\",\"username\":\"alice\",\"user_id\":7,\"auth_generation\":2,\"password\":\"password1234\"}"};
|
||||
self_target=true;
|
||||
for (unsigned i=0;i<3;++i) {
|
||||
auth_reset(); issued_t admin=mint(&alice), sibling=mint(&alice), user=mint(&bob);
|
||||
unsigned revokes=web_revokes;
|
||||
mutation_error=ESP_ERR_INVALID_STATE; submit_account(&admin,bodies[i]); execute_account();
|
||||
assert(s_operation.state==PROTECTED && web_revokes==revokes);
|
||||
account_begin(&admin,NULL); account_expect("200 OK"); assert(strstr(output,"protected"));
|
||||
mutation_error=ESP_OK; submit_account(&admin,bodies[i]); execute_account();
|
||||
assert(s_operation.state==OK && web_revokes==revokes+1 && !executing_password);
|
||||
account_begin(&admin,NULL); account_expect("401 Unauthorized"); assert(!strstr(output,"\"state\""));
|
||||
account_begin(&sibling,NULL); account_expect("401 Unauthorized");
|
||||
bool current=false; assert(web_session_store_check_principal(user.view.id,&user.view.principal,¤t)==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);
|
||||
}
|
||||
@@ -45,6 +45,15 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
|
||||
admin = "--admin" in sys.argv
|
||||
settings = "--settings" in sys.argv
|
||||
serial_settings = "--serial-settings" in sys.argv
|
||||
accounts = "--accounts" in sys.argv
|
||||
if accounts:
|
||||
HEADERS["esp_timer.h"] += """
|
||||
#include <stdbool.h>
|
||||
typedef void *esp_timer_handle_t;
|
||||
typedef struct { void (*callback)(void *); const char *name; bool skip_unhandled_events; } esp_timer_create_args_t;
|
||||
int esp_timer_create(const esp_timer_create_args_t *, esp_timer_handle_t *);
|
||||
int esp_timer_start_periodic(esp_timer_handle_t, uint64_t);
|
||||
"""
|
||||
if admin:
|
||||
HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n"
|
||||
HEADERS["esp_heap_caps.h"] = """#pragma once
|
||||
@@ -132,6 +141,11 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
settings_source += ' serial_service_counters_t serial_counters = {0};\n' + acquisition
|
||||
settings_source += ' return snprintf(response, capacity,\n' + serial_format + ',\n' + serial_arguments + ');\n}\n'
|
||||
(tmp / 'settings_production.h').write_text(settings_source)
|
||||
if accounts:
|
||||
db_source = (ROOT / 'src/user_database.c').read_text()
|
||||
alphabet_start = db_source.index('static const uint8_t s_generated_alphabet')
|
||||
alphabet = db_source[alphabet_start:db_source.index(';', alphabet_start) + 1]
|
||||
(tmp / 'account_parse_production.h').write_text(alphabet + '\n' + '\n'.join(function(db_source, name) for name in ('user_database_username_valid', 'user_database_password_valid', 'user_role_parse', 'user_role_to_string', 'user_database_generate_password_value')))
|
||||
if serial_settings:
|
||||
config_source = (ROOT / 'src/serial_config.c').read_text()
|
||||
names = ['serial_config_defaults', 'serial_config_validate']
|
||||
@@ -155,6 +169,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
*(["-DHOST_ADMIN"] if admin else []),
|
||||
*(["-DHOST_SETTINGS"] if settings else []),
|
||||
*(["-DHOST_SERIAL_SETTINGS"] if serial_settings else []),
|
||||
*(["-DHOST_ACCOUNTS"] if accounts else []),
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/* Production store dependency doubles and its existing public API suite. */
|
||||
#define main store_tests
|
||||
#ifdef HOST_ACCOUNTS
|
||||
#define user_database_username_valid store_username_valid
|
||||
#endif
|
||||
#include "../web_session_store/test.c"
|
||||
#undef main
|
||||
#ifdef HOST_ACCOUNTS
|
||||
#undef user_database_username_valid
|
||||
#endif
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "esp_httpd_priv.h"
|
||||
@@ -126,6 +132,9 @@ static void auth_reset(void) {
|
||||
#ifdef HOST_SERIAL_SETTINGS
|
||||
#include "serial_settings_test.c"
|
||||
#endif
|
||||
#ifdef HOST_ACCOUNTS
|
||||
#include "account_settings_test.c"
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
@@ -279,6 +288,9 @@ int main(void) {
|
||||
#endif
|
||||
#ifdef HOST_SERIAL_SETTINGS
|
||||
serial_settings_tests();
|
||||
#endif
|
||||
#ifdef HOST_ACCOUNTS
|
||||
account_settings_tests();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,24 @@ Coverage:
|
||||
- Repeated current Settings selection is a no-op during submission, between and
|
||||
during result checks, and during completion refresh: requests, timers, visible
|
||||
values/control state, final outcome and socket/writer identity remain intact.
|
||||
**35 Node groups total.**
|
||||
- Accounts: admin-only strict eight-user/1,024-byte list, no secret/key fields,
|
||||
confirmed identity-bound role/delete, automatic completion/list refresh,
|
||||
ten-check limit/manual recovery, stale/protected/failure and lost-acknowledgement
|
||||
handling, cancellation, and 401/identity isolation. The original 41 groups remain;
|
||||
the former self-denial assertion now checks enabled self actions and confirmation cancellation.
|
||||
- Second slice: exact create/password JSON and CSRF, 768-byte request ceiling,
|
||||
untrimmed 12–64 printable ASCII passwords including spaces/quotes/backslashes,
|
||||
confirmation and username validation, separate bodyless generation without
|
||||
mutation/list changes, strict 24-character base64url/96-byte generation response.
|
||||
- Generated acknowledgement binds value and operation/target identity; edits,
|
||||
regeneration, target/purpose changes reset it. 60-second lifetime, including
|
||||
delayed timer admission checks; submission/cancel/failure and all lifecycle wipes.
|
||||
Late headers/streamed bodies, concurrent reconnect and newer snapshots are fenced.
|
||||
- Self password/role/delete warnings and POST/poll 401 close both routes without
|
||||
success claims or proactive logout. Safe duplicate/full messages and no routine
|
||||
secret outputs, storage, clipboard writes or history APIs.
|
||||
**57 Node groups total**, plus renderer/HTML/CSP checks, reported PASS by the UI
|
||||
continuation agent (four added beyond its earlier 53-group slice 2 run).
|
||||
|
||||
## Automatic result-check budget
|
||||
|
||||
@@ -91,7 +108,8 @@ existing 15-second per-request bound (session validation and snapshot GET are
|
||||
separate requests). Settings remain visible but conflicting controls are disabled
|
||||
during work; old snapshots are explicitly stale during pending/uncertain work or
|
||||
a failed refresh. A successful refresh replaces the browser draft. Only Reset
|
||||
asks for confirmation, specifically because it overwrites saved configuration.
|
||||
asks for confirmation among Serial actions, specifically because it overwrites saved
|
||||
configuration. Every Accounts mutation retains an explicit confirmation.
|
||||
|
||||
Tests use a deterministic clock and individually fired timer callbacks, including
|
||||
callbacks invoked after cancellation and fetch/body doubles that ignore abort.
|
||||
@@ -100,8 +118,10 @@ These deliberately exercise fences beyond normal browser cancellation behavior.
|
||||
## Integration and known gaps
|
||||
|
||||
This covers 8D.3 session behavior, the 8D.6 selector, 8D.8 Settings and the 8D.9
|
||||
Serial UI. Operation responses are fetch doubles, not end-to-end execution of
|
||||
`web_serial_settings.c`, dispatcher work, serial reconfiguration or NVS persistence. The renderer
|
||||
Serial UI and both 8D.10 Accounts slices. Operation/generation responses are fetch
|
||||
doubles, not end-to-end execution of `web_serial_settings.c`,
|
||||
`web_account_settings.c`, dispatcher work, credential generation/derivation,
|
||||
serial reconfiguration or NVS persistence. The renderer
|
||||
still relies on its caller to authenticate resources; protected asset failures
|
||||
must be 401, never a redirect to HTML served as JavaScript. No Basic fallback is
|
||||
implemented here. Existing 8D.5 server authorization/protocols are unchanged.
|
||||
@@ -110,7 +130,13 @@ These tests model DOM, timers, fetch cancellation and WebSocket events. They do
|
||||
not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior,
|
||||
actual bfcache policy, cookie expiry, server revocation, or hardware serial byte
|
||||
integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout.
|
||||
Prior 8D.6 signoff stands; the new Settings build and pending target checklist are in
|
||||
`docs/phase8d8_implementation.md`. No target resource reserve is claimed. Browser secret
|
||||
Prior 8D.6 signoff stands; current slice 2 contracts and pending target checklist are in
|
||||
`docs/phase8d10_implementation.md`. Implementation is complete, host-tested/build-verified,
|
||||
not target accepted: parent build PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash.
|
||||
The generated endpoint is independently optionally registered (23 handlers), with
|
||||
route-agent lifecycle 15 PASS for failure isolation/restart. UI 57/CSP and lifecycle
|
||||
15 results are agent-attributed, not claims of the parent's additional reruns.
|
||||
Target/signoff and new timer runtime measurements remain open; no sanitizer,
|
||||
device/assets/commit/8D.11 action or target resource reserve is claimed. Browser secret
|
||||
references are dropped and never persisted/logged, but JavaScript cannot securely
|
||||
wipe engine-managed strings.
|
||||
|
||||
@@ -11,9 +11,9 @@ const serialSettings = (extra = {}) => ({running: true, baud: 230400, data_bits:
|
||||
const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}});
|
||||
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; };
|
||||
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
|
||||
function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
|
||||
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': []};
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': []};
|
||||
const fits = [];
|
||||
let serial = 0, now = Date.now();
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
@@ -39,7 +39,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
|
||||
location: {origin: 'https://sak.local', replace: path => redirects.push(path)}};
|
||||
const context = vm.createContext({window, document: {getElementById(id) {
|
||||
return nodes[id] ||= {textContent: '', dataset: {}, classList: {toggle() {}},
|
||||
return nodes[id] ||= {textContent: '', value: '', checked: false, dataset: {}, classList: {toggle() {}},
|
||||
setAttribute(k, v) { this[k] = v; },
|
||||
getBoundingClientRect: () => ({width: 100, height: 100}),
|
||||
addEventListener(k, fn) { this[k] = fn; }};
|
||||
@@ -56,9 +56,10 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
calls.push({url, ...options});
|
||||
const next = queues[url].shift();
|
||||
if (next !== undefined) return typeof next === 'function' ? next(options) : next;
|
||||
if (url === '/api/session') return session({role});
|
||||
if (url === '/api/session') return session({role, username});
|
||||
if (url === '/api/status') return json({});
|
||||
if (url === '/api/settings/serial') return json(serialSettings());
|
||||
if (url === '/api/settings/accounts') return json({users: [{username: 'alice', user_id: 1, auth_generation: 2, role: 'admin'}, {username: 'carol', user_id: 7, auth_generation: 2, role: 'user'}]});
|
||||
if (url === '/api/ws-ticket') return ticket();
|
||||
if (url === '/api/admin/ws-ticket') return json({ticket: '0123456789abcdef'.repeat(4), expires_in: 30});
|
||||
throw new Error('network unavailable');
|
||||
@@ -782,6 +783,94 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
assert.equal(b.nodes['setting-baud'].textContent, '230400');
|
||||
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
|
||||
});
|
||||
async function accountsBrowser() {
|
||||
const b = browser({role: 'admin', username: 'alice'}); b.start(); await tick(); b.sockets[0].emit('open');
|
||||
b.click('select-admin'); b.click('admin-toggle'); await tick(); b.sockets[1].emit('open');
|
||||
b.click('select-settings'); await tick(); b.click('settings-accounts'); await tick();
|
||||
return b;
|
||||
}
|
||||
const accountPath = '/api/settings/account-operation';
|
||||
const accountReply = (id, state, action = 'role') => json({id, state, action});
|
||||
await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => {
|
||||
const u = await connected(); u.click('settings-accounts'); await tick();
|
||||
assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts'));
|
||||
const b = await accountsBrowser();
|
||||
assert.match(b.nodes['accounts-list'].textContent, /alice.*admin.*you/);
|
||||
assert.match(b.nodes['accounts-list'].textContent, /carol.*user/);
|
||||
assert.ok(!b.nodes['account-delete'].disabled);
|
||||
const calls=b.calls.length; b.window.confirm=()=>false; b.click('account-delete'); await tick(); assert.equal(b.calls.length,calls); b.window.confirm=()=>true;
|
||||
b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); assert.ok(!b.nodes['account-delete'].disabled);
|
||||
for(let i=0;i<3;++i) { b.click('settings-serial'); await tick(); b.click('settings-accounts'); await tick(); }
|
||||
assert.equal(b.sockets.length,2); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
|
||||
for (const bad of [{users:[{username:'<img>',role:'user',user_id:1,auth_generation:1}]}, {users:Array(9).fill({})}, {users:[],password:'SECRET'}, {users:[{username:'safe',role:'admin',user_id:0,auth_generation:1}]}]) {
|
||||
b.queues['/api/settings/accounts'].push(json(bad)); b.click('refresh-accounts'); await tick();
|
||||
assert.match(b.nodes['accounts-detail'].textContent,/stale/); assert.ok(!b.nodes['accounts-list'].textContent.includes('SECRET'));
|
||||
}
|
||||
});
|
||||
await test('Accounts role/delete confirmation, typed identity, automatic result and list refresh', async () => {
|
||||
for (const action of ['role','delete']) {
|
||||
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); b.nodes['account-role'].value='admin';
|
||||
const button=action==='role'?'account-change-role':'account-delete', before=b.calls.length;
|
||||
b.window.confirm=()=>false; b.click(button); await tick(); assert.equal(b.calls.length,before);
|
||||
b.window.confirm=()=>true; b.queues[accountPath].push(accountReply(10,'pending',action)); b.click(button); await tick();
|
||||
const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1);
|
||||
assert.deepEqual(JSON.parse(posts[0].body),{action,username:'carol',user_id:7,auth_generation:2,...(action==='role'?{role:'admin'}:{})});
|
||||
assert.equal(posts[0].headers['X-CSRF-Token'],token); assert.ok(b.nodes['account-target'].disabled);
|
||||
for (const state of ['pending','ok']) { b.queues[accountPath].push(accountReply(10,state,action)); b.fire(1000); await tick(); }
|
||||
assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/);
|
||||
assert.match(b.nodes['accounts-detail'].textContent,/refreshed/);
|
||||
assert.equal(b.calls.filter(c=>c.url==='/api/settings/accounts').length,2);
|
||||
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
|
||||
}
|
||||
});
|
||||
await test('Accounts bounded checks exhaust to manual recovery without POST retry', async () => {
|
||||
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
|
||||
b.queues[accountPath].push(accountReply(11,'pending')); b.click('account-change-role'); await tick();
|
||||
for(let i=0;i<10;++i) { b.queues[accountPath].push(accountReply(11,'pending')); b.elapse(1000); b.fire(1000); await tick(); }
|
||||
assert.match(b.nodes['account-operation-detail'].textContent,/stopped.*Check Result/); assert.ok(!b.nodes['account-result'].disabled);
|
||||
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='GET').length,10);
|
||||
b.queues[accountPath].push(accountReply(11,'ok')); b.click('account-result'); await tick();
|
||||
assert.match(b.nodes['account-operation-detail'].textContent,/completed/);
|
||||
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1);
|
||||
});
|
||||
await test('Accounts cancellation fences pending posts, checks and refreshes on domain/view/pagehide', async () => {
|
||||
for (const mode of ['domain','view','pagehide']) {
|
||||
const b=await accountsBrowser(), d=deferred(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
|
||||
b.queues[accountPath].push(d.promise); b.click('account-change-role'); await tick();
|
||||
const request=b.calls.filter(c=>c.url===accountPath).at(-1);
|
||||
if(mode==='domain') b.click('settings-serial'); else if(mode==='view') b.click('select-serial'); else b.emit('pagehide');
|
||||
assert.ok(request.signal.aborted); const detail=b.nodes['account-operation-detail'].textContent;
|
||||
d.resolve(accountReply(12,'pending')); await tick(); assert.equal(b.nodes['account-operation-detail'].textContent,detail);
|
||||
assert.equal(b.nodes['accounts-list'].textContent,'');
|
||||
assert.ok(!b.calls.some(c=>c.url===accountPath && c.method==='GET'));
|
||||
}
|
||||
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
|
||||
b.queues[accountPath].push(accountReply(13,'pending')); b.click('account-change-role'); await tick();
|
||||
b.click('settings-serial'); await tick(); assert.ok(![...b.timers.values()].some(t=>t.ms===1000 || t.ms===15000));
|
||||
});
|
||||
await test('Accounts timeout, stale/protected/failed outcomes, failed refresh and unknown acknowledgement', async () => {
|
||||
for(const state of ['stale','protected','failed','cancelled']) {
|
||||
const b=await accountsBrowser(); b.queues[accountPath].push(accountReply(14,state)); b.queues['/api/settings/accounts'].push(failure(503));
|
||||
b.click('account-result'); await tick(); assert.match(b.nodes['accounts-detail'].textContent,/stale/); assert.match(b.nodes['accounts-list'].textContent,/carol/);
|
||||
}
|
||||
const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin';
|
||||
b.queues[accountPath].push(()=>{throw new Error('lost');}); b.click('account-change-role'); await tick();
|
||||
assert.match(b.nodes['account-operation-detail'].textContent,/unknown/);
|
||||
for(let i=0;i<2;++i) { b.queues[accountPath].push(accountReply(15,'ok')); b.click('account-result'); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/Acknowledgement lost/); }
|
||||
b.queues[accountPath].push(accountReply(16,'pending')); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin'; b.click('account-change-role'); await tick();
|
||||
const d=deferred(); b.queues[accountPath].push(d.promise); b.fire(1000); await tick();
|
||||
b.elapse(15000); b.fire(15000); await tick(); d.resolve(accountReply(16,'ok')); await tick();
|
||||
assert.match(b.nodes['account-operation-detail'].textContent,/unknown/); assert.ok(!b.nodes['account-result'].disabled);
|
||||
});
|
||||
await test('Accounts 401 and identity changes close routes without adopting stale list', async () => {
|
||||
for(const identity of [false,true]) {
|
||||
const b=await accountsBrowser();
|
||||
if(identity) b.queues['/api/session'].push(session({role:'admin',username:'replacement'}));
|
||||
else b.queues['/api/settings/accounts'].push(failure(401));
|
||||
b.click('refresh-accounts'); await tick(); assert.deepEqual(b.redirects,[identity?'/':'/login']);
|
||||
assert.ok(b.sockets.every(s=>s.closed)); assert.equal(b.nodes['accounts-list'].textContent,'');
|
||||
}
|
||||
});
|
||||
await test('Routine actions never confirm; Reset cancellation has no request or state change', async () => {
|
||||
const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick();
|
||||
const confirms = []; b.window.confirm = message => { confirms.push(message); return false; };
|
||||
@@ -796,5 +885,209 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
assert.equal(confirms.length, 1); assert.match(confirms[0], /overwrites saved NVS configuration/);
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 6);
|
||||
});
|
||||
const generatePath = '/api/settings/accounts/generate-password', secret = 'Abcdefghijklmnopqrst_-12';
|
||||
function draft(b, purpose = 'create', password = ' typed "pass\\word ') {
|
||||
b.nodes['account-purpose'].value = purpose; b.nodes['account-purpose'].change();
|
||||
if (purpose === 'create') { b.nodes['account-username'].value = 'new_account'; b.nodes['account-username'].input(); }
|
||||
b.nodes['account-password'].value = b.nodes['account-password-confirm'].value = password;
|
||||
}
|
||||
function cleanSecret(b) {
|
||||
for (const id of ['account-password','account-password-confirm','account-generated']) assert.equal(b.nodes[id].value, '', id);
|
||||
assert.equal(b.nodes['account-password-saved'].checked, false); assert.ok(b.nodes['account-generated-panel'].hidden);
|
||||
}
|
||||
function noSecretOutput(b, value = secret) {
|
||||
for (const [id, node] of Object.entries(b.nodes)) assert.ok(!node.textContent.includes(value), id);
|
||||
}
|
||||
async function generated(b) {
|
||||
b.queues[generatePath].push(json({password: secret})); b.click('account-generate'); await tick();
|
||||
assert.equal(b.nodes['account-generated'].value, secret);
|
||||
b.nodes['account-password-confirm'].value = secret; b.nodes['account-password-confirm'].input();
|
||||
b.nodes['account-password-saved'].checked = true; b.nodes['account-password-saved'].change();
|
||||
}
|
||||
await test('Create/password exact JSON, escaped printable ASCII and bounds, CSRF and isolated sockets', async () => {
|
||||
for (const purpose of ['create','password']) for (const password of [' '.repeat(12), ' typed "pass\\word ', '\\'.repeat(64)]) {
|
||||
const b = await accountsBrowser(); draft(b, purpose, password);
|
||||
b.nodes['account-target'].value = '1'; b.nodes['account-create-role'].value = 'admin';
|
||||
b.queues[accountPath].push(accountReply(20, 'pending', purpose)); b.click('account-submit-password'); cleanSecret(b); await tick();
|
||||
const post = b.calls.find(c => c.url === accountPath && c.method === 'POST'); assert.ok(post);
|
||||
assert.deepEqual(JSON.parse(post.body), purpose === 'create' ? {action:purpose, username:'new_account', role:'admin', password} : {action:purpose, username:'carol', user_id:7, auth_generation:2, password});
|
||||
assert.ok(Buffer.byteLength(post.body) <= 768); assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
|
||||
b.queues[accountPath].push(accountReply(20, 'ok', purpose)); b.fire(1000); await tick();
|
||||
noSecretOutput(b, password); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
|
||||
}
|
||||
});
|
||||
await test('Credential validation rejects short/long/non-ASCII/mismatch and invalid usernames; every attempt wipes', async () => {
|
||||
for (const password of ['a'.repeat(11), 'a'.repeat(65), 'é'.repeat(12), 'abcde\nfghijklm', 'abcde\u007ffghijklm']) {
|
||||
const b=await accountsBrowser(); draft(b, 'create', password); b.click('account-submit-password'); await tick(); cleanSecret(b);
|
||||
assert.ok(!b.calls.some(c=>c.url===accountPath));
|
||||
}
|
||||
for (const name of ['', 'Aname', 'a'.repeat(17), 'a b', '<img>']) {
|
||||
const b=await accountsBrowser(); draft(b); b.nodes['account-username'].value=name; b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
|
||||
}
|
||||
const b=await accountsBrowser(); draft(b); b.nodes['account-password-confirm'].value='different password'; b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
|
||||
});
|
||||
await test('Explicit generation is bodyless CSRF, bounded, separate from account list and mutation slot', async () => {
|
||||
const b=await accountsBrowser(); draft(b); const listReads=b.calls.filter(c=>c.url==='/api/settings/accounts').length;
|
||||
assert.ok(!b.calls.some(c=>c.url===generatePath)); await generated(b);
|
||||
const post=b.calls.find(c=>c.url===generatePath); assert.equal(post.body,''); assert.equal(post.headers['X-CSRF-Token'],token); assert.equal(post.headers['Content-Type'],undefined);
|
||||
assert.equal(b.calls.filter(c=>c.url==='/api/settings/accounts').length,listReads); assert.ok(!b.calls.some(c=>c.url===accountPath)); noSecretOutput(b);
|
||||
assert.ok([...b.timers.values()].some(t=>t.ms===60000));
|
||||
b.queues[accountPath].push(accountReply(21,'pending','create')); b.click('account-submit-password'); cleanSecret(b); await tick();
|
||||
assert.equal(JSON.parse(b.calls.find(c=>c.url===accountPath).body).password,secret);
|
||||
});
|
||||
await test('Generated acknowledgement binds exact value and intent; edits and regeneration reset it', async () => {
|
||||
for (const edit of ['unchecked','password','confirm','username','role','target','purpose','regenerate','silent-target','silent-password']) {
|
||||
const b=await accountsBrowser(); draft(b); await generated(b);
|
||||
if(edit==='unchecked') b.nodes['account-password-saved'].checked=false;
|
||||
if(edit==='password') { b.nodes['account-password'].value='different password'; b.nodes['account-password'].input(); }
|
||||
if(edit==='confirm') b.nodes['account-password-confirm'].input();
|
||||
if(edit==='username') { b.nodes['account-username'].value='another'; b.nodes['account-username'].input(); }
|
||||
if(edit==='role') { b.nodes['account-create-role'].value='admin'; b.nodes['account-create-role'].change(); }
|
||||
if(edit==='target' || edit==='silent-target') { b.nodes['account-target'].value='1'; if(edit==='target') b.nodes['account-target'].change(); }
|
||||
if(edit==='purpose') { b.nodes['account-purpose'].value='password'; b.nodes['account-purpose'].change(); }
|
||||
if(edit==='regenerate') { b.queues[generatePath].push(json({password:secret})); b.click('account-generate'); await tick(); }
|
||||
if(edit==='silent-password') b.nodes['account-password'].value=b.nodes['account-password-confirm'].value='different password';
|
||||
if(!edit.startsWith('silent')) assert.equal(b.nodes['account-password-saved'].checked,false);
|
||||
b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
|
||||
}
|
||||
});
|
||||
await test('Generated lifetime wipes at 60 seconds and expired delayed timer cannot authorize submission', async () => {
|
||||
for(const timer of [true,false]) {
|
||||
const b=await accountsBrowser(); draft(b); await generated(b); b.elapse(60000);
|
||||
if(timer) b.fire(60000); else b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath));
|
||||
}
|
||||
});
|
||||
await test('All secret lifecycle cleanup: view/domain/target/purpose/refresh/pagehide/logout/identity/401', async () => {
|
||||
for(const mode of ['view','domain','target','purpose','refresh','pagehide','logout','identity','401']) {
|
||||
const b=await accountsBrowser(); draft(b); await generated(b);
|
||||
if(mode==='view') b.click('select-serial');
|
||||
if(mode==='domain') b.click('settings-serial');
|
||||
if(mode==='target') { b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); }
|
||||
if(mode==='purpose') { b.nodes['account-purpose'].value='password'; b.nodes['account-purpose'].change(); }
|
||||
if(mode==='refresh') b.click('refresh-accounts');
|
||||
if(mode==='pagehide') b.emit('pagehide');
|
||||
if(mode==='logout') b.click('sign-out');
|
||||
if(mode==='identity' || mode==='401') { b.queues['/api/session'].push(mode==='identity'?session({role:'admin',username:'newadmin'}):failure(401)); b.click('refresh-accounts'); }
|
||||
await tick(); cleanSecret(b); noSecretOutput(b); assert.ok(![...b.timers.values()].some(t=>t.ms===60000));
|
||||
}
|
||||
});
|
||||
await test('Late generation headers and streamed bodies cannot resurrect secrets after form cancellation', async () => {
|
||||
for(const body of [false,true]) for(const mode of ['target','edit','purpose','domain','pagehide','logout','refresh']) {
|
||||
const b=await accountsBrowser(); draft(b); const d=deferred(); let stream;
|
||||
b.queues[generatePath].push(body?new Response(new ReadableStream({start(c){stream=c;}})):d.promise);
|
||||
b.click('account-generate'); await tick(); const post=b.calls.find(c=>c.url===generatePath);
|
||||
if(mode==='target') b.nodes['account-target'].change();
|
||||
if(mode==='edit') b.nodes['account-password'].input();
|
||||
if(mode==='purpose') b.nodes['account-purpose'].change();
|
||||
if(mode==='domain') b.click('settings-serial');
|
||||
if(mode==='pagehide') b.emit('pagehide');
|
||||
if(mode==='logout') b.click('sign-out');
|
||||
if(mode==='refresh') b.click('refresh-accounts');
|
||||
assert.ok(post.signal.aborted);
|
||||
if(body) { stream.enqueue(new TextEncoder().encode(JSON.stringify({password:secret}))); stream.close(); } else d.resolve(json({password:secret}));
|
||||
await tick(); cleanSecret(b); noSecretOutput(b); assert.equal(b.calls.filter(c=>c.url===generatePath).length,1); assert.ok(!b.calls.some(c=>c.url===accountPath));
|
||||
}
|
||||
});
|
||||
await test('Generation errors/schema/96-byte overflow/timeouts never echo response or retry', async () => {
|
||||
for(const response of [failure(503), failure(403), json({password:secret,extra:1}),json({password:'!'.repeat(24)}),json({password:'x'.repeat(25)}),new Response(' '.repeat(97)),json({password:secret+' '.repeat(100)})]) {
|
||||
const b=await accountsBrowser(); draft(b); b.queues[generatePath].push(response); b.click('account-generate'); await tick(); cleanSecret(b); noSecretOutput(b); noSecretOutput(b,'SECRET ERROR BODY');
|
||||
assert.match(b.nodes['account-secret-detail'].textContent,/Nothing applied/); assert.equal(b.calls.filter(c=>c.url===generatePath).length,1);
|
||||
}
|
||||
const b=await accountsBrowser(); draft(b); const d=deferred(); b.queues[generatePath].push(d.promise); b.click('account-generate'); await tick(); b.fire(15000); d.resolve(json({password:secret})); await tick(); cleanSecret(b);
|
||||
});
|
||||
await test('Generation timeout releases controls on fetch abort; explicit retry is isolated from serial/admin', async () => {
|
||||
const b = await accountsBrowser(); draft(b);
|
||||
b.queues[generatePath].push(options => new Promise((_, reject) => {
|
||||
options.signal.addEventListener('abort', () => reject(new Error('SECRET timeout')), {once:true});
|
||||
}));
|
||||
b.click('account-generate'); await tick(); assert.ok(b.nodes['account-generate'].disabled);
|
||||
b.fire(15000); await tick(); cleanSecret(b); noSecretOutput(b, 'SECRET timeout');
|
||||
assert.ok(!b.nodes['account-generate'].disabled && !b.nodes['account-submit-password'].disabled);
|
||||
assert.match(b.nodes['account-secret-detail'].textContent, /Nothing applied/);
|
||||
assert.equal(b.calls.filter(c => c.url === generatePath).length, 1);
|
||||
assert.ok(!b.calls.some(c => c.url === accountPath));
|
||||
await generated(b);
|
||||
assert.equal(b.calls.filter(c => c.url === generatePath).length, 2);
|
||||
assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
|
||||
});
|
||||
await test('Generation endpoint 401 wipes secrets and closes both routes once without a mutation', async () => {
|
||||
const b = await accountsBrowser(); draft(b); await generated(b);
|
||||
b.queues[generatePath].push(failure(401)); b.click('account-generate'); await tick();
|
||||
cleanSecret(b); noSecretOutput(b); noSecretOutput(b, 'SECRET ERROR BODY');
|
||||
assert.deepEqual(b.redirects, ['/login']); assert.ok(b.sockets.every(s => s.closed));
|
||||
assert.equal(b.timers.size, 0); assert.ok(!b.calls.some(c => c.url === accountPath));
|
||||
b.window.sakSessionExpired(); assert.deepEqual(b.redirects, ['/login']);
|
||||
});
|
||||
await test('Credential pre-submit session cancellation fences late responses and never sends the password', async () => {
|
||||
for (const purpose of ['create', 'password']) for (const mode of ['view', 'pagehide', 'identity', '401']) {
|
||||
const b = await accountsBrowser(); draft(b, purpose); await generated(b);
|
||||
const pending = deferred(); b.queues['/api/session'].push(pending.promise);
|
||||
b.click('account-submit-password'); cleanSecret(b); await tick();
|
||||
const check = b.calls.filter(c => c.url === '/api/session').at(-1);
|
||||
if (mode === 'view') b.click('select-serial');
|
||||
if (mode === 'pagehide') b.emit('pagehide');
|
||||
pending.resolve(mode === 'identity' ? session({role:'admin', username:'replacement'}) :
|
||||
mode === '401' ? failure(401) : session({role:'admin', username:'alice'}));
|
||||
await tick(); cleanSecret(b); noSecretOutput(b);
|
||||
assert.ok(check.signal.aborted); assert.ok(!b.calls.some(c => c.url === accountPath));
|
||||
assert.deepEqual(b.redirects, mode === 'identity' ? ['/'] : mode === '401' ? ['/login'] : []);
|
||||
assert.ok(b.sockets.every(s => mode === 'view' ? !s.closed : s.closed));
|
||||
}
|
||||
});
|
||||
await test('Self password/role/delete confirmation, no preemptive logout; 401 closes without success claim', async () => {
|
||||
for(const action of ['password','role','delete']) for(const stage of ['post','poll']) {
|
||||
const b=await accountsBrowser(); let warning=''; b.window.confirm=m=>{warning=m;return true;};
|
||||
if(action==='password') { draft(b,'password'); await generated(b); }
|
||||
const button=action==='password'?'account-submit-password':action==='role'?'account-change-role':'account-delete';
|
||||
b.queues[accountPath].push(stage==='post'?failure(401):accountReply(25,'pending',action)); b.click(button); cleanSecret(b);
|
||||
assert.ok(b.sockets.every(s=>!s.closed)); await tick();
|
||||
assert.match(warning,/ALL.*web\/SSH.*browser serial\/admin/); assert.match(warning,/401.*NOT proof/); assert.match(warning,/even a no-op role/); assert.ok(!warning.includes(secret));
|
||||
if(stage==='poll') { assert.ok(b.sockets.every(s=>!s.closed)); b.queues['/api/session'].push(failure(401)); b.fire(1000); await tick(); }
|
||||
assert.deepEqual(b.redirects,['/login']); assert.ok(b.sockets.every(s=>s.closed)); cleanSecret(b); noSecretOutput(b);
|
||||
assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/completed and saved/); assert.ok(!b.calls.some(c=>c.url==='/api/logout'));
|
||||
}
|
||||
});
|
||||
await test('Cancelled confirmation and failed credential POST wipe; duplicate/full safe result messages', async () => {
|
||||
for(const cancel of [true,false]) {
|
||||
const b=await accountsBrowser(); draft(b); await generated(b); b.window.confirm=()=>!cancel;
|
||||
b.queues[accountPath].push(failure(503)); b.click('account-submit-password'); cleanSecret(b); await tick(); noSecretOutput(b); assert.equal(b.calls.filter(c=>c.url===accountPath).length,cancel?0:1);
|
||||
}
|
||||
for(const state of ['duplicate','full']) { const b=await accountsBrowser(); b.queues[accountPath].push(accountReply(27,state,'create')); b.click('account-result'); await tick(); assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/undefined|unknown/); }
|
||||
});
|
||||
await test('Rejected self mutations retain both routes and lease without logout or success claims', async () => {
|
||||
for (const action of ['password', 'role', 'delete']) for (const outcome of [403, 'protected', 'failed', 'stale']) {
|
||||
const b = await accountsBrowser();
|
||||
b.sockets[0].emit('message', {data:JSON.stringify({type:'hello', clientId:8, writerId:8, role:'writer'})});
|
||||
if (action === 'password') { draft(b, 'password'); await generated(b); }
|
||||
b.queues[accountPath].push(outcome === 403 ? failure(403) : accountReply(28, 'pending', action));
|
||||
b.click(action === 'password' ? 'account-submit-password' : action === 'role' ? 'account-change-role' : 'account-delete');
|
||||
await tick();
|
||||
if (outcome !== 403) { b.queues[accountPath].push(accountReply(28, outcome, action)); b.fire(1000); await tick(); }
|
||||
cleanSecret(b); noSecretOutput(b); assert.deepEqual(b.redirects, []);
|
||||
assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
|
||||
assert.equal(b.nodes['writer-id'].textContent, '8'); assert.equal(b.nodes['release-control'].disabled, false);
|
||||
assert.doesNotMatch(b.nodes['account-operation-detail'].textContent, /completed and saved/);
|
||||
assert.ok(!b.calls.some(c => c.url === '/api/logout'));
|
||||
assert.equal(b.calls.filter(c => c.url === accountPath && c.method === 'POST').length, 1);
|
||||
}
|
||||
});
|
||||
await test('Generation session checks cannot strand reconnect; newer admission fences old generation', async () => {
|
||||
const b=await accountsBrowser(); draft(b); const old=deferred(); b.queues['/api/session'].push(old.promise);
|
||||
b.click('account-generate'); await tick(); b.click('connection-toggle'); b.click('connection-toggle'); await tick();
|
||||
old.resolve(session({role:'admin',username:'alice'})); await tick();
|
||||
assert.equal(b.sockets.length,3); assert.ok(!b.sockets[1].closed); assert.ok(!b.calls.some(c=>c.url===generatePath)); cleanSecret(b);
|
||||
await generated(b); assert.equal(b.nodes['account-generated'].value,secret);
|
||||
const c=await accountsBrowser(); draft(c); const reconnect=deferred(); c.click('connection-toggle'); c.queues['/api/session'].push(reconnect.promise); c.click('connection-toggle'); await tick();
|
||||
await generated(c); reconnect.resolve(session({role:'admin',username:'alice'})); await tick(); assert.equal(c.sockets.length,3); assert.equal(c.nodes['account-generated'].value,secret);
|
||||
});
|
||||
await test('Old generation/refresh responses cannot overwrite new target snapshot or generated value', async () => {
|
||||
const b=await accountsBrowser(); draft(b); const old=deferred(); b.queues[generatePath].push(old.promise); b.click('account-generate'); await tick();
|
||||
b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); await generated(b);
|
||||
old.resolve(json({password:'x'.repeat(24)})); await tick(); assert.equal(b.nodes['account-generated'].value,secret); assert.equal(b.nodes['account-password-saved'].checked,true);
|
||||
const stale=deferred(); b.queues['/api/settings/accounts'].push(stale.promise); b.click('refresh-accounts'); await tick(); cleanSecret(b);
|
||||
b.click('settings-serial'); await tick(); b.click('settings-accounts'); await tick(); draft(b); await generated(b);
|
||||
stale.resolve(json({users:[{username:'replaced',role:'user',user_id:90,auth_generation:99}]})); await tick();
|
||||
assert.match(b.nodes['accounts-list'].textContent,/alice/); assert.doesNotMatch(b.nodes['accounts-list'].textContent,/replaced/); assert.equal(b.nodes['account-generated'].value,secret);
|
||||
});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
@@ -78,7 +78,13 @@ esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
|
||||
assert (rendered['html'].index('id="terminal-title"') <
|
||||
rendered['html'].index('id="admin-toggle"') <
|
||||
rendered['html'].index('id="terminal-selector"'))
|
||||
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization'):
|
||||
for field in ('account-password', 'account-password-confirm'):
|
||||
assert re.search(r'id="' + field + r'" type="password" maxlength="64" autocomplete="new-password"', rendered['html'])
|
||||
assert 'id="account-generated" readonly autocomplete="off"' in rendered['html']
|
||||
assert 'id="account-password-saved" type="checkbox"' in rendered['html']
|
||||
assert 'not applied yet' in rendered['html'] and 'no retrieval' in rendered['html']
|
||||
assert 'JavaScript cannot securely zero strings' in rendered['html']
|
||||
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization', 'clipboard', 'pushState', 'replaceState'):
|
||||
assert forbidden not in rendered['script'] + rendered['loader'], forbidden
|
||||
(tmp / 'rendered.json').write_text(json.dumps(rendered))
|
||||
subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30)
|
||||
|
||||
Reference in New Issue
Block a user