diff --git a/README.md b/README.md index ac0e776..a0c8435 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ Apply changes RAM; **Save** explicitly persists device working state, not unsent Confirm disruptive actions only with a recovery route ready. `accepted` does not mean online or verified DNS, and HTTPS/SSH/both browser terminals may disconnect before acknowledgement. Never automatically replay uncertain operations: reconnect via STA/AP, use Check Result/Refresh and inspect state. UART0 remains administrative recovery and native USB remains network-independent UART1 access. Changed hostnames require client DNS/trust/login checks. Browser-shell command restrictions are unchanged. See the [full bounded API, implementation evidence and pending target checklist](docs/phase8d12_13_implementation.md); no new commands or generated assets are introduced. +### Browser HTTPS identity (8D.21 HTTPS-only slice) + +Administrators can use **Settings → HTTPS / Reboot → Refresh** to inspect the stored HTTPS certificate's public SHA-256 fingerprint and identity/service generations, then explicitly confirm **Rotate HTTPS identity**. Rotation persists a new certificate and private key and restarts HTTPS, invalidating all web logins and both browser terminal routes. Save browser drafts first. User accounts, SSH identity, Wi-Fi and device configuration are unchanged; UART0 administration and native USB UART1 access remain independent. + +A lost acknowledgement, expired login or failed stop/start does **not** prove that the old identity remains: the new identity may already be persisted, with an old certificate still served if shutdown failed. There is no rollback or automatic retry. Inspect the result through trusted UART0 using `web certificate info`; verify that fingerprint before renewing browser trust, then reload and sign in freshly. Accepting a certificate warning alone is not trusted verification. Use canonical `web stop` / `web start` for retained-server recovery when necessary. The existing CLI `web reset --force` retains TLS-only replacement/recovery semantics and starts a stopped service; the browser has no duplicate reset or unavailable-material recovery action. No certificate/private-key export is provided. + +The [HTTPS slice is implemented and host/build verified](docs/phase8d21_implementation.md); independent parent review and target trust/persistence/full-mix checks remain pending. SSH identity work requires a separate request, so full 8D.21 remains incomplete. + ## Documentation - [Hardware wiring](docs/wiring.md): hardware profile, GPIO assignments, connector guidance, and safety notes. diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 9934cad..6ffd31a 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -110,6 +110,8 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li ### HTTPS, WebSocket, and web serial +**8D.21 HTTPS identity extension:** The existing 8D.20 lifecycle routes/slot/controller additionally expose public stored SHA-256 fingerprint/identity generation and confirmed `rotate` with both expected generations. `web_server_replace_identity()` reserves the canonical service transition before acquiring the security identity reservation, then retains it across generation/commit and reserved stop/start. CLI rotate/reset and the deferred browser-shell owner use this same composition; direct security mutations acquire the same nonreused identity reservation. `web_security` holds its normal mutex only for admission/copies/publication during replacement, never over crypto/NVS; the reservation remains live while start copies the committed TLS pair. Generation/storage failure leaves identity and HTTPD unchanged; committed identity is never rolled back on lifecycle failure, so stored and served fingerprints can differ. Reset remains canonical CLI TLS-only recovery and starts a stopped service; ordinary stopped rotation stays stopped. No new browser reset, secrets, SSH identity action, route, task, timer or queue. Seven-field lifecycle snapshot is bounded to320 bytes; existing256-byte input/96-byte result and ACK deadlines remain. UI confirmation names the old public fingerprint and both generations, demands UART0 `web certificate info` verification of changed trust and fresh login, and shares the pending/no-replay gate with ordinary lifecycle controls. Full contracts/tests/target limits: `docs/phase8d21_implementation.md`. This extends, rather than replaces,8D.20 below. + **8D.20 integrated lifecycle (supersedes prerequisite-only status below):** `web_lifecycle_settings` admits strict current-admin cookie/Origin/CSRF stop/restart/reboot through three optional routes. One original-login operation slot and nonreused ID,256-byte/four-receive request,128-byte snapshot/96-byte result. Only after synchronous response send succeeds does HTTPD queue an ID callback; it hands that ID without waiting to the existing four-entry dispatcher. No request/fd/socket/operation pointer escapes the handler. Two-second ACK and30-second post-validation dequeue deadlines precede owner admission, not lifecycle completion. Lost HTTPD work retains one reservation until callback or successful destruction, avoiding late-work accumulation; failed destruction preserves it. Dispatcher reserves execution, revalidates original session/current admin and calls canonical generation-conditional owner APIs outside locks. Restart invalidates logins but retains its owner reservation through start; later revocation is not cancellation. Conditional reboot reserves the HTTPS generation then invokes the shared canonical `esp_restart()` primitive outside locks, with no console self-cleanup or HTTPD stop wait. UI explicit confirmation/manual results/15-second whole request fences and no replay/restore; existing Network reused.39 handlers/six sockets and unchanged tasks/stacks/timers/queue depth. Full API, uncertainty, SDK/reset audit and resource limits: `docs/phase8d20_implementation.md`. **Historical 8D.20 internal owner prerequisite (superseded above):** `web_server` exposes a zero-wait scalar management snapshot and off-HTTPD generation-conditional stop/restart APIs. A firmware-lifetime saturated generation advances under the existing lifecycle mutex on every admitted canonical start/stop, including failed attempts; counter clear and repeated init do not restore old tokens. Conditional admission rejects failed cleanup, transition, absent server or exhausted generation. Reserved restart keeps `s_transitioning` true across successful stop into private start, with no unreserved stopped gap; external lifecycle calls remain outside the mutex. Stop failure skips start, partial-start cleanup failure retains ownership, and canonical stop/start remain recovery after saturation/failure. Repeated init preserves retained lifecycle errors. These APIs have no production caller yet and perform no authorization or ACK handoff; HTTP routes/UI/typed reboot are unimplemented. A future dispatcher must validate the original login and bound ACK handling before admission, never call shutdown from HTTPD or describe admitted work as cancelled by later revocation. Contract and incomplete scope: `docs/phase8d20_implementation.md`. diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 350b623..163f1e7 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -61,6 +61,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the ## Web and WebSocket serial +- **8D.21 HTTPS-only slice (2026-09-13):** `web_security.{c,h}` owns zero-wait fingerprint/generation projection and nonreused identity reservation shared by canonical rotate/reset; replacement generates/commits outside locks, publishes only after commit. `web_server_replace_identity()` reserves service before identity checks and retains both through commit/stop/start, shared by `web_console` and browser-shell owner; no mutation before stale service rejection, no rollback after commit. `web_lifecycle_settings.c`/`web_ui.c` extend the existing 8D.20 slot/routes/controller with confirmed `rotate` plus required identity generation and seven-field public metadata snapshot (320-byte bound); ACK/original-login and pending/no-replay rules unchanged. No new route/task/timer/queue/assets/config/SSH identity work. PASS security17, lifecycle44+two real-crypto/NVS integration groups, cookie lifecycle8+shared/all variants, UI156+HTML/CSP and broad regressions. Final pio100,532 RAM/1,825,073 flash (+24/+3,568 vs audited baseline), CPU160 verified. Chosen HTTPS slice implemented; parent review/target pending, full21 incomplete pending separately requested SSH identity work. Exact ownership/API/failure/wrap/test/resource contracts: `docs/phase8d21_implementation.md`. + - **8D.20 end-to-end (supersedes prerequisite-only entry below):** `web_lifecycle_settings.{c,h}` owns three optional current-admin lifecycle snapshot/operation routes,256-byte typed stop/restart/reboot, one original-login slot and bounded send-return→HTTPD ID callback→existing dispatcher handoff. One lost ACK reservation survives until callback/successful HTTPD destruction; nonreused IDs and owner generation prevent late/same-pointer ABA. `web_server` conditional reboot uses canonical `esp_restart()` outside locks, stop/restart retains owner reservation. `web_ui.c` adds confirmed HTTPS/Reboot,15-second requests/manual results, pending uncertainty/no replay/relogin, link to existing Network with accurate UART0/SSH/USB recovery.39 handlers/six sockets; no tasks/timers/queue growth/assets/config/identity work. PASS lifecycle41, cookie lifecycle8+shared, UI153+HTML/CSP, dispatcher and broad regressions. Final pio100,508 RAM/1,821,505 flash (+104/+13,064 vs pre-phase100,404/1,808,441); CPU160 preserved. Host/build complete, independent parent review and target/runtime checks pending. Exact bounds/failure/reboot audit/resources/checklist: `docs/phase8d20_implementation.md`. - **Historical 8D.20 internal HTTPS owner prerequisite (superseded by integration above):** `web_server.{c,h}` owns zero-wait `web_server_get_management_snapshot()` plus off-HTTPD `web_server_stop_current()`/`web_server_restart_current()`. Canonical mutex compares saturated lifecycle generation; restart retains transition reservation across stop/start, failures preserve canonical cleanup/recovery. Repeated init preserves lifecycle failure. No HTTP/UI/ACK/reboot integration or new production caller yet. Existing lifecycle harness now34 groups including ABA, saturation, gap interleavings and failure recovery. Baseline100,404/1,808,441 →100,404/1,808,589 B (+0/+148); unused public entry points linker-discarded. Parent/target review pending. Contract/split/remaining authorized20 and target checklist: `docs/phase8d20_implementation.md`. Do not implement unrelated19 or duplicate existing Network controls. diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index aa5ac34..88a003d 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,10 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **8D.21 HTTPS-first slice implemented end-to-end (2026-09-13), host/build verified, independently reviewed; target sign-off pending:** Preserved initial three-file audit documentation work, then completed shared `web_server_replace_identity()` service-before-identity reservation across generation/commit/stop/start, zero-wait public `web_security` fingerprint/generation projection and nonreused reservation shared by direct canonical rotate/reset. Crypto/NVS outside locks; no identity mutation before stale service rejection, no CLI/browser-shell bypass, no rollback after commit. CLI reset/recovery starts a stopped service; stopped ordinary rotation stays stopped. Existing8D.20 lifecycle routes/slot/ACK/original-login dispatcher and shared UI controller extend with confirmed `rotate`, required identity generation and320-byte seven-field snapshot. Fingerprint/both generations confirmed; trusted UART0 `web certificate info`, changed trust/fresh login, all-web-session disruption, partial-effect uncertainty,15-second UI bounds/manual results/no replay retained. No browser reset/export/recovery secrets, SSH identity work, new tasks/timers/routes/queues/assets/config/SDK/dependencies. PASS security17, lifecycle44+two integrated production-owner/real-mbedTLS/NVS fault groups, cookie lifecycle8+shared/all variants, UI156+renderer/HTML/CSP, dispatcher/console self-detach and broad regressions. Final pio22.78s **100,532 RAM/1,825,073 flash (+24/+3,568 vs audited100,508/1,821,505)**; defaults/active/generated CPU160 verified, combined WS send and prior20/18/19/throughput work preserved. Independent reviewer found no confirmed actionable findings, reran security17/lifecycle44+2/cookie lifecycle/UI/dispatcher/console lifecycle/diff PASS. Parent final pio confirmation PASS6.98s100,532/1,825,073 B; diff check PASS. Final expanded integration rerun PASS; no hardware/upload/erase/commit/branch or runtime-reserve/M3 claim. Exact ownership/wrap/failure/API/tests/resources and pending parent/target checklist: `docs/phase8d21_implementation.md`. **Chosen HTTPS slice complete; full21 incomplete, remaining SSH identity work separately requested.** + +- **Historical 8D.21 audit-only handoff (2026-09-13), superseded by implemented HTTPS slice above:** Read exact plan and relevant memory/source; enumerated HTTPS-first public metadata + confirmed rotation, excluded redundant healthy-material reset UI, all SSH identity work and recovery secrets/database recovery. Verified TLS-only reset additionally recovers unavailable material and starts a stopped HTTPS service at the CLI; preserve those semantics. Existing security mutex protects generation/commit but not restart; CLI and browser-shell callers commit then stop/start separately. Completion requires combined service/identity generation admission and reservation shared with canonical callers,8D.20 ACK path, complete typed API/UI and fault/concurrency/auth/no-replay tests—not unused prerequisite interfaces. Baseline `pio run` PASS7.17s100,508 RAM/1,821,505 flash; initial worktree clean. No source/test/device/config changes or regression/final-build/review/sign-off claim. Only audit handoff documentation delivered; chosen HTTPS slice and full21 both incomplete. Exact findings, exclusions and continuation checklist: `docs/phase8d21_implementation.md`. Do not mistake this entry for feature delivery; durable architecture/code-map/decisions remain unchanged because no owner contract changed. + - **8D.20 end-to-end integration complete (2026-09-13), host/build verified, independently reviewed; target sign-off pending:** Continuation found/preserved uncommitted backend/API/UI/tests beyond the prerequisite-only memory. Inspected and validated that integration, added send-return-at-deadline/request-storage-reuse/accepted-restart-expired-login regressions and explicit post-restart login wording. `web_lifecycle_settings.{c,h}` provides three optional current-admin routes, strict256-byte/four-receive typed stop/restart/reboot,128-byte snapshot/96-byte result, one login-bound slot/nonreused ID and one2-second ACK reservation. Synchronous send return then HTTPD ID callback then existing four-entry dispatcher, original-login/current-admin/post-validation30-second deadline, canonical generation/reserved stop/restart and narrow `esp_restart()` admission. No request/fd capture, no lifecycle on HTTPD, no lock/self-console-cleanup wait at reset. Lost HTTPD work stays singly reserved until callback or successful destruction; late/ABA work inert; failed stop retains canonical recovery. UI HTTPS/Reboot explicit confirmations,15-second whole requests, manual results, pending/unknown duplicate gate and no replay/restore; existing Network link/controller, corrected USB-vs-admin recovery wording. PASS lifecycle41, cookie lifecycle8+shared/all prior variants, dispatcher and broad console/auth/transport/store/idle/performance/SSH/Network/broker/security/parser/login/diagnostics; UI153+seven-view HTML/CSP. Final focused suites rerun after final wording. `pio run` first21.86s100,508/1,821,441; final22.85s100,508/1,821,505 B. Final delta vs pre-phase100,404/1,808,441: **+104 RAM/+13,064 flash**; vs prerequisite100,404/1,808,589: +104/+12,916. Module mutable object108 B (slot88,lock8,IDs/handle12); linked placement differs.39 handlers (36+3), six sockets; no task/timer/queue-depth/item/stack/session/ticket/heap/PSRAM/schema/config/assets/SDK/dependency/identity changes. CPU160/nonblocking HTTPD queue verified; UI18/SSH19/combined WS preserved. No device/upload/erase/commit or runtime reserve/full-M3 claim. SDK sequencing/reset inspected, not real scheduler/reset tested. Independent reviewer found no confirmed actionable defects after installed-IDF callback/reset and owner/UI audit; independently reran lifecycle41, cookie lifecycle8+shared, dispatcher, UI153+renderer/HTML/CSP and diff checks PASS. Parent final pio confirmation PASS6.82s100,508/1,821,505 B; diff check PASS. Full exact contracts, test commands/evidence, limits and pending target checklist: `docs/phase8d20_implementation.md`. The prerequisite-only entry below is historical and superseded by this integration. - **Historical 8D.20 lifecycle-owner prerequisite (2026-09-13), superseded by integration above:** Scope split before editing under plan600–800-line rule after finding missing HTTPS generation admission and stop/start reservation. `web_server.{c,h}` adds zero-wait secret-free management snapshot, conditional stop/restart under canonical mutex, saturated lifecycle generation across CLI ABA/counter clear and continuously reserved restart. Failed stop skips start; partial cleanup retains ownership and canonical recovery. Repeated init no longer clears retained invalid-state lifecycle failure. No new HTTP routes/UI/ACK handoff/typed reboot; these remain next authorized8D.20 integration, not complete phase delivery. Existing Network Wi-Fi controls must be reused; unrelated19 other-service and21 excluded. Initial worktree clean. PASS lifecycle34 (27+7 owner groups), broad console/auth all variants/UI143/SSH/Network/broker/store/transports/idle/performance/security/login/parser/diagnostics and diff check. Baseline pio21.10s100,404 RAM/1,808,441 flash → post-production35.05s100,404/1,808,589 (+0/+148 B); final pio20.02s same sizes, lifecycle34/diff confirmation PASS. Object generation4 B; unused public management entry points linker-discarded, so not full feature cost. CPU160 active/default/generated verified; UI18/SSH19 and combined WS source unchanged. No new task/timer/queue/depth/stack/socket/route/heap/assets/SDK/dependency/config/secret/device/upload/erase/commit changes. Self-review only; no independent reviewer tool, parent review pending. Target/runtime reserves/M3 pending. Exact owner contract, seven new fault groups, split rationale, remaining ACK/API/UI/reboot scope and target checklist: `docs/phase8d20_implementation.md`. Earlier no8D.20 wording below is historical;19 remains SSH-only completed/reviewed, other-service slice unimplemented. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index f6fcc48..b85f977 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -168,6 +168,8 @@ The following first-slice exclusions are historical and superseded by slice 2: ## Security material and configuration use bounded, versioned NVS records +**8D.21 HTTPS identity owner decision:** Reserve the service transition BEFORE conditional identity admission or generation, not rotate-then-conditional-restart. `web_server_replace_identity()` shares this composition with CLI reset/rotate and deferred browser-shell rotation; `web_security` reserves a nonreused token across generate/commit and service stop/start, excluding direct canonical security mutation too. Short normal-mutex admission/publication protects state, but crypto/NVS and HTTPD waits run without held locks. Failed generation/storage never publishes or stops HTTPD; committed material is not rolled back if later stop/start fails. Identity generation increments only on successful commit; service generation advances on admitted replacement and canonical stop/start, saturating without reuse. Reservation-token exhaustion rejects mutations until reboot; persisted identity exhaustion retains the existing fail-closed behavior. Zero-wait public metadata is the stored identity, not proof of the retained HTTPD certificate after failure. Extend only the existing ACK-safe typed lifecycle slot/controller with `rotate` and required identity generation; keep UART0 unavailable-material recovery and duplicate healthy Reset out of browser UI. Explicit trust/fingerprint verification through trusted UART0 and fresh login, uncertainty and no replay are correctness requirements. SSH identity work remains separately requested. See `docs/phase8d21_implementation.md` for exact API, bounds and host-versus-target evidence. + **Decision:** Application settings, users, and identities use separate fixed/versioned NVS blobs. Serial, Wi-Fi, mDNS-hostname, and local-UI working edits are RAM-only until explicitly saved. User mutations and HTTPS/SSH identity changes commit directly as part of the operation. Invalid ordinary configuration generally selects RAM defaults without erasing storage; malformed security material fails closed and needs explicit reset. **Rationale/evidence:** Serial, Wi-Fi, local UI, web security, users, and SSH security each validate schema/size and own their namespace. User/security mutations build and validate candidate state before committing it; security modules avoid silently replacing an established identity. The live user database remains internal while its 5,360-byte candidate is a persistent PSRAM-preferred allocation with internal fallback and is wiped after every transaction. diff --git a/docs/phase8d21_implementation.md b/docs/phase8d21_implementation.md new file mode 100644 index 0000000..c02ee56 --- /dev/null +++ b/docs/phase8d21_implementation.md @@ -0,0 +1,233 @@ +# 8D.21 — HTTPS-first security settings + +## Status (2026-09-13) + +**Chosen HTTPS slice implemented end-to-end, host-tested and build-verified.** +Independent review complete with no confirmed actionable findings; target validation/sign-off remains pending. Full +8D.21 remains incomplete: SSH identity work requires its separately requested +owner/scope audit and implementation. No SSH identity changes were made here. + +The previous agent delivered only an audit and baseline, modifying this document, +`docs/agent/current-state.md` and `docs/phase8d_plan.md`. Those were the only initial +worktree changes in this continuation. This implementation supersedes their +NOT-implemented status, not prior user sign-offs or resource follow-ups. + +Independent reviewer checked shared service/identity reservations, all canonical mutation callers, installed-IDF TLS material copies, commit/failure semantics, auth/ACK and public metadata. Reviewer reran security17, lifecycle44+2 integration groups, cookie lifecycle8+shared, UI/renderer/HTML/CSP, dispatcher/console lifecycle and diff checks PASS. Parent final `pio run` confirmation PASS6.98s,100,532 B RAM/1,825,073 B flash; diff check PASS. No target trust/reset/NVS power-loss or runtime-margin validation inferred. + +## Delivered scope and user workflow + +- Admin Settings → **HTTPS / Reboot** now also shows the **stored** HTTPS + certificate's SHA-256 fingerprint, identity generation and service generation. + This is public metadata behind current-admin authorization, not an unauthenticated + endpoint, certificate download, private-key export or proof of the served identity. +- **Rotate HTTPS identity…** confirms the old fingerprint and both generations, + persistence, changed browser trust and loss of all web logins/both browser terminal + routes, including clients admitted before execution. Save browser drafts first. +- Verify the NEW fingerprint using trusted UART0 `web certificate info` before + accepting changed browser trust. A browser certificate warning alone is not + trusted verification. Reload and sign in freshly; never automatically retry. +- A new identity may already be persisted even if stop/start fails. A retained + HTTPD can still serve the old certificate; compare stored and served identity + explicitly and recover using canonical `web stop` / `web start` when necessary. +- SSH identity, accounts, Wi-Fi, serial configuration and other saved settings + are unchanged. UART0 administration and native USB UART1 access are independent + of this rotation. USB is not an administrative console. The preexisting reboot + action still interrupts the entire device; rotation does not reboot it. +- No duplicate browser Reset for healthy TLS material. Existing CLI + `web reset --force` retains TLS-only replacement/recovery and starts a stopped + HTTPS service; ordinary CLI rotation leaves a stopped service stopped. Neither + touches users. Unavailable-user-database recovery stays UART0-only. No browser + unavailable-material recovery, bootstrap, retained recovery secret, new password + feature, configuration wipe, factory reset, OTA, encryption or secure boot. + +## Ownership and admission contract + +### Combined service/security owner + +`web_server_replace_identity(service_generation, identity_generation, reset, +&committed)` is the shared off-HTTPD owner entry point. Both nonzero generations +select conditional healthy-running rotation; both zero select existing canonical +CLI semantics. Reset cannot be combined with conditional generations. + +1. Conditional calls avoid the blocking legacy initializer and take the existing + server mutex with zero wait. Reject stale/saturated service generation, absent + server, transition or failed cleanup **before any identity mutation**. +2. Set `s_transitioning` under the server mutex, then release it. This reservation + excludes canonical start/stop/restart/reboot and competing CLI identity work. +3. Reserve the expected identity using `web_security_reserve_identity()`. Its + zero-wait security mutex check excludes stale/unavailable/saturated identity, + existing reservation and exhausted reservation IDs. Canonical direct + `web_security_rotate_certificate()` / `web_security_reset_all()` acquire the + same reservation, so they cannot bypass an admitted combined operation. +4. After both reservations, advance the saturated service generation. Generate a + local candidate and commit its bounded existing NVS blob without holding either + mutex or a spinlock. Publish only after successful commit, wiping the old key. + The security mutex protects short state admission/copies/publication, not crypto + or flash. Initialization/migration retains its existing startup locking. +5. Keep the identity reservation through canonical reserved stop/start. Start can + copy the committed TLS pair under the ordinary security mutex without deadlock. + Running rotation/reset restarts HTTPS; stopped rotation remains stopped and + stopped reset starts HTTPS. No unreserved stop-to-start gap is introduced. +6. Release the nonreused identity token on return. Only its reserving owner may + replace once and release it; old tokens cannot replace/release a new reservation. + +Identity generation advances only on committed replacement, retaining the existing +persisted UINT32_MAX fail-closed rule. Service generation advances on admitted +replacement and on each admitted stop/start, including failed attempts, saturating +at UINT32_MAX without wrapping. Failed identity admission does not advance it; +failed crypto/storage after combined admission can advance service generation +without changing identity or stopping HTTPD. Reservation IDs never reuse; exhaustion +denies identity mutations until reboot, while existing canonical service recovery +remains available. Reboot invalidates all old browser sessions. + +Public snapshot copies use separate zero-wait service/security observations, not +one cross-owner atomic view. They authorize nothing; any intervening service or +identity change is rejected by the combined execution-time reservations. Unavailable +identity metadata yields generation0/empty fingerprint/rotatable false without +removing ordinary lifecycle controls. + +### Failures, revocation and console safety + +- Generation, RNG, NVS open/set/commit errors before publication leave the live + identity unchanged and do not stop HTTPD or revoke its logins. Host NVS faults + also verify unchanged stored bytes; doubles do not prove power-loss behavior. +- `committed=true` is irreversible publication even when stop/start returns an + error. Stop failure skips start and retains canonical cleanup ownership. Failed + start follows the existing retained-handle cleanup rules. No rollback to the old + identity occurs. CLI reports the precommit/postcommit distinction; typed results + deliberately retain conservative failure/unknown-outcome language. +- Stop invokes canonical `web_cookie_auth_stop()` and transport detach/stopped + paths, invalidating all web sessions/tickets and closing browser routes. No + unrelated account or SSH revocation is added. Later logout/revocation is not + cancellation of admitted crypto/commit/restart. +- `web_console.c` rotate/reset wrappers and `web_admin_transport.c` deferred + browser-shell rotation call the same combined owner, not separate commit/stop/start. + Browser shell retains its existing parsed force policy and drain/dispatcher mask; + its acknowledgement is still the bounded application-drain heuristic, not HTTP ACK. +- Inspected `web_admin_transport_detach()`: it waits only for queue submitters, not + console execution. `admin_ssh_console_close()` marks an executing slot inactive + without waiting/wiping it; dispatcher return retires that slot. No self-console + cleanup wait, command-string replay, HTTPD self-stop or lock-held HTTPD wait. + +## Bounded HTTP and UI contracts + +Reuse the three existing optional routes and single lifecycle operation slot: + +- `GET /api/settings/lifecycle`: seven fields, **320-byte response buffer**: + `generation`, `running`, `transitioning`, `controllable`, `identity_generation`, + `fingerprint` (64 lowercase hex digits, SHA-256), `rotatable`. +- `POST /api/settings/lifecycle-operation`: `stop|restart|reboot` still require + exactly action + service generation. `rotate` requires exactly + `{"action":"rotate","generation":7,"identity_generation":11}`. + No reset, extra fields, duplicate fields, escaped/coerced values, zero/saturated + generations or trailing bytes. Existing **256-byte/four-receive** bound retained. +- `GET /api/settings/lifecycle-operation`: existing **96-byte** login-isolated + `id/action/state` result. `failed` can mean rejected admission OR partial effects; + no falsely precise success, cancellation, rollback or durable-history guarantee. + +Current cookie/principal/admin checks protect all routes; mutations additionally +require existing JSON/Origin/CSRF policy. The operation keeps the original login, +not merely the account. Reuse8D.20's send-return → nonreused-ID HTTPD callback → +existing dispatcher handoff: no crypto, persistence or lifecycle on HTTPD and no +request/fd/operation pointer captured. Two-second ACK and post-validation30-second +dequeue deadlines remain admission bounds, not peer receipt or execution deadlines. +One accepted-but-lost callback reservation survives until callback arrival or +successful HTTPD destruction; failed destruction cannot accumulate new callbacks. + +`web_ui.c` extends the existing lifecycle view/controller, not a parallel Security +controller. It validates the complete bounded public schema, captures both versions +before confirmation/session validation, and shares single-flight/pending gates with +all lifecycle actions. Identity unavailable/exhausted disables rotation. Explicit +manual Check Result/Refresh,15-second whole-request deadline, late-result/context +fencing and original-login rules remain. Unknown/lost ACK cannot clear the duplicate +gate by adopting another result; navigation/pagehide does not replay or restore a +mutation. No generated secret, secret input or retained retry material is introduced. +No generated assets or loader/CSP changes; existing HTML/CSP tests pass. + +## Validation performed + +All commands below passed in this continuation; no hardware/runtime pass inferred. + +- `python3 tests/web_security/run.py`: **17 production-security groups** with real + host mbedTLS, including exact NVS open/set/commit transactional failures, real + fingerprint/key changes, generation exhaustion, public-only projection, zero-wait + contention, one-use/nonreused tokens and competing canonical writers during real + crypto outside locks. Existing migration/invalid storage/recovery tests retained. +- `python3 tests/web_admin_transport/server_lifecycle.py`: **44 orchestration/ACK + groups plus2 integrated owner/security groups**. The second executable links the + extracted production server owner to the complete production security implementation + and real mbedTLS, with NVS/HTTPD/scheduler doubles. Tests real generation-time RNG + and NVS open/set/commit failures, exact unchanged identity/storage before commit, + committed identity surviving stop/start failures, stale service/identity rejection, + and canonical/direct-security exclusion during crypto AND stop/start. The44 groups + additionally cover CLI stopped rotation/reset, saturated/stale/busy/currentness + admission, every detach failure, original ACK sequencing and lifecycle recovery. +- `python3 tests/web_cookie_auth/run.py --lifecycle`: **8 extended lifecycle groups + plus shared auth tests**, actual handlers/parser/auth/session store; rotate's exact + third generation, malformed/truncated inputs, bounded fingerprint-only projection, + all four actions off-handler/off-callback, login invalidation after admission, + queue/send/lost/late callback/request storage reuse and deadline/wrap regressions. +- `python3 tests/web_ui_session/run.py`: **156 browser behavior groups plus C-renderer, + seven-view HTML and exact CSP checks**. Three new groups cover fingerprint/version + confirmation, missing/invalid generation/fingerprint/secret-field rejection, + unavailable identity, shared pending gate, partial-effect warning, role loss, + lost ACK, expired login and no replay/restore. Existing lifecycle timing and + navigation tests remain. Not real-browser geometry or TLS target validation. +- `python3 tests/admin_console_boundary/run.py` and `lifecycle.py`: PASS, including + deferred browser certificate dispatcher/executing-slot/self-detach safety and + typed dispatcher queue isolation. `python3 tests/web_admin_transport/run.py`: + **25 groups**; its owner dependency double now represents the combined API. +- `python3 tests/web_cookie_auth/run.py` with no option and each of `--settings`, + `--serial-settings`, `--accounts`, `--network`, `--display`, `--broker`, `--ssh`, + `--lifecycle`, `--admin`: PASS. +- `python3 tests//run.py` for `admin_ssh_policy`, `web_auth_parse`, + `web_session_store`, `web_httpd_idle`, `web_serial_performance`, `web_diagnostics`, + `web_login_ui`, `ssh_management`, `web_network_settings`, + `session_broker_diagnostics`, `web_security`, `web_admin_tickets`: PASS. + `python3 tests/web_session_store/run.py --serial`: PASS. +- Final combined owner/integration suite rerun after adding generation-time RNG and + stop/start reservation interleavings: PASS. The initial integration-fixture compile + found an unused copied test counter; fixture extraction was corrected, not compiler + warnings disabled. No production test failure was hidden. +- `git --no-pager diff --check`: PASS. Final worktree inspection shows only the + intended source/tests/docs; no assets, configuration or device-operation changes. + +## Build, resources and preserved work + +- Prior audit baseline: `pio run` PASS7.17s, **100,508 B RAM / 1,821,505 B flash**. + It was not redundantly rerun before implementation. +- Intermediate implementation build: PASS21.92s,100,532 /1,824,889 B. +- Final production `pio run`: **PASS22.78s,100,532 B RAM /1,825,073 B flash**. + Final delta against audited baseline: **+24 B RAM /+3,568 B flash**. +- Still **39 handlers/six sockets**, same existing lifecycle slot/dispatcher item + and queue depth, no new task/timer/stack size/session/ticket/heap allocation/schema. + Lifecycle snapshot buffer grows128→320 bytes; identity-operation slot adds one + uint32 field; security adds bounded token/one-use state. The existing local TLS + candidate is retained, not another persistent private-material allocation. +- Defaults, active sdkconfig and generated sdkconfig.h independently checked: + **CPU160MHz**. No configuration changes. Combined binary WebSocket send, + signed-off throughput, serial broker isolation and native USB path unchanged. +- No assets regeneration, SDK/dependency changes, device actions, upload, erase, + commit or new branch. User/prior-agent documentation work preserved and updated. + Runtime HTTPD/dispatcher stack margins and heap minima are not inferred from + linked RAM or host sizeof; the larger snapshot handler frame needs target checks. + +## Pending parent/target gates and remaining phase + +- [ ] Independent parent review of combined ownership, direct canonical exclusions, + snapshots/ACK/auth/parser/UI and failure/uncertainty contracts. +- [ ] On target, verify old/new stored and served fingerprints, trusted UART0 + verification, browser trust renewal, fresh login and persisted identity after + a normal reboot (no automatic reboot in this feature). +- [ ] Verify canonical CLI reset with unavailable TLS material and stopped-service + rotation/reset semantics, including low-memory/cleanup failure recovery. +- [ ] Real-client lost response, timeout, two-admin concurrency, stale confirmations, + changed role/login and cleanup behavior; no mutation replay. +- [ ] Full mix at160MHz (two web serial, browser admin, SSH serial/admin and USB), + unchanged binary transparency/writer isolation, UART0 recovery/native USB + availability through HTTPS rotation, HTTPD/dispatcher stack and heap minima. +- [ ] User target sign-off. No reserve approval, full M3 or full8D.21 claim. + +**HTTPS slice has no remaining implementation item identified here.** SSH identity +metadata/rotation/reset remains outside this slice and requires a separate request; +no speculative SSH owner design or implementation is included. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index 5a07649..9727db9 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -192,7 +192,7 @@ Typed operations must preserve subsystem owner/lock/persistence contracts and co | **8D.18 — Client/writer contextual dialogs** | **Implemented, host/build verified; independent parent review and target sign-off pending.** UI-only reuse of8D.16 and8D.17's single host for live client popover and confirmed Active writer dialog. [Contract/tests/checklist](phase8d18_implementation.md). | Native pointer/keyboard/touch entrances; single-flight5-second live refresh/deadline, explicit selection preserved without lease-token renewal, sticky stale/absent rejection, full-page draft protection and focus-safe updates. Ordinary users retain only ordinary status.135 UI groups plus broad broker/auth/lifecycle regressions pass; real browser/device checks pending. No new writer policy/backend/icons/8D.19/later. | | **8D.19 — Ordinary service/session controls** | **First service slice SSH implemented, host/build verified; independent parent review and target sign-off pending. Phase incomplete.** Typed SSH status and confirmed exact-session disconnect/start/stop via existing dispatcher/SSH owner, excluding invoking HTTPS-session-cutting actions. [SSH contract/tests/resources](phase8d19_implementation.md). | Explicit SSH/all-SSH/one-session confirmation; owner lock/service generation/retired session IDs reject stale/reused targets and stop/start ABA. No settings/identity clear. SSH4, cookie SSH6+shared, dispatcher, lifecycle27 and UI143 PASS. Split-by-service rule applied: all web-session/HTTPS/USB controls excluded; next other-session web slice requires explicit login/owner-safety audit, USB actions are not promised. No generic broker disconnect or8D.20/21. Target full-mix/heap/stack/recovery checks pending. | | **8D.20 — Self-affecting service actions and reboot** | **User-authorized HTTPS stop/restart/reboot integration implemented, host/build verified; independent parent review and target sign-off pending.** Current-admin typed routes, bounded send-return/HTTPD ID callback/existing dispatcher handoff, canonical generation/reserved lifecycle and shared reset API; explicit Settings HTTPS/Reboot and link to existing Network. [Contracts, tests, costs and checklist](phase8d20_implementation.md). | PASS lifecycle41, cookie lifecycle8+shared, UI153+HTML/CSP, dispatcher and broad regressions. Tests cover queue/send/lost/late callback/request-lifetime/ABA/login revocation/deadlines/owner failures, no replay/late result adoption, all-client/unsaved-state and accurate UART0/SSH/USB recovery. Final pio100,508 RAM/1,821,505 flash (+104/+13,064 vs pre-phase).39 handlers/six sockets, no new tasks/timers/queue growth/assets/config/identity or unrelated19/21. Real TLS/scheduling/reboot/full-mix/runtime reserves and independent review remain pending. | -| **8D.21 — Security/danger-zone settings** | Carefully separated HTTPS/SSH identity rotation/reset and any explicitly retained recovery-secret operation through existing security APIs. Enumerate allowed operations first; split HTTPS and SSH work into separate requests if both need owner changes. | Confirmation, secret-safe one-time responses, no routine private-key export, expected trust/fingerprint changes, bounded self-disconnect, no accidental configuration wipe. Bootstrap/unavailable-database recovery remain UART0-only. NVS encryption, secure boot, OTA, and new factory-reset semantics stay out of scope. | +| **8D.21 — Security/danger-zone settings** | **Chosen HTTPS-first slice implemented end-to-end, host/build verified; independent parent review and target sign-off pending. Full21 incomplete.** Public stored certificate fingerprint/identity+service generations and confirmed rotation reuse8D.20 routes/ACK slot/dispatcher/UI controller. Shared service-before-identity reservation covers canonical CLI/browser-shell/direct security mutation exclusion through crypto/commit/stop/start; no rollback after commit. Canonical TLS-only reset/recovery retained without duplicate browser Reset. [Exact HTTPS contract/tests/resources and pending gates](phase8d21_implementation.md). **SSH identity work remains separately requested**, not implemented; no recovery-secret operation added. | PASS security17, lifecycle44+two real-mbedTLS/NVS integration groups, cookie lifecycle8+shared/all variants, UI156+HTML/CSP and broad regressions. Final pio100,532 RAM/1,825,073 flash (+24/+3,568 vs audited baseline),39 handlers/six sockets/no new task/timer/queue/assets/config. Confirm fingerprint/both generations, warn changed trust, trusted UART0 verification/fresh login, partial effects and no replay. No private-key/certificate export, browser invalid-material recovery or configuration wipe. Bootstrap/unavailable-database recovery remain UART0-only; NVS encryption/secure boot/OTA/new factory reset excluded. Parent/target trust/persistence/full-mix/runtime reserve gates pending. | **Scope decision (2026-09-09):** Phase 8D.15 has been removed at the user's request. Network diagnostics remain exclusive to the admin shell; no dedicated typed diagnostic endpoints or settings UI are planned. Existing shell transport permissions and implemented Network settings/status remain unchanged. Later phase numbers are retained for stable references; the next planned chunk after 8D.14 is 8D.16, requiring a separate implementation request. diff --git a/src/web_admin_transport.c b/src/web_admin_transport.c index 469fdd0..b6f9625 100644 --- a/src/web_admin_transport.c +++ b/src/web_admin_transport.c @@ -109,11 +109,8 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token, /* The owner mask routes this crypto/NVS path to the 12KiB dispatcher. * Commit before restart; a failed stop retains HTTPD ownership and must * not be followed by start. No socket IO or console handler calls here. */ - esp_err_t error = web_security_rotate_certificate(); - if (error != ESP_OK) return error; - error = web_server_stop(); - if (error != ESP_OK) return error; - return web_server_start(); + bool committed = false; + return web_server_replace_identity(0, 0, false, &committed); } if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop(); if (action == ADMIN_SSH_DEFER_REBOOT) { diff --git a/src/web_console.c b/src/web_console.c index b96cb64..d4909f9 100644 --- a/src/web_console.c +++ b/src/web_console.c @@ -234,68 +234,25 @@ static bool force_is_present(int argc, char **argv, int expected_argc) return argc == expected_argc && strcmp(argv[expected_argc - 1], "--force") == 0; } -static int restart_if_running(bool was_running) +static int replace_material(bool reset) { - if (!was_running) { - return 0; - } - esp_err_t error = web_server_stop(); + bool committed = false; + esp_err_t error = web_server_replace_identity(0, 0, reset, &committed); if (error != ESP_OK) { - printf("Material changed, but the old TLS server could not stop: %s\n", - esp_err_to_name(error)); + printf("%s: %s\n", committed + ? "New HTTPS identity persisted, but stop/start failed; no rollback. Inspect via UART0 before retrying" + : "HTTPS identity replacement rejected or failed before publication", + esp_err_to_name(error)); return 1; } - error = web_server_start(); - if (error != ESP_OK) { - printf("Material changed, but HTTPS could not restart: %s\n", - esp_err_to_name(error)); - return 1; - } - return 0; -} - -static int rotate_certificate(void) -{ - web_server_snapshot_t snapshot; - esp_err_t error = web_server_get_snapshot(&snapshot); - if (error != ESP_OK) { - printf("Could not inspect HTTPS runtime: %s\n", esp_err_to_name(error)); - return 1; - } - error = web_security_rotate_certificate(); - if (error != ESP_OK) { - printf("Could not rotate web certificate: %s\n", esp_err_to_name(error)); - return 1; - } - printf("Web certificate and private key rotated and persisted.\n"); - return restart_if_running(snapshot.running); -} - -static int reset_material(void) -{ - web_server_snapshot_t snapshot; - bool was_running = web_server_get_snapshot(&snapshot) == ESP_OK && snapshot.running; - esp_err_t error = web_security_reset_all(); - if (error != ESP_OK) { - printf("Could not reset web security material: %s\n", esp_err_to_name(error)); - return 1; - } - printf("HTTPS certificate and private key replaced and persisted; user accounts unchanged.\n"); - if (was_running) { - return restart_if_running(true); - } - - error = web_server_start(); - if (error != ESP_OK) { - printf("Security material recovered, but HTTPS could not start: %s\n", - esp_err_to_name(error)); - return 1; - } - printf("HTTPS started with the recovered security material.\n"); + printf("Verify the new fingerprint via trusted UART0, renew browser trust, and sign in again.\n"); return 0; } +static int rotate_certificate(void) { return replace_material(false); } +static int reset_material(void) { return replace_material(true); } + static void print_performance_time(const char *name, const web_serial_performance_timing_t *t) { printf(" %s: count=%" PRIu64 " sum_us=%" PRIu64 " avg_us_est=%" PRIu64 " max_us=%" PRIu64 "\n", @@ -417,7 +374,7 @@ static int command_web(int argc, char **argv) printf("Could not schedule HTTPS certificate rotation: %s\n", esp_err_to_name(error)); return 1; } - printf("HTTPS certificate rotation scheduled after console output drains; both browser connections will close. Reconnect and verify the new certificate. If restart fails, use UART0 or SSH recovery.\n"); + printf("HTTPS identity rotation scheduled after console output drains; all web logins and browser terminals will close. A new identity may persist even if stop/start fails; no rollback. Verify the new fingerprint via trusted UART0 web certificate info before renewing browser trust, then reload and sign in. SSH and USB UART1 access remain independent.\n"); return 0; } return rotate_certificate(); diff --git a/src/web_lifecycle_settings.c b/src/web_lifecycle_settings.c index ece7e63..4098de8 100644 --- a/src/web_lifecycle_settings.c +++ b/src/web_lifecycle_settings.c @@ -11,6 +11,7 @@ #include "web_cookie_auth.h" #include "web_httpd_adapter.h" #include "web_server.h" +#include "web_security.h" #if CONFIG_HTTPD_QUEUE_WORK_BLOCKING #error "Lifecycle ACK handoff requires nonblocking HTTPD work submission" @@ -18,9 +19,9 @@ enum { IDLE, PENDING, EXECUTING, OK, FAILED, CANCELLED }; static const char *const s_states[] = {"idle", "pending", "pending", "ok", "failed", "cancelled"}; -static const char *const s_actions[] = {"stop", "restart", "reboot"}; +static const char *const s_actions[] = {"stop", "restart", "reboot", "rotate"}; typedef struct { - uint32_t id, generation; + uint32_t id, generation, identity_generation; web_session_id_t session; user_principal_t principal; int64_t ack_deadline, deadline; @@ -46,33 +47,34 @@ static void expire_locked(int64_t now) cancel_locked(); } -/* Exactly action + generation; no escapes, duplicates, coercions or extra fields. */ +/* Exactly action + service generation, plus identity generation only for rotate. + * No escapes, duplicates, coercions or extra fields. */ static bool parse(const char *body, size_t length, lifecycle_operation_t *operation) { - const char *keys[] = {"action", "generation"}; + const char *keys[] = {"action", "generation", "identity_generation"}; 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 < 2; ++field) { + for (unsigned field = 0; field < 3; ++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 < 2; ++key) + for (; key < 3; ++key) if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break; - if (key == 2 || (seen & (1U << key))) return false; + if (key == 3 || (seen & (1U << key))) return false; ++pos; TAKE(':'); SPACE(); if (key == 0) { TAKE('"'); start = pos; while (pos < length && body[pos] != '"') ++pos; if (pos == length) return false; unsigned action = 0; - for (; action < 3; ++action) + for (; action < 4; ++action) if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break; - if (action == 3) return false; + if (action == 4) return false; operation->action = action; ++pos; } else { uint32_t number = 0; start = pos; @@ -82,14 +84,19 @@ static bool parse(const char *body, size_t length, lifecycle_operation_t *operat number = number * 10U + digit; } if (pos == start || (pos - start > 1 && body[start] == '0')) return false; - operation->generation = number; + if (key == 1) operation->generation = number; + else operation->identity_generation = number; } seen |= 1U << key; + SPACE(); + if (pos < length && body[pos] == '}') break; } TAKE('}'); SPACE(); #undef TAKE #undef SPACE - return pos == length && seen == 3 && operation->generation && operation->generation != UINT32_MAX; + return pos == length && seen == (operation->action == 3 ? 7U : 3U) && + operation->generation && operation->generation != UINT32_MAX && + (operation->action != 3 || (operation->identity_generation && operation->identity_generation != UINT32_MAX)); } /* Runs on HTTPD after its synchronous response handler returns. No socket IO, @@ -143,9 +150,11 @@ void web_lifecycle_settings_execute(uint32_t id) unsigned state = CANCELLED; if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN && esp_timer_get_time() < operation.deadline) { + bool committed = false; error = operation.action == 0 ? web_server_stop_current(operation.generation) : operation.action == 1 ? web_server_restart_current(operation.generation) : - web_server_reboot_current(operation.generation); + operation.action == 2 ? web_server_reboot_current(operation.generation) : + web_server_replace_identity(operation.generation, operation.identity_generation, false, &committed); /* Even INVALID_STATE can be a detach failure after stop admission. */ state = error == ESP_OK ? OK : FAILED; } @@ -250,11 +259,22 @@ esp_err_t web_lifecycle_settings_handler(httpd_req_t *request) if (web_server_get_management_snapshot(&snapshot) != ESP_OK) { error = respond(request, "503 Service Unavailable", "{\"error\":\"lifecycle_unavailable\"}"); goto done; } - char response[128]; + web_security_identity_snapshot_t identity = {0}; + bool available = web_security_get_identity_snapshot(&identity) == ESP_OK; + char fingerprint[65] = {0}; + if (available) { + for (size_t i = 0; i < sizeof(identity.fingerprint); ++i) + snprintf(fingerprint + i * 2, 3, "%02x", identity.fingerprint[i]); + } + char response[320]; int written = snprintf(response, sizeof(response), - "{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,\"controllable\":%s}", + "{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,\"controllable\":%s," + "\"identity_generation\":%" PRIu32 ",\"fingerprint\":\"%s\",\"rotatable\":%s}", snapshot.generation, snapshot.running ? "true" : "false", - snapshot.transitioning ? "true" : "false", snapshot.controllable ? "true" : "false"); + snapshot.transitioning ? "true" : "false", snapshot.controllable ? "true" : "false", + available ? identity.generation : 0, fingerprint, + available && snapshot.controllable && !identity.busy && identity.generation && + identity.generation != UINT32_MAX ? "true" : "false"); error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response); done: secure_wipe(&view, sizeof(view)); diff --git a/src/web_security.c b/src/web_security.c index dea4268..a0a24e8 100644 --- a/src/web_security.c +++ b/src/web_security.c @@ -51,6 +51,8 @@ _Static_assert(sizeof(web_security_blob_t) == WEB_SECURITY_BLOB_SIZE, static SemaphoreHandle_t s_security_mutex; static web_security_blob_t s_material; static bool s_material_ready; +static uint32_t s_identity_token, s_next_identity_token; +static bool s_identity_used; static web_security_load_result_t s_load_result; static bool bytes_are_zero(const uint8_t *data, size_t size) @@ -649,6 +651,10 @@ esp_err_t web_security_init(web_security_load_result_t *load_result) } xSemaphoreTake(s_security_mutex, portMAX_DELAY); + if (s_identity_token) { + xSemaphoreGive(s_security_mutex); + return ESP_ERR_INVALID_STATE; + } if (s_material_ready) { if (load_result != NULL) { *load_result = s_load_result; @@ -763,14 +769,6 @@ esp_err_t web_security_get_certificate_metadata( return error; } -static esp_err_t increment_generation(web_security_blob_t *blob) -{ - if (blob->generation == UINT32_MAX) { - return ESP_ERR_INVALID_STATE; - } - ++blob->generation; - return ESP_OK; -} static void install_committed_blob(const web_security_blob_t *candidate) { @@ -781,64 +779,90 @@ static void install_committed_blob(const web_security_blob_t *candidate) s_load_result = WEB_SECURITY_LOAD_STORED; } -esp_err_t web_security_rotate_certificate(void) +esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *snapshot) { - if (s_security_mutex == NULL) { + if (!snapshot) return ESP_ERR_INVALID_ARG; + memset(snapshot, 0, sizeof(*snapshot)); + if (!s_security_mutex) return ESP_ERR_INVALID_STATE; + if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT; + esp_err_t error = s_material_ready ? ESP_OK : ESP_ERR_INVALID_STATE; + if (error == ESP_OK) { + snapshot->generation = s_material.generation; + memcpy(snapshot->fingerprint, s_material.certificate_fingerprint, sizeof(snapshot->fingerprint)); + snapshot->busy = s_identity_token != 0 || s_next_identity_token == UINT32_MAX; + } + xSemaphoreGive(s_security_mutex); + return error; +} + +esp_err_t web_security_reserve_identity(uint32_t expected_generation, bool reset, uint32_t *token) +{ + if (!token || (reset && expected_generation)) return ESP_ERR_INVALID_ARG; + *token = 0; + if (!reset) { + if (!s_security_mutex) return ESP_ERR_INVALID_STATE; + if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT; + bool ready = s_material_ready; + xSemaphoreGive(s_security_mutex); + if (!ready) return ESP_ERR_INVALID_STATE; + } + esp_err_t error = secure_random_init(); + if (error == ESP_OK) error = ensure_security_mutex(); + if (error != ESP_OK) return error; + if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT; + if (s_identity_token || s_next_identity_token == UINT32_MAX || + (!s_material_ready && !reset) || + (s_material_ready && s_material.generation == UINT32_MAX) || + (expected_generation && expected_generation != s_material.generation)) { + xSemaphoreGive(s_security_mutex); return ESP_ERR_INVALID_STATE; } + *token = s_identity_token = ++s_next_identity_token; - xSemaphoreTake(s_security_mutex, portMAX_DELAY); - esp_err_t error = ESP_ERR_INVALID_STATE; - web_security_blob_t candidate; - memset(&candidate, 0, sizeof(candidate)); - if (s_material_ready) { - candidate = s_material; - error = increment_generation(&candidate); - if (error == ESP_OK) { - error = generate_certificate(&candidate); - } - if (error == ESP_OK) { - error = save_blob(&candidate); - } - if (error == ESP_OK) { - install_committed_blob(&candidate); - } - } - secure_wipe(&candidate, sizeof(candidate)); + s_identity_used = false; xSemaphoreGive(s_security_mutex); - return error; + return ESP_OK; } -esp_err_t web_security_reset_all(void) +esp_err_t web_security_replace_reserved(uint32_t token) { - esp_err_t error = secure_random_init(); - if (error != ESP_OK) { - return error; - } - error = ensure_security_mutex(); - if (error != ESP_OK) { - return error; - } - + if (!s_security_mutex || !token) return ESP_ERR_INVALID_STATE; xSemaphoreTake(s_security_mutex, portMAX_DELAY); - web_security_blob_t candidate; - uint32_t generation = 1U; - if (s_material_ready) { - if (s_material.generation == UINT32_MAX) { - xSemaphoreGive(s_security_mutex); - return ESP_ERR_INVALID_STATE; - } - generation = s_material.generation + 1U; + if (s_identity_token != token || s_identity_used) { + xSemaphoreGive(s_security_mutex); + return ESP_ERR_INVALID_STATE; } - - error = generate_all(&candidate, generation); - if (error == ESP_OK) { - error = save_blob(&candidate); - } - if (error == ESP_OK) { - install_committed_blob(&candidate); - } - secure_wipe(&candidate, sizeof(candidate)); + s_identity_used = true; + uint32_t generation = s_material_ready ? s_material.generation + 1U : 1U; xSemaphoreGive(s_security_mutex); + + /* The reservation, not a held mutex/spinlock, excludes all identity writers. */ + web_security_blob_t candidate = {0}; + esp_err_t error = generate_all(&candidate, generation); + if (error == ESP_OK) error = save_blob(&candidate); + xSemaphoreTake(s_security_mutex, portMAX_DELAY); + if (error == ESP_OK) install_committed_blob(&candidate); + xSemaphoreGive(s_security_mutex); + secure_wipe(&candidate, sizeof(candidate)); return error; } + +void web_security_release_identity(uint32_t token) +{ + if (!s_security_mutex || !token) return; + xSemaphoreTake(s_security_mutex, portMAX_DELAY); + if (s_identity_token == token) s_identity_token = 0; + xSemaphoreGive(s_security_mutex); +} + +static esp_err_t replace_identity(bool reset) +{ + uint32_t token = 0; + esp_err_t error = web_security_reserve_identity(0, reset, &token); + if (error == ESP_OK) error = web_security_replace_reserved(token); + web_security_release_identity(token); + return error; +} + +esp_err_t web_security_rotate_certificate(void) { return replace_identity(false); } +esp_err_t web_security_reset_all(void) { return replace_identity(true); } diff --git a/src/web_security.h b/src/web_security.h index c889de8..688451f 100644 --- a/src/web_security.h +++ b/src/web_security.h @@ -70,6 +70,23 @@ esp_err_t web_security_copy_tls_material( esp_err_t web_security_get_certificate_metadata( web_security_certificate_metadata_t *metadata); +typedef struct { + uint32_t generation; + uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH]; + bool busy; +} web_security_identity_snapshot_t; + +/* Zero-wait public metadata only; never returns DER or private material. */ +esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *snapshot); + +/* Internal owner transaction shared with canonical mutations. Tokens never reuse. + * Reserve before crypto; retain through service restart. No lock stays held. + * Zero expected_generation selects canonical CLI semantics; reset permits recovery. + * Only the reserving owner may replace once and release its token. */ +esp_err_t web_security_reserve_identity(uint32_t expected_generation, bool reset, uint32_t *token); +esp_err_t web_security_replace_reserved(uint32_t token); +void web_security_release_identity(uint32_t token); + /* Mutations become visible only after a complete blob has committed to NVS. */ esp_err_t web_security_rotate_certificate(void); diff --git a/src/web_server.c b/src/web_server.c index 4630068..35a9a00 100644 --- a/src/web_server.c +++ b/src/web_server.c @@ -796,7 +796,7 @@ esp_err_t web_server_start(void) return error == ESP_OK ? start_server(false) : error; } -static esp_err_t stop_server(uint32_t expected_generation, bool restart) +static esp_err_t stop_server(uint32_t expected_generation, bool restart, bool reserved) { if (s_server_mutex == NULL) { return ESP_ERR_INVALID_STATE; @@ -804,7 +804,7 @@ static esp_err_t stop_server(uint32_t expected_generation, bool restart) if (xSemaphoreTake(s_server_mutex, expected_generation ? 0U : portMAX_DELAY) != pdTRUE) return ESP_ERR_TIMEOUT; - if (s_server == NULL || s_transitioning || + if (s_server == NULL || s_transitioning != reserved || (expected_generation && (expected_generation != s_generation || s_generation == UINT32_MAX || s_last_error != ESP_OK))) { xSemaphoreGive(s_server_mutex); @@ -879,21 +879,69 @@ static esp_err_t stop_server(uint32_t expected_generation, bool restart) return error == ESP_OK && restart ? start_server(true) : error; } +esp_err_t web_server_replace_identity(uint32_t expected_service_generation, + uint32_t expected_identity_generation, bool reset, bool *committed) +{ + if (!committed || (!!expected_service_generation != !!expected_identity_generation) || + (reset && expected_service_generation)) return ESP_ERR_INVALID_ARG; + *committed = false; + /* Conditional dispatcher admission must not wait in the legacy initializer. */ + esp_err_t error = expected_service_generation + ? (s_server_mutex ? ESP_OK : ESP_ERR_INVALID_STATE) : web_server_init(); + if (error != ESP_OK) return error; + if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT; + if (s_transitioning || (expected_service_generation && + (!s_server || s_last_error != ESP_OK || s_generation == UINT32_MAX || + expected_service_generation != s_generation))) { + xSemaphoreGive(s_server_mutex); + return ESP_ERR_INVALID_STATE; + } + bool running = s_server != NULL; + s_transitioning = true; + xSemaphoreGive(s_server_mutex); + + uint32_t token = 0; + error = web_security_reserve_identity(expected_identity_generation, reset, &token); + if (error == ESP_OK) { + xSemaphoreTake(s_server_mutex, portMAX_DELAY); + if (s_generation != UINT32_MAX) ++s_generation; + xSemaphoreGive(s_server_mutex); + error = web_security_replace_reserved(token); + } + if (error == ESP_OK) { + *committed = true; + if (running) error = stop_server(0, true, true); + else if (reset) error = start_server(true); + else { + xSemaphoreTake(s_server_mutex, portMAX_DELAY); + s_transitioning = false; + xSemaphoreGive(s_server_mutex); + } + } else { + /* No identity publication: leave HTTPD and its logins untouched. */ + xSemaphoreTake(s_server_mutex, portMAX_DELAY); + s_transitioning = false; + xSemaphoreGive(s_server_mutex); + } + web_security_release_identity(token); + return error; +} + esp_err_t web_server_stop(void) { - return stop_server(0U, false); + return stop_server(0U, false, false); } esp_err_t web_server_stop_current(uint32_t expected_generation) { if (!expected_generation) return ESP_ERR_INVALID_ARG; - return stop_server(expected_generation, false); + return stop_server(expected_generation, false, false); } esp_err_t web_server_restart_current(uint32_t expected_generation) { if (!expected_generation) return ESP_ERR_INVALID_ARG; - return stop_server(expected_generation, true); + return stop_server(expected_generation, true, false); } esp_err_t web_server_reboot_current(uint32_t expected_generation) diff --git a/src/web_server.h b/src/web_server.h index a653ba1..f34cd6c 100644 --- a/src/web_server.h +++ b/src/web_server.h @@ -62,6 +62,14 @@ esp_err_t web_server_restart_current(uint32_t expected_generation); * Admission cannot be cancelled; normally does not return. Same caller rules. */ esp_err_t web_server_reboot_current(uint32_t expected_generation); +/* Combined identity/service owner operation, off HTTPD only. Nonzero expected + * generations select healthy running conditional rotation; both zero select CLI. + * reset is CLI-only and starts a stopped service; ordinary rotation leaves it stopped. + * committed reports irreversible NVS publication even when stop/start later fails. + * Reservation covers generation checks, crypto/commit and canonical stop/start. */ +esp_err_t web_server_replace_identity(uint32_t expected_service_generation, + uint32_t expected_identity_generation, bool reset, bool *committed); + /* Start one TLS-only server on all active network interfaces. * Start/stop may wait for HTTPD; never call from its task or queued callbacks. */ esp_err_t web_server_start(void); diff --git a/src/web_ui.c b/src/web_ui.c index 528a01b..e18eb0c 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -191,12 +191,14 @@ static const char s_index_html[] = "" "" "" - "