Add HTTPS identity rotation support

This commit is contained in:
2026-09-13 18:21:37 +02:00
parent 36e80811e8
commit aa4bbc2c8c
24 changed files with 826 additions and 170 deletions
+8
View File
@@ -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.
+2
View File
@@ -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`.
+2
View File
@@ -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.
+4
View File
@@ -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 plan600800-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.
+2
View File
@@ -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.
+233
View File
@@ -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/<suite>/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.
+1 -1
View File
@@ -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.
+2 -5
View File
@@ -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) {
+11 -54
View File
@@ -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",
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();
+35 -15
View File
@@ -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));
+76 -52
View File
@@ -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) {
return ESP_ERR_INVALID_STATE;
}
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 (!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) {
error = generate_certificate(&candidate);
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;
}
if (error == ESP_OK) {
error = save_blob(&candidate);
}
if (error == ESP_OK) {
install_committed_blob(&candidate);
}
}
secure_wipe(&candidate, sizeof(candidate));
xSemaphoreGive(s_security_mutex);
return error;
}
esp_err_t web_security_reset_all(void)
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) {
return error;
}
error = ensure_security_mutex();
if (error != ESP_OK) {
return error;
}
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) {
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;
}
generation = s_material.generation + 1U;
*token = s_identity_token = ++s_next_identity_token;
s_identity_used = false;
xSemaphoreGive(s_security_mutex);
return ESP_OK;
}
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));
esp_err_t web_security_replace_reserved(uint32_t token)
{
if (!s_security_mutex || !token) return ESP_ERR_INVALID_STATE;
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (s_identity_token != token || s_identity_used) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
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); }
+17
View File
@@ -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);
+53 -5
View File
@@ -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)
+8
View File
@@ -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);
+15 -10
View File
@@ -191,12 +191,14 @@ static const char s_index_html[] =
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button><button id=\"settings-broker\" class=\"button\" type=\"button\" aria-pressed=\"false\">Broker</button><button id=\"settings-ssh\" class=\"button\" type=\"button\" aria-pressed=\"false\">SSH</button><button id=\"settings-lifecycle\" class=\"button\" type=\"button\" aria-pressed=\"false\">HTTPS / Reboot</button></div>"
"<div id=\"lifecycle-settings\" hidden><h2>HTTPS service and device reboot</h2>"
"<div id=\"lifecycle-settings\" hidden><h2>HTTPS identity, service and device reboot</h2>"
"<p class=\"connection-detail\">Stop/Restart HTTPS closes ALL web logins and both browser terminal routes, including clients admitted before execution. Settings and certificate identity are unchanged; HTTPS restart preserves device working configuration. Save unsaved browser drafts first. Recover a stopped web service with <code>web start</code> through UART0 or still-running, reachable admin SSH. USB remains UART1 serial access, not a web administration console.</p>"
"<p class=\"connection-detail\">Reboot interrupts ALL clients and the entire device, including SSH, USB and UART operation during restart. Unsaved RAM-only working configuration and browser drafts can be lost. Saved configuration and identities are not reset. After boot, restore network reachability, reload and sign in explicitly; inspect the outcome before another action.</p>"
"<p class=\"connection-detail\">Rotate replaces and persists the HTTPS certificate AND private key, changes browser trust, and disconnects all web logins/terminals. No SSH identity or user/configuration change. Verify the NEW SHA-256 certificate fingerprint using trusted UART0 (<code>web certificate info</code>) before accepting browser trust; a certificate warning is not verification. Reload and sign in freshly. Native USB remains independent UART1 serial access, not administration. No browser TLS reset/recovery or key/certificate export.</p>"
"<p id=\"lifecycle-identity\" class=\"connection-detail\"></p>"
"<button id=\"lifecycle-network\" class=\"button\" type=\"button\">Open existing Network / Wi-Fi controls</button>"
"<button id=\"lifecycle-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"lifecycle-detail\" class=\"connection-detail\" role=\"status\"></p>"
"<div class=\"serial-actions\"><button id=\"lifecycle-stop\" class=\"button\" type=\"button\">Stop HTTPS…</button><button id=\"lifecycle-restart\" class=\"button\" type=\"button\">Restart HTTPS…</button><button id=\"lifecycle-reboot\" class=\"button\" type=\"button\">Reboot device…</button><button id=\"lifecycle-result\" class=\"button\" type=\"button\">Check Operation Result</button></div>"
"<div class=\"serial-actions\"><button id=\"lifecycle-stop\" class=\"button\" type=\"button\">Stop HTTPS…</button><button id=\"lifecycle-restart\" class=\"button\" type=\"button\">Restart HTTPS…</button><button id=\"lifecycle-reboot\" class=\"button\" type=\"button\">Reboot device…</button><button id=\"lifecycle-rotate\" class=\"button\" type=\"button\">Rotate HTTPS identity…</button><button id=\"lifecycle-result\" class=\"button\" type=\"button\">Check Operation Result</button></div>"
"<p id=\"lifecycle-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Acknowledgement is not peer receipt or completion. Connection loss, expiry, revocation or timeout does not prove cancellation after admission. No automatic mutation retry or restore. Check Result, inspect state, then act explicitly.</p><a href=\"/\">Reload / sign in after recovery</a></div>\n"
"<div id=\"ssh-settings\" hidden><h2>SSH service and sessions</h2><p class=\"connection-detail\">SSH only, TCP port 22. Start/Stop do not change saved settings or host identity. Stop closes all SSH sessions, including any admitted before execution; an SSH administrator's already executing command may finish. HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped. Targeted disconnect affects only the selected SSH connection, not all logins for its account. Viewing or selecting never changes services or writer ownership.</p><button id=\"ssh-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"ssh-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"ssh-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Disconnect SSH session<select id=\"ssh-target\"><option value=\"\">Select a session</option><option id=\"ssh-option-0\" hidden disabled></option><option id=\"ssh-option-1\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"ssh-start\" class=\"button\" type=\"button\">Start SSH…</button><button id=\"ssh-stop\" class=\"button\" type=\"button\">Stop SSH…</button><button id=\"ssh-disconnect\" class=\"button\" type=\"button\">Disconnect selected…</button><button id=\"ssh-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"ssh-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. After submission use Check Operation Result, then Refresh. Navigation or timeout does not cancel admitted work. No automatic mutation retry.</p></div>\n"
"<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh retains explicit selection without renewing its lease token. Stale selections require choosing the blank option then the target again. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</p></div>\n"
@@ -893,17 +895,18 @@ static const char s_app_js[] =
" if (brokerSelection) brokerDetail.textContent = 'Explicit selection: ' + brokerLabel(client) + '. Confirm assignment separately; refresh never renews this lease token.';\n"
" brokerButtons();\n"
"});\n"
"const lifecycleActions = ['stop','restart','reboot'];\n"
"const lifecycleActions = ['stop','restart','reboot','rotate'];\n"
"let lifecycleSnapshot = null, lifecycleAbort = null, lifecyclePending = false, lifecycleAwaitingAck = false, lifecycleId = 0, lifecycleAction = '';\n"
"const lifecycleRecovery = 'Outcome may be unknown; no automatic retry. HTTPS stop: use UART0 or reachable admin SSH web start. HTTPS restart expires this login; reload and sign in again. Reboot interrupts every client, including USB; restore network after boot, reload/sign in and inspect before acting again. A stuck ACK handoff requires canonical web stop then web start; this closes all web clients.';\n"
"const lifecycleRecovery = 'Outcome may be unknown; no automatic retry. HTTPS stop: use UART0 or reachable admin SSH web start. HTTPS restart expires this login; reload and sign in again. Reboot interrupts every client, including USB; restore network after boot, reload/sign in and inspect before acting again. Rotation may have persisted a NEW identity even when stop/start fails; no rollback. The stored fingerprint may differ from a retained old server certificate. Inspect with trusted UART0 web certificate info, verify the new fingerprint before renewing trust, then reload and sign in freshly. SSH and native USB UART1 access are not stopped by rotation. A stuck ACK handoff requires canonical web stop then web start; this closes all web clients.';\n"
"function lifecycleButtons() {\n"
" const busy = !!lifecycleAbort;\n"
" element('lifecycle-refresh').disabled = element('lifecycle-result').disabled = busy;\n"
" for (const action of lifecycleActions) element('lifecycle-' + action).disabled = busy || lifecyclePending || !lifecycleSnapshot?.controllable;\n"
" for (const action of lifecycleActions) element('lifecycle-' + action).disabled = busy || lifecyclePending || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable);\n"
"}\n"
"function clearLifecycle() {\n"
" if (lifecycleAbort) lifecycleAbort.abort(); lifecycleAbort = null; lifecycleSnapshot = null;\n"
" element('lifecycle-detail').textContent = 'Select Refresh to inspect HTTPS state.';\n"
" element('lifecycle-identity').textContent = '';\n"
" if (lifecyclePending) element('lifecycle-operation-detail').textContent = lifecycleRecovery + ' Navigation does not cancel admitted work.';\n"
" lifecycleButtons();\n"
"}\n"
@@ -911,9 +914,10 @@ static const char s_app_js[] =
" if (settingsDomain !== 'lifecycle' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || lifecycleAbort || (action && lifecyclePending)) return;\n"
" let body; const detail = element(snapshotRead ? 'lifecycle-detail' : 'lifecycle-operation-detail');\n"
" if (action) {\n"
" if (!lifecycleActions.includes(action) || !lifecycleSnapshot?.controllable) return;\n"
" if (!lifecycleActions.includes(action) || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable)) return;\n"
" const value = {action, generation:lifecycleSnapshot.generation};\n"
" const scope = action === 'reboot' ? 'Reboot the ENTIRE device? ALL clients disconnect; SSH, USB and UART operation are interrupted during restart. Unsaved RAM-only working configuration and browser drafts may be lost.' : (action === 'stop' ? 'Stop HTTPS?' : 'Restart HTTPS?') + ' ALL web logins and BOTH browser terminal routes disconnect, including clients admitted before execution. Device working configuration and identity are unchanged; save unsaved browser drafts first. SSH, USB and UART0 are not stopped.';\n"
" if (action === 'rotate') value.identity_generation = lifecycleSnapshot.identity_generation;\n"
" const scope = action === 'rotate' ? 'Rotate and persist the HTTPS certificate and private key? CURRENT stored SHA-256 fingerprint: ' + lifecycleSnapshot.fingerprint + '. Identity generation ' + value.identity_generation + ', service generation ' + value.generation + '. ALL web logins and BOTH browser terminals disconnect. Save browser drafts first. Browser trust changes; SSH identity and device settings remain unchanged.' : action === 'reboot' ? 'Reboot the ENTIRE device? ALL clients disconnect; SSH, USB and UART operation are interrupted during restart. Unsaved RAM-only working configuration and browser drafts may be lost.' : (action === 'stop' ? 'Stop HTTPS?' : 'Restart HTTPS?') + ' ALL web logins and BOTH browser terminal routes disconnect, including clients admitted before execution. Device working configuration and identity are unchanged; save unsaved browser drafts first. SSH, USB and UART0 are not stopped.';\n"
" if (!window.confirm(scope + ' ' + lifecycleRecovery)) return;\n"
" body = JSON.stringify(value);\n"
" }\n"
@@ -925,17 +929,18 @@ static const char s_app_js[] =
" try {\n"
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
" if (action) { lifecyclePending = true; lifecycleAwaitingAck = true; lifecycleId = 0; lifecycleAction = action; lifecycleSnapshot = null; lifecycleButtons(); }\n"
" const {status, payload:v} = await api(snapshotRead ? '/api/settings/lifecycle' : '/api/settings/lifecycle-operation', generation, {method:action ? 'POST' : 'GET', body, signal:controller.signal, limit:snapshotRead ? 128 : 96, current});\n"
" const {status, payload:v} = await api(snapshotRead ? '/api/settings/lifecycle' : '/api/settings/lifecycle-operation', generation, {method:action ? 'POST' : 'GET', body, signal:controller.signal, limit:snapshotRead ? 320 : 96, current});\n"
" if (snapshotRead) {\n"
" if (status !== 200 || !v || Object.keys(v).length !== 4 || !brokerUint(v.generation) || !v.generation || typeof v.running !== 'boolean' || typeof v.transitioning !== 'boolean' || typeof v.controllable !== 'boolean' || (v.controllable && (!v.running || v.transitioning || v.generation === 4294967295))) throw new Error('Invalid lifecycle snapshot');\n"
" if (status !== 200 || !v || Object.keys(v).length !== 7 || !brokerUint(v.identity_generation) || typeof v.fingerprint !== 'string' || !(v.identity_generation ? /^[0-9a-f]{64}$/.test(v.fingerprint) : v.fingerprint === '') || typeof v.rotatable !== 'boolean' || (v.rotatable && (!v.controllable || !v.identity_generation || v.identity_generation === 4294967295)) || !brokerUint(v.generation) || !v.generation || typeof v.running !== 'boolean' || typeof v.transitioning !== 'boolean' || typeof v.controllable !== 'boolean' || (v.controllable && (!v.running || v.transitioning || v.generation === 4294967295))) throw new Error('Invalid lifecycle snapshot');\n"
" lifecycleSnapshot = v;\n"
" element('lifecycle-identity').textContent = v.identity_generation ? 'Stored HTTPS identity generation ' + v.identity_generation + '; service generation ' + v.generation + '; SHA-256 certificate fingerprint: ' + v.fingerprint + '. Public metadata, not proof of the currently served certificate or trusted verification.' : 'HTTPS identity metadata unavailable; rotation disabled. Use UART0 recovery.';\n"
" detail.textContent = (v.running ? 'HTTPS running. ' : 'HTTPS stopped. ') + (v.controllable ? 'Explicit confirmation required.' : 'Transition, failed cleanup or exhausted generation: use UART0/admin SSH recovery.');\n"
" } else {\n"
" if (status !== (action ? 202 : 200) || !v || Object.keys(v).length !== 3 || !brokerUint(v.id) || !['none',...lifecycleActions].includes(v.action) || !['idle','pending','ok','failed','cancelled'].includes(v.state) || ((v.id === 0) !== (v.state === 'idle')) || ((v.id === 0) !== (v.action === 'none')) || (action && (!v.id || v.action !== action || v.state !== 'pending'))) throw new Error('Invalid lifecycle result');\n"
" const matched = !!action || (!lifecycleAwaitingAck && lifecycleId === v.id && lifecycleAction === v.action);\n"
" if (!matched && lifecyclePending) { detail.textContent = 'Result cannot be matched to this submission (lost ACK or replaced result). ' + lifecycleRecovery; return; }\n"
" lifecycleId = v.id; lifecycleAction = v.action; lifecyclePending = v.state === 'pending'; lifecycleAwaitingAck = false; lifecycleSnapshot = null;\n"
" const messages = {idle:'No retained result. Inspect before any new action.',pending:'ACK handoff, queued or executing; do not resubmit. Check Result explicitly.',ok:'Completed at execution time; not proof of peer receipt.',failed:'Lifecycle failed or admission rejected; changes may already have occurred. Inspect before retrying.',cancelled:'Not executed: ACK/queue handoff, deadline or original login/currentness rejected before lifecycle admission.'};\n"
" const messages = {idle:'No retained result. Inspect before any new action.',pending:'ACK handoff, queued or executing; do not resubmit. Check Result explicitly.',ok:'Completed at execution time; not proof of peer receipt.',failed:'Operation failed or admission rejected; identity may already be persisted even if stop/start failed. No rollback. Inspect via UART0 before retrying.',cancelled:'Not executed: ACK/queue handoff, deadline or original login/currentness rejected before lifecycle admission.'};\n"
" detail.textContent = v.action + ': ' + messages[v.state] + ' ' + lifecycleRecovery;\n"
" element('lifecycle-detail').textContent = 'Snapshot stale. Refresh to inspect state; this never repeats a mutation.';\n"
" }\n"
+7
View File
@@ -17,6 +17,13 @@ static esp_err_t web_server_start(void) {
}
static void esp_restart(void) { OUTSIDE(); assert(!httpd_owner); ++reboots; }
esp_err_t web_server_stop(void) { OUTSIDE(); assert(!httpd_owner); ++web_stops; return web_stop_result; }
static esp_err_t web_server_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
assert(!service && !identity && !reset); *committed = false;
esp_err_t error = web_security_rotate_certificate();
if (error != ESP_OK) return error;
*committed = true; error = web_server_stop();
return error == ESP_OK ? web_server_start() : error;
}
static bool ticket_live, upgrade_requested, revoke_on_open, revoke_on_send;
static unsigned upgrades, closes, sends, queues, wipes, checks, receive_headers;
static size_t feed_limit, fed_length, output_length;
+57 -1
View File
@@ -32,11 +32,12 @@ static void pipeline_reset(void) {
memset(&s_operation, 0, sizeof(s_operation)); s_ack_id = 0; s_ack_server = NULL;
pipeline_now = 0; pipeline_current = true; pipeline_queue_fail = false;
pipeline_reboots = pipeline_submits = 0; pipeline_id = 0; validation_hook = NULL;
identity_generation = 11; identity_token = 0; identity_replacements = 0; identity_error = ESP_OK; identity_hook = NULL;
reset(); start();
}
static uint32_t pipeline_admit(unsigned action) {
assert(s_operation.state != PENDING && s_operation.state != EXECUTING && !s_ack_id);
s_operation = (lifecycle_operation_t){.id=++s_next_id, .generation=s_generation,
s_operation = (lifecycle_operation_t){.id=++s_next_id, .generation=s_generation, .identity_generation=identity_generation,
.session=1, .principal={USER_ROLE_ADMIN}, .ack_deadline=pipeline_now+2000000,
.deadline=pipeline_now+30000000, .action=action, .state=PENDING};
s_ack_id = s_operation.id; s_ack_server = SERVER;
@@ -50,7 +51,62 @@ static void pipeline_callback(uint32_t id) {
static void validation_aba(void) {
assert(web_server_stop() == ESP_OK); fresh_registration(); start();
}
static void identity_interleave(void) {
assert(!locked && s_transitioning && identity_token);
bool committed = true;
assert(web_server_replace_identity(0, 0, false, &committed) == ESP_ERR_INVALID_STATE && !committed);
assert(web_server_replace_identity(0, 0, true, &committed) == ESP_ERR_INVALID_STATE && !committed);
assert(web_server_start() == ESP_ERR_INVALID_STATE && web_server_stop() == ESP_ERR_INVALID_STATE);
assert(web_server_restart_current(s_generation) == ESP_ERR_INVALID_STATE);
assert(web_server_reboot_current(s_generation) == ESP_ERR_INVALID_STATE);
}
static void pipeline_tests(void) {
for (unsigned mode = 0; mode < 8; ++mode) {
pipeline_reset(); uint32_t id = pipeline_admit(3);
unsigned before_starts = ssl_starts;
if (mode == 0) ++s_generation;
if (mode == 1) ++identity_generation;
if (mode == 2) s_generation = UINT32_MAX;
if (mode == 3) identity_generation = UINT32_MAX;
if (mode == 4) s_last_error = ESP_FAIL;
if (mode == 5) identity_token = 99;
if (mode == 6) pipeline_current = false;
if (mode == 7) pipeline_now = 2000000;
pipeline_callback(id); web_lifecycle_settings_execute(id);
assert(!identity_replacements && !ssl_stops && ssl_starts == before_starts);
assert(s_operation.state == (mode >= 6 ? CANCELLED : FAILED));
}
puts("PASS HTTPS rotation original-login/ACK expiry and stale identity/service/saturation/CLI reservation reject before identity mutation");
for (unsigned failure = 0; failure < 7; ++failure) {
pipeline_reset(); uint32_t id = pipeline_admit(3), generation = s_generation;
identity_hook = identity_interleave;
if (failure == 1) identity_error = ESP_FAIL;
if (failure == 2) idle_detach_error = ESP_ERR_TIMEOUT;
if (failure == 3) admin_detach_error = ESP_ERR_TIMEOUT;
if (failure == 4) serial_detach_error = ESP_ERR_INVALID_STATE;
if (failure == 5) ssl_stop_error = ESP_FAIL;
if (failure == 6) ssl_start_error = ESP_FAIL;
web_lifecycle_settings_execute(id); assert(!identity_replacements);
pipeline_callback(id); assert(!identity_replacements);
fresh_registration(); web_lifecycle_settings_execute(id);
assert(identity_replacements == 1 && !identity_token && !s_transitioning);
assert(s_operation.state == (failure ? FAILED : OK));
assert(identity_generation == (failure == 1 ? 11U : 12U));
if (failure == 1) assert(!ssl_stops && ssl_starts == 1 && auth_live);
if (failure >= 2 && failure <= 5) assert(s_server == SERVER && ssl_starts == 1 && !auth_live);
if (failure == 6) assert(!s_server && !auth_live && ssl_starts == 2);
if (!failure) assert(auth_live && s_server == SERVER && s_generation == generation + 3);
web_lifecycle_settings_execute(id); assert(identity_replacements == 1);
}
puts("PASS ACK dispatch rotation holds common owner reservation through crypto/commit/stop/start; precommit failure keeps logins, postcommit failure never rolls back");
pipeline_reset(); assert(web_server_stop() == ESP_OK);
bool committed = false; unsigned starts = ssl_starts;
assert(web_server_replace_identity(0, 0, false, &committed) == ESP_OK && committed);
assert(!s_server && ssl_starts == starts && identity_generation == 12);
fresh_registration();
assert(web_server_replace_identity(0, 0, true, &committed) == ESP_OK && committed);
assert(s_server == SERVER && ssl_starts == starts + 1 && identity_generation == 13);
puts("PASS canonical stopped rotation remains stopped; CLI TLS reset starts stopped HTTPS without account/config reset");
for (unsigned action = 0; action < 3; ++action) {
pipeline_reset(); uint32_t id = pipeline_admit(action), generation = s_generation;
web_lifecycle_settings_execute(id); assert(!ssl_stops && !pipeline_reboots);
+140 -3
View File
@@ -19,7 +19,7 @@ source = SOURCE.read_text()
def function(name):
match = re.search(r'^(?:static )?esp_err_t ' + name + r'\([^\n]*\)\n\{.*?^\}',
match = re.search(r'^(?:static )?esp_err_t ' + name + r'\([^;{}]*\)\n\{.*?^\}',
source, re.M | re.S)
if not match:
raise RuntimeError('Production function shape changed: ' + name)
@@ -117,6 +117,22 @@ static void xSemaphoreGive(SemaphoreHandle_t m) {
assert(m && locked); locked = 0; if (unlock_hook) unlock_hook();
}
static void secure_wipe(void *p, size_t n) { assert(!locked); memset(p, 0, n); }
static uint32_t identity_generation = 11, identity_token;
static unsigned identity_replacements;
static esp_err_t identity_error;
static void (*identity_hook)(void);
static esp_err_t web_security_reserve_identity(uint32_t expected, bool reset, uint32_t *token) {
assert(!locked); (void)reset; *token = 0;
if (identity_token || identity_generation == UINT32_MAX || (expected && expected != identity_generation)) return ESP_ERR_INVALID_STATE;
*token = identity_token = 1; return ESP_OK;
}
static esp_err_t web_security_replace_reserved(uint32_t token) {
assert(!locked && token && token == identity_token); ++identity_replacements;
if (identity_hook) identity_hook();
if (identity_error == ESP_OK) ++identity_generation;
return identity_error;
}
static void web_security_release_identity(uint32_t token) { assert(!locked); if (token == identity_token) identity_token = 0; }
#define HANDLER(name) static esp_err_t name(httpd_req_t *r) { (void)r; assert(!"HTTP handler must not run in lifecycle harness"); return ESP_FAIL; }
HANDLER(root_handler) HANDLER(status_handler) HANDLER(traced_ticket_handler)
HANDLER(traced_websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
@@ -845,7 +861,7 @@ int main(void) {
puts("PASS every other settings route failure leaves the complete Network domain available");
management_tests();
pipeline_tests();
puts("41 lifecycle groups passed (34 prior owner/route groups plus 7 lifecycle integration groups)");
puts("44 lifecycle groups passed (34 prior owner/route, 7 lifecycle integration, 3 HTTPS identity owner groups)");
return 0;
}
'''
@@ -1006,7 +1022,8 @@ unit += function('ensure_mutex')
unit += ''.join(function(name) for name in (
'web_server_init', 'start_server', 'web_server_start', 'stop_server',
'web_server_stop', 'web_server_stop_current', 'web_server_restart_current',
'web_server_reboot_current', 'web_server_get_management_snapshot', 'web_server_clear_counters'))
'web_server_reboot_current', 'web_server_get_management_snapshot', 'web_server_clear_counters',
'web_server_replace_identity'))
lifecycle_source = (ROOT / 'src/web_lifecycle_settings.c').read_text()
pipeline_state = lifecycle_source[lifecycle_source.index('typedef struct {'):lifecycle_source.index('static void cancel_locked')]
pipeline_state = 'enum { IDLE, PENDING, EXECUTING, OK, FAILED, CANCELLED };\n' + pipeline_state
@@ -1026,3 +1043,123 @@ with tempfile.TemporaryDirectory(prefix='web-admin-server-lifecycle-') as direct
'-g', str(c_file), '-o', str(executable)], check=True, timeout=30)
subprocess.run([str(executable)], check=True, timeout=15)
print('Compiled production init/start/stop, URI initializers and configuration; dependency behavior is faked.')
# Second executable links the same production server functions to the COMPLETE
# security implementation and real mbedTLS. Only NVS/HTTPD/scheduler are doubles.
import ast
security_runner = ast.parse((ROOT / 'tests/web_security/run.py').read_text())
security_headers = next(ast.literal_eval(node.value) for node in security_runner.body
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'HEADERS' for t in node.targets))
real_unit = unit.replace('int main(void)', 'int orchestration_regressions(void)')
real_unit = real_unit.replace('typedef int esp_err_t;\nenum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_STATE, ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_INVALID_ARG };', '#include "esp_err.h"')
for name in ('web_security_reserve_identity', 'web_security_replace_reserved', 'web_security_release_identity', 'web_security_copy_tls_material'):
pattern = r'^static (?:esp_err_t|void) ' + name + r'\([^{}]*\) \{.*?^\}' if name != 'web_security_release_identity' else r'^static void web_security_release_identity[^\n]*'
match = re.search(pattern, real_unit, re.M | re.S)
assert match, name
signature = match.group().split('{', 1)[0].replace('static ', '', 1).strip() + ';'
real_unit = real_unit[:match.start()] + signature + real_unit[match.end():]
real_unit = real_unit.replace('config->servercert_len == 1 && config->servercert[0] == 1', 'config->servercert_len > 100 && config->servercert[0] == 0x30')
real_unit = real_unit.replace('config->prvtkey_len == 1 && config->prvtkey_pem[0] == 2', 'config->prvtkey_len > 32 && config->prvtkey_pem[0] == 0x30')
real_unit = 'static int real_identity_active;\nstatic void real_runtime_check(void);\n' + real_unit
real_unit = real_unit.replace("event('A'); ++auth_stops", "real_runtime_check(); event('A'); ++auth_stops")
real_unit = real_unit.replace('assert(!locked && auth_live && !ssl_live); ++ssl_starts;', 'real_runtime_check(); assert(!locked && auth_live && !ssl_live); ++ssl_starts;')
real_unit += r'''
void identity_fixture_prepare(void);
void identity_fixture_fault(unsigned);
void identity_fixture_check(bool);
void identity_fixture_hook(void (*hook)(void));
esp_err_t web_security_rotate_certificate(void);
esp_err_t web_security_reset_all(void);
static unsigned real_crypto_hooks, real_runtime_checks;
static void real_runtime_check(void) {
if (!real_identity_active) return;
assert(!locked && s_transitioning);
++real_runtime_checks;
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
}
static void real_crypto_interleave(void) {
assert(!locked && s_transitioning);
++real_crypto_hooks;
bool committed = true;
assert(web_server_replace_identity(0, 0, true, &committed) == ESP_ERR_INVALID_STATE && !committed);
assert(web_server_replace_identity(0, 0, false, &committed) == ESP_ERR_INVALID_STATE && !committed);
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
assert(web_server_stop() == ESP_ERR_INVALID_STATE && web_server_start() == ESP_ERR_INVALID_STATE);
identity_fixture_check(false);
}
int main(void) {
for (unsigned failure = 0; failure < 7; ++failure) {
reset(); identity_fixture_prepare(); start();
bool committed = false;
if (failure >= 1 && failure <= 4) identity_fixture_fault(failure);
if (failure == 5) ssl_stop_error = ESP_FAIL;
if (failure == 6) ssl_start_error = ESP_FAIL;
identity_fixture_hook(real_crypto_interleave);
fresh_registration(); real_identity_active = 1;
esp_err_t error = web_server_replace_identity(s_generation, 1, false, &committed);
real_identity_active = 0;
assert(error == ESP_OK || failure);
assert((error == ESP_OK) == (failure == 0));
bool changed = !failure || failure >= 5;
assert(committed == changed && !s_transitioning);
identity_fixture_check(changed);
if (failure >= 1 && failure <= 4) assert(auth_live && !ssl_stops && ssl_starts == 1);
if (failure == 5) assert(s_server == SERVER && !auth_live && ssl_starts == 1);
if (failure == 6) assert(!s_server && !auth_live && ssl_starts == 2);
}
assert(real_crypto_hooks == 7 && real_runtime_checks >= 5);
puts("PASS integrated production HTTPS owner + real mbedTLS/NVS open/set/commit/RNG failures: exact identity/storage unchanged before commit; successful commit survives actual owner stop/start failure without rollback");
reset(); identity_fixture_prepare(); start();
bool committed = true;
assert(web_server_replace_identity(s_generation + 1, 1, false, &committed) == ESP_ERR_INVALID_STATE && !committed);
assert(web_server_replace_identity(s_generation, 2, false, &committed) == ESP_ERR_INVALID_STATE && !committed);
identity_fixture_check(false);
assert(!ssl_stops && ssl_starts == 1 && auth_live);
puts("PASS integrated stale service and identity reject without real crypto/NVS effects; real crypto interleavings exclude canonical CLI/reset and direct security mutation");
return 0;
}
'''
security_fixture = (ROOT / 'tests/web_security/security.c').read_text().replace('"../../src/web_security.c"', '"' + str(ROOT / 'src/web_security.c') + '"')
security_fixture = security_fixture[:security_fixture.index('static void put16')].replace('legacy_wipes, groups;', 'legacy_wipes;')
security_fixture += r'''
static bool fail_during_crypto;
static void (*fixture_hook)(void);
static void fixture_crypto(void) {
assert(!locked && s_identity_token);
if (fixture_hook) fixture_hook();
if (fail_during_crypto) fail_rng = true;
}
void identity_fixture_prepare(void) {
boot(); stored_size = 0; fail_during_crypto = false; fixture_hook = NULL;
assert(web_security_init(NULL) == ESP_OK);
expected_live = s_material;
}
void identity_fixture_fault(unsigned value) {
fault = value == 1 ? OPEN_RW : value == 2 ? SET : value == 3 ? COMMIT : 0;
fail_during_crypto = value == 4;
}
void identity_fixture_hook(void (*hook)(void)) { fixture_hook = hook; crypto_hook = fixture_crypto; }
void identity_fixture_check(bool changed) {
assert(!locked && s_material_ready);
assert(s_material.generation == expected_live.generation + (changed ? 1U : 0U));
assert(stored_size == sizeof(s_material) && !memcmp(stored, &s_material, sizeof(s_material)));
if (!changed) assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
else assert(memcmp(s_material.certificate_fingerprint, expected_live.certificate_fingerprint, 32));
}
'''
with tempfile.TemporaryDirectory(prefix='https-identity-integration-') as directory:
temporary = Path(directory)
for name, text in security_headers.items():
path = temporary / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
(temporary / 'server.c').write_text(real_unit)
(temporary / 'identity.c').write_text(security_fixture)
executable = temporary / 'integration'
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror', '-g',
'-I', str(temporary), '-I', str(ROOT / 'src'),
str(temporary / 'server.c'), str(temporary / 'identity.c'),
'-lmbedx509', '-lmbedcrypto', '-o', str(executable)], check=True, timeout=30)
subprocess.run([str(executable)], check=True, timeout=30)
+3
View File
@@ -19,6 +19,9 @@ void esp_restart(void) { assert(false); }
esp_err_t web_server_stop(void) { assert(false); return ESP_FAIL; }
esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
esp_err_t web_security_rotate_certificate(void) { assert(false); return ESP_FAIL; }
esp_err_t web_server_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
(void)service; (void)identity; (void)reset; (void)committed; assert(false); return ESP_FAIL;
}
void *heap_caps_calloc(size_t n, size_t size, unsigned caps) {
assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); return calloc(n, size);
}
+32 -4
View File
@@ -25,6 +25,13 @@ static esp_err_t owner_action(unsigned action, uint32_t generation) {
esp_err_t web_server_stop_current(uint32_t generation) { return owner_action(0, generation); }
esp_err_t web_server_restart_current(uint32_t generation) { return owner_action(1, generation); }
esp_err_t web_server_reboot_current(uint32_t generation) { return owner_action(2, generation); }
esp_err_t web_server_replace_identity(uint32_t generation, uint32_t identity, bool reset, bool *committed) {
assert(identity == 11 && !reset); *committed = owner_error == ESP_OK; return owner_action(3, generation);
}
esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *out) {
assert(on_handler && !host_lock_depth); *out = (web_security_identity_snapshot_t){.generation=11};
memset(out->fingerprint, 0xab, 32); return ESP_OK;
}
esp_err_t httpd_queue_work(httpd_handle_t handle, void (*callback)(void *), void *argument) {
assert(on_handler && !on_dispatcher && !on_callback && !host_lock_depth && handle == &server);
assert(!strcmp(response_status, "202 Accepted") && !send_fail && !aux.remaining_len && sends);
@@ -53,7 +60,7 @@ static void expect_lifecycle(const char *status, bool snapshot_read) {
on_handler = false;
assert(error == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
assert(!strcmp(response_status, status) && mutations == before && submit_calls == submitted);
assert(strlen(output) < (snapshot_read ? 128 : 96)); zero(scratch, sizeof(scratch));
assert(strlen(output) < (snapshot_read ? 320 : 96)); zero(scratch, sizeof(scratch));
assert(!strstr(output, "principal") && !strstr(output, "csrf") && !strstr(output, "password"));
}
static const char *stop_body = "{\"action\":\"stop\",\"generation\":7}";
@@ -109,7 +116,23 @@ static void lifecycle_tests(void) {
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
lifecycle_begin(&admin, invalid[i], false); expect_lifecycle("400 Bad Request", false);
}
const char *invalid_rotation[] = {
"{\"action\":\"rotate\",\"generation\":7}",
"{\"action\":\"rotate\",\"identity_generation\":11}",
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":0}",
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":4294967295}",
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":4294967296}",
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":\"11\"}",
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":11,\"reset\":true}",
"{\"action\":\"stop\",\"generation\":7,\"identity_generation\":11}"
};
for (unsigned i = 0; i < sizeof(invalid_rotation)/sizeof(*invalid_rotation); ++i) {
lifecycle_begin(&admin, invalid_rotation[i], false); expect_lifecycle("400 Bad Request", false);
}
lifecycle_operation_t parsed = {0};
const char *rotation = "{\"identity_generation\":11,\"generation\":7,\"action\":\"rotate\"}";
assert(parse(rotation, strlen(rotation), &parsed) && parsed.identity_generation == 11 && parsed.action == 3);
for (size_t n = 0; n < strlen(rotation); ++n) assert(!parse(rotation, n, &parsed));
for (size_t n = 0; n < strlen(stop_body); ++n) assert(!parse(stop_body, n, &parsed));
assert(parse(stop_body, strlen(stop_body), &parsed)); assert(!parse(stop_body, strlen(stop_body) + 1, &parsed));
const char *reordered = " { \"generation\":4294967294, \"action\":\"restart\" } ";
@@ -129,7 +152,11 @@ static void lifecycle_tests(void) {
for (unsigned mode = 0; mode < 2; ++mode) {
owner_error = mode ? ESP_FAIL : ESP_OK;
lifecycle_begin(&admin, NULL, true); expect_lifecycle(mode ? "503 Service Unavailable" : "200 OK", true);
if (!mode) assert(!strcmp(output, "{\"generation\":7,\"running\":true,\"transitioning\":false,\"controllable\":true}"));
if (!mode) {
assert(strstr(output, "\"identity_generation\":11") && strstr(output, "\"rotatable\":true"));
assert(strstr(output, "abababababababababababababababababababababababababababababababab"));
assert(!strstr(output, "private") && !strstr(output, "certificate_der"));
}
}
owner_error = ESP_OK;
puts("PASS lifecycle bounded scalar snapshot and optional owner failure isolation");
@@ -165,9 +192,10 @@ static void lifecycle_tests(void) {
lifecycle_submit(&admin, stop_body); on_callback = true; ack_handoff((void *)(uintptr_t)late); on_callback = false;
assert(s_ack_id == s_operation.id); owner_callback(); dispatch(); assert(s_operation.state == OK);
puts("PASS lifecycle original-login expiry/revocation/validation races and shutdown/restart same-owner ABA");
for (unsigned action = 0; action < 3; ++action) {
for (unsigned action = 0; action < 4; ++action) {
auth_reset(); admin = mint(&alice); char body[80];
snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":7}", s_actions[action]);
snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":7%s}", s_actions[action],
action == 3 ? ",\"identity_generation\":11" : "");
invalidate_during_owner = true; lifecycle_submit(&admin, body); owner_callback(); before = mutations; dispatch();
invalidate_during_owner = false;
assert(s_operation.state == OK && mutations == before + 1);
+23 -6
View File
@@ -31,7 +31,7 @@ particular, modeled failed commits retain predecessor storage; real flash fault
and power-loss behavior needs target validation. No claim that NVS logical
replacement securely erases historical flash pages.
The suite reports 15 production security groups plus one API/console static
The suite reports 17 production security groups plus one API/console static
absence check. Coverage includes fresh and stored-v2 paths, exact v1 migration,
metadata/pair-copy bounds, NVS failures and retries, 21 legacy corruptions,
14 v2 corruptions, unknown sizes, real bad signatures with recomputed hashes,
@@ -44,7 +44,7 @@ intentionally retained.
## Integration/API contract
Five public functions remain:
The five existing public functions remain:
- `web_security_init(web_security_load_result_t *)`
- `web_security_copy_tls_material(...)` (unchanged pair-copy API)
@@ -52,23 +52,40 @@ Five public functions remain:
- `web_security_rotate_certificate(void)`
- `web_security_reset_all(void)` (**TLS only**, changed signature)
8D.21 adds `web_security_get_identity_snapshot()` (zero-wait public fingerprint/
generation only) and the owner-only reservation contract
`web_security_reserve_identity()` / `web_security_replace_reserved()` /
`web_security_release_identity()`. The API-symbol check includes all nine functions.
New tests cover zero-wait contention, stale generations, one-use/nonreused tokens,
reservation exhaustion and competing canonical rotate/reset/init during real crypto.
Crypto/NVS runs outside the normal mutex during replacement; the identity reservation
survives until its owner releases it after service stop/start.
`python3 tests/web_admin_transport/server_lifecycle.py` additionally links real
production security and mbedTLS to the production HTTPS owner, with NVS/HTTPD doubles,
for unchanged identity/storage before commit and no rollback after stop/start failure.
See [8D.21 contracts and evidence limits](../../docs/phase8d21_implementation.md).
Removed: two credential functions (`show_credentials`, `rotate_credentials`),
one credential struct type, three username/password capacity/length constants,
and two console operations (`web credentials show`, `web credentials rotate`).
There is no credential generation/display/synchronization path. Authentication
continues to belong to the user database; read-only status does not mutate it.
The integration owner must remove legacy startup callers in `main.c` and adapt
other console policy/completion/UI/test callers outside this ownership scope.
Legacy startup callers and console policy/completion integrations were removed in
the accepted legacy-credential cleanup; this test does not reintroduce them.
Load results retain `STORED=0`, `GENERATED_MISSING=1`, and add `MIGRATED_V1=2`.
Repeated successful init returns the remembered result without reloading.
Repeated successful init returns the remembered result without reloading; an active
identity reservation rejects init until its owner finishes.
Migration must validate and commit before publication; no fallback generation
or overwrite follows migration failure. Reset explicitly overwrites missing,
valid, or incompatible material, increments a live generation or uses one when
no live identity exists, and fails on live generation exhaustion. Rotation
requires live material and also fails at `UINT32_MAX`.
`web reset --force` retains the old lifecycle: commit first; when running,
CLI and browser-shell identity mutations now share `web_server_replace_identity()`
service/security reservation composition. `web reset --force` retains the old
lifecycle: commit first; when running,
stop then start, with no start after failed stop; otherwise attempt start.
Lifecycle failure does not roll back committed identity. Database accounts are
never synchronized, reset or otherwise mutated by these operations.
+6 -3
View File
@@ -17,6 +17,7 @@ HEADERS = {
#define ESP_ERR_INVALID_VERSION 4
#define ESP_ERR_INVALID_RESPONSE 5
#define ESP_ERR_NO_MEM 6
#define ESP_ERR_TIMEOUT 7
""",
"esp_mac.h": """#pragma once
#include <stdint.h>
@@ -26,6 +27,7 @@ HEADERS = {
""",
"freertos/FreeRTOS.h": """#pragma once
#define portMAX_DELAY 0xffffffffU
#define pdTRUE 1
""",
"freertos/semphr.h": """#pragma once
typedef void *SemaphoreHandle_t;
@@ -69,7 +71,8 @@ with tempfile.TemporaryDirectory(prefix="web-security-") as directory:
assert set(re.findall(r" T (web_security_\w+)$", symbols, re.MULTILINE)) == {
"web_security_init", "web_security_copy_tls_material",
"web_security_get_certificate_metadata", "web_security_rotate_certificate",
"web_security_reset_all",
"web_security_reset_all", "web_security_get_identity_snapshot",
"web_security_reserve_identity", "web_security_replace_reserved", "web_security_release_identity",
}
header = (ROOT / "src/web_security.h").read_text()
assert "web_security_credentials_t" not in header
@@ -78,8 +81,8 @@ with tempfile.TemporaryDirectory(prefix="web-security-") as directory:
console = (ROOT / "src/web_console.c").read_text()
for forbidden in ('"credentials"', "web credentials", "user_database_sync_legacy", "synchronize_migrated", "Password:"):
assert forbidden not in console, forbidden
assert "web_security_reset_all()" in console
assert "web_server_replace_identity(0, 0, reset, &committed)" in console
assert set(re.findall(r"\b(user_database_\w+)\s*\(", console)) == {
"user_database_get_snapshot",
}
print("PASS exact five-function API and legacy credential/console DB-mutation absence")
print("PASS exact public/owner API and legacy credential/console DB-mutation absence")
+48 -3
View File
@@ -11,13 +11,14 @@
static uint8_t stored[1600], pending[1600];
static size_t stored_size, pending_size;
static int fault, writes, commits, rng_calls, legacy_wipes, groups;
static bool locked, fail_mutex, fail_rng, fail_mac, alternate_mac, watch_publication;
static bool locked, fail_mutex, fail_rng, fail_mac, alternate_mac, watch_publication, mutex_busy;
static void (*crypto_hook)(void);
static web_security_blob_t expected_live;
enum { OPEN_RO = 20, OPEN_RW, QUERY, READ, SET, COMMIT, TYPE, SHORT_READ };
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return fail_mutex ? NULL : (void *)1; }
int xSemaphoreTake(SemaphoreHandle_t m, unsigned delay)
{ (void)delay; assert(m && !locked); locked = true; return 1; }
{ assert(m && !locked); if (mutex_busy) { assert(delay == 0); return 0; } locked = true; return 1; }
int xSemaphoreGive(SemaphoreHandle_t m)
{ assert(m && locked); locked = false; return 1; }
esp_err_t esp_read_mac(uint8_t *mac, int type)
@@ -32,6 +33,7 @@ esp_err_t secure_random_init(void) { return fail_rng ? ESP_FAIL : ESP_OK; }
esp_err_t secure_random_fill(void *out, size_t length)
{
++rng_calls;
if (crypto_hook) { void (*hook)(void) = crypto_hook; crypto_hook = NULL; hook(); }
if (fail_rng) return ESP_FAIL;
return getrandom(out, length, 0) == (ssize_t)length ? ESP_OK : ESP_FAIL;
}
@@ -68,7 +70,7 @@ esp_err_t nvs_get_blob(nvs_handle_t handle, const char *key, void *data, size_t
esp_err_t nvs_set_blob(nvs_handle_t handle, const char *key, const void *data, size_t size)
{
assert(handle == NVS_READWRITE && !strcmp(key, "material"));
assert(size == 1340 && locked);
assert(size == 1340 && (s_identity_token ? !locked : locked));
++writes;
if (watch_publication) assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
if (fault == SET) return ESP_FAIL;
@@ -90,6 +92,8 @@ static void boot(void)
{
memset(&s_material, 0, sizeof(s_material));
s_material_ready = false; s_security_mutex = NULL;
s_identity_token = s_next_identity_token = 0; s_identity_used = false;
crypto_hook = NULL; mutex_busy = false;
s_load_result = WEB_SECURITY_LOAD_STORED;
fault = writes = commits = rng_calls = legacy_wipes = 0;
fail_mutex = fail_rng = fail_mac = alternate_mac = locked = false;
@@ -124,6 +128,19 @@ static void rejected(void)
assert(writes == 0 || fault == SET || fault == COMMIT);
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
}
static void competing_identity(void)
{
assert(!locked && s_identity_token);
web_security_identity_snapshot_t snapshot;
assert(web_security_get_identity_snapshot(&snapshot) == ESP_OK && snapshot.busy);
assert(snapshot.generation == expected_live.generation);
assert(!memcmp(snapshot.fingerprint, expected_live.certificate_fingerprint, 32));
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
assert(web_security_init(NULL) == ESP_ERR_INVALID_STATE);
assert(!memcmp(&expected_live, &s_material, sizeof(s_material)));
}
int main(void)
{
boot(); stored_size = 0;
@@ -262,6 +279,34 @@ int main(void)
}
group("rotation/reset transactional failures, identity change and generation increment");
uint32_t token = 0, generation = s_material.generation;
int prior_writes = writes, prior_rng = rng_calls;
assert(web_security_reserve_identity(generation - 1, false, &token) == ESP_ERR_INVALID_STATE && !token);
assert(writes == prior_writes && rng_calls == prior_rng);
web_security_identity_snapshot_t projection;
mutex_busy = true;
assert(web_security_get_identity_snapshot(&projection) == ESP_ERR_TIMEOUT);
assert(web_security_reserve_identity(generation, false, &token) == ESP_ERR_TIMEOUT);
mutex_busy = false;
assert(web_security_reserve_identity(generation, false, &token) == ESP_OK && token);
crypto_hook = competing_identity;
assert(web_security_replace_reserved(token) == ESP_OK && !crypto_hook);
assert(s_identity_token == token && s_material.generation == generation + 1);
assert(web_security_replace_reserved(token) == ESP_ERR_INVALID_STATE);
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
web_security_release_identity(token - 1); assert(s_identity_token == token);
web_security_release_identity(token); assert(!s_identity_token);
assert(web_security_replace_reserved(token) == ESP_ERR_INVALID_STATE);
assert(web_security_get_identity_snapshot(&projection) == ESP_OK && !projection.busy);
assert(!memcmp(projection.fingerprint, s_material.certificate_fingerprint, 32));
expected_live = s_material;
group("zero-wait public projection and stale/token fencing; real crypto outside locks excludes canonical writers through release");
s_next_identity_token = UINT32_MAX;
assert(web_security_reserve_identity(0, true, &token) == ESP_ERR_INVALID_STATE);
assert(web_security_get_identity_snapshot(&projection) == ESP_OK && projection.busy);
assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
group("reservation IDs saturate without ABA or recovery mutation bypass");
for (int kind = 0; kind < 3; ++kind) {
boot(); legacy(&identity);
if (kind == 0) stored_size = 0;
+36 -3
View File
@@ -2,7 +2,7 @@
const assert = require('node:assert/strict');
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
const path = '/api/settings/lifecycle', op = path + '-operation';
const fixture = (extra = {}) => ({generation:7,running:true,transitioning:false,controllable:true,...extra});
const fixture = (extra = {}) => ({generation:7,running:true,transitioning:false,controllable:true,identity_generation:11,fingerprint:'ab'.repeat(32),rotatable:true,...extra});
const reply = (state='pending', id=42, status=200, action='stop') => new Response(JSON.stringify({id,action,state}), {status});
const n = (b,id) => b.nodes['lifecycle-'+id], posts = b => b.calls.filter(c=>c.url===op && c.method==='POST');
async function open(v=fixture()) { const b=await adminBrowser(); b.click('select-settings'); await tick(); b.queues[path].push(json(v)); b.click('settings-lifecycle'); await tick(); return b; }
@@ -28,18 +28,51 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
assert.ok(n(b,'stop').disabled); await refresh(b); assert.equal(n(b,'stop').disabled,false); assert.equal(posts(b).length,1);
}
});
await test('HTTPS identity rotation confirms public fingerprint and both generations, shares pending gate, and requires renewed trust/login',async()=>{
const b=await open(); let confirmation=''; b.window.confirm=s=>{confirmation=s;return false;};
b.click('lifecycle-rotate'); await tick(); assert.equal(posts(b).length,0);
for(const text of ['ab'.repeat(32),'generation 11','generation 7','UART0','trust','ALL','SSH','USB','sign in freshly']) assert.ok(confirmation.includes(text),text);
assert.match(n(b,'identity').textContent,/Stored HTTPS identity generation 11/);
b.window.confirm=()=>true; await submit(b,'rotate');
assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'rotate',generation:7,identity_generation:11});
b.click('lifecycle-stop'); b.click('lifecycle-rotate'); await tick(); assert.equal(posts(b).length,1);
b.queues[op].push(reply('failed',42,200,'rotate')); b.click('lifecycle-result'); await tick();
assert.match(n(b,'operation-detail').textContent,/identity may already be persisted/);
assert.match(n(b,'operation-detail').textContent,/No rollback/);
assert.ok(n(b,'rotate').disabled); await refresh(b); assert.equal(n(b,'rotate').disabled,false);
assert.equal(posts(b).length,1);
});
await test('HTTPS identity metadata rejects missing generations, secrets and malformed fingerprints; unavailable identity does not disable ordinary service controls',async()=>{
for(const v of [fixture({identity_generation:undefined}),fixture({identity_generation:0}),fixture({identity_generation:4294967295}),fixture({fingerprint:'<script>'}),fixture({fingerprint:'a'.repeat(65)}),fixture({private_key:'secret'})]) {
const b=await open(v); b.click('lifecycle-rotate'); await tick(); assert.ok(n(b,'rotate').disabled); assert.equal(posts(b).length,0);
assert.doesNotMatch(n(b,'identity').textContent,/script|secret/);
}
const b=await open(fixture({identity_generation:0,fingerprint:'',rotatable:false}));
assert.ok(n(b,'rotate').disabled); assert.equal(n(b,'stop').disabled,false);
assert.doesNotMatch(html,/id="lifecycle-reset"/);
});
await test('HTTPS rotation lost ACK, role loss and post-admission login expiry never replay or restore',async()=>{
const b=await open(); b.queues[op].push(()=>{throw Error('lost');}); b.click('lifecycle-rotate'); await tick();
b.queues[op].push(reply('ok',42,200,'rotate')); b.click('lifecycle-result'); await tick();
await refresh(b); assert.ok(n(b,'rotate').disabled && n(b,'stop').disabled); assert.equal(posts(b).length,1);
b.click('select-serial'); b.queues[path].push(json(fixture())); b.click('select-settings'); await tick(); assert.equal(posts(b).length,1);
const denied=await open(); denied.queues['/api/session'].push(session({role:'user'})); denied.click('lifecycle-rotate'); await tick(); assert.equal(posts(denied).length,0);
const expired=await open(); await submit(expired,'rotate'); expired.queues[op].push(failure(401)); expired.click('lifecycle-result'); await tick();
assert.deepEqual(expired.redirects,['/login']); assert.ok(expired.sockets.every(s=>s.closed));
const fresh=await open(fixture({identity_generation:12,generation:10})); assert.equal(posts(fresh).length,0); assert.ok(!fresh.calls.some(c=>c.url===op));
});
await test('Lifecycle bounded snapshot schema rejects unavailable malformed transitioning saturated and contradictory state',async()=>{
for(const v of [{},fixture({generation:0}),fixture({generation:4294967296}),fixture({running:1}),fixture({controllable:1}),fixture({extra:true}),fixture({transitioning:true}),fixture({running:false}),fixture({generation:4294967295})]) {
const b=await open(v); assert.ok(n(b,'stop').disabled && n(b,'restart').disabled && n(b,'reboot').disabled); assert.equal(posts(b).length,0);
}
for(const v of [fixture({transitioning:true,controllable:false}),fixture({generation:4294967295,controllable:false})]) {const b=await open(v);assert.ok(n(b,'stop').disabled);}
for(const v of [fixture({transitioning:true,controllable:false,rotatable:false}),fixture({generation:4294967295,controllable:false,rotatable:false})]) {const b=await open(v);assert.ok(n(b,'stop').disabled);}
const b=await open(); b.queues[path].push(failure(503)); b.click('lifecycle-refresh'); await tick(); assert.ok(n(b,'stop').disabled); assert.equal(b.sockets.length,2);
});
await test('Lifecycle captures confirmation before delayed original-session validation and gates double click',async()=>{
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); b.queues[op].push(reply('pending',42,202)); b.click('lifecycle-stop'); await tick();
b.click('lifecycle-reboot'); b.click('lifecycle-refresh'); await tick(); assert.equal(posts(b).length,0);
d.resolve(session({role:'admin'})); await tick(); assert.equal(posts(b).length,1); assert.equal(JSON.parse(posts(b)[0].body).generation,7);
for(const state of ['failed','cancelled']) {b.queues[op].push(reply(state));b.click('lifecycle-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='failed'?/may already have occurred/:/before lifecycle admission/);}
for(const state of ['failed','cancelled']) {b.queues[op].push(reply(state));b.click('lifecycle-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='failed'?/may already be persisted/:/before lifecycle admission/);}
assert.equal(posts(b).length,1);
});
await test('Lifecycle lost ACK/replaced result never clears pending or adopts old action results',async()=>{