Add SSH host identity rotation controls
This commit is contained in:
@@ -152,6 +152,8 @@ The 8D.6 document binds retained terminal state to its first validated username/
|
|||||||
|
|
||||||
### SSH
|
### SSH
|
||||||
|
|
||||||
|
**8D.21 identity extension:** Existing SSH settings routes/controller/ID dispatcher slot expose atomic public stored fingerprint/generation and fixed supported ECDSA P-256 algorithm, plus rotation confirming service and identity generations. `ssh_transport_replace_identity()` takes existing command mutex with zero wait, compares service state then obtains task-bound nonreused security reservation before any stop/crypto/NVS. Canonical UART0/deferred SSH wrapper and direct security rotate/reset share admission; command mutex and identity reservation span stop→commit/publish→conditional restart. Crypto/NVS run outside security locks/spinlocks. Failed stop skips mutation/start; persistence failure can follow disconnection and attempts old-identity service recovery; committed replacement is never rolled back after restart failure. Stopped rotate stays stopped; canonical reset can recover unavailable material and start stopped SSH, without a browser Reset/recovery endpoint. Runtime owner retains context until all slots retire, frees it before clearing cleanup admission, and rejects orphan overwrites at start. wolfSSH copies caller DER; stack/candidate/live superseded key wiping retained. Public service/security snapshots are separate observations; admission compares both. Existing bounds256/768/96 bytes, login-isolated/manual15-second/no-replay result flow and shell policy unchanged; no HTTPS self-cutting ACK gate needed because HTTPS stays accessible. Changed-known_hosts verification requires trusted UART0 `ssh host-key info`. Full contract/evidence/parent-target limits: `docs/phase8d21_implementation.md`.
|
||||||
|
|
||||||
**Typed ordinary SSH controls (8D.19 first service slice):** `web_ssh_settings` owns one session-bound operation/result slot, with256-byte/four-receive JSON,768-byte safe two-row projection and96-byte result. Three optional current-admin routes use canonical cookie/Origin/CSRF protection and the existing four-entry dispatcher (IDs only), never lifecycle work on HTTPD. The dispatcher checks login/principal currentness and30-second dequeue deadline. `ssh_transport` copies only published state under its short lock, and conditional actions take the existing command mutex with zero wait before checking a saturated lifecycle generation; lifecycle comparison/admission shares that mutex with CLI. Disconnect publishes an exact SSH-ID close request under the existing SSH lock; only the owner closes sockets/wolfSSH. SSH session generations now retire exhausted slots, while a separate lifecycle generation fences stop/start ABA and survives counter clear. Failed/pending cleanup gates typed controls, retaining canonical UART0 recovery. UI confirms SSH/all-SSH/one-SSH scope, preserves stale selection without rebasing, and uses15-second bounded requests with manual result/refresh recovery, no automatic replay.36 handlers/six sockets and unchanged tasks/stacks/timers/queue depth. No invoking HTTPS-session-cutting action, web-session/USB/Wi-Fi controls or identity mutation. Full contracts, admitted-work/timeout limits and pending target checks: `docs/phase8d19_implementation.md`.
|
**Typed ordinary SSH controls (8D.19 first service slice):** `web_ssh_settings` owns one session-bound operation/result slot, with256-byte/four-receive JSON,768-byte safe two-row projection and96-byte result. Three optional current-admin routes use canonical cookie/Origin/CSRF protection and the existing four-entry dispatcher (IDs only), never lifecycle work on HTTPD. The dispatcher checks login/principal currentness and30-second dequeue deadline. `ssh_transport` copies only published state under its short lock, and conditional actions take the existing command mutex with zero wait before checking a saturated lifecycle generation; lifecycle comparison/admission shares that mutex with CLI. Disconnect publishes an exact SSH-ID close request under the existing SSH lock; only the owner closes sockets/wolfSSH. SSH session generations now retire exhausted slots, while a separate lifecycle generation fences stop/start ABA and survives counter clear. Failed/pending cleanup gates typed controls, retaining canonical UART0 recovery. UI confirms SSH/all-SSH/one-SSH scope, preserves stale selection without rebasing, and uses15-second bounded requests with manual result/refresh recovery, no automatic replay.36 handlers/six sockets and unchanged tasks/stacks/timers/queue depth. No invoking HTTPS-session-cutting action, web-session/USB/Wi-Fi controls or identity mutation. Full contracts, admitted-work/timeout limits and pending target checks: `docs/phase8d19_implementation.md`.
|
||||||
|
|
||||||
`ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
|
`ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
|||||||
|
|
||||||
## SSH
|
## SSH
|
||||||
|
|
||||||
|
- **8D.21 SSH identity extension:** `ssh_security.{c,h}` owns zero-wait atomic public metadata and task-bound/nonreused identity reservations shared by direct rotate/reset, crypto/NVS outside security locks. `ssh_transport_replace_identity()` shares canonical CLI/deferred SSH combined service-before-identity admission, existing command-mutex reservation and stop→commit→conditional restart; failed stop skips mutation/start, context retained until owner retires every slot, start rejects orphan handles. `web_ssh_settings.c`/existing `web_ui.c` SSH controller/routes add P-256 fingerprint/algorithm/both generations and confirmed rotation (same256/768/96-byte bounds, manual15-second/no-replay result flow); no browser Reset/recovery/export or shell-policy change. PASS real-security+combined-owner5, runtime-retention, management5, cookie SSH7+shared/all variants, UI158 and broad HTTPS/console/transport regressions. Final100,556 RAM/1,828,573 flash (+24/+3,500), CPU160 unchanged. Full21 allowed HTTPS+SSH implementation complete; independent SSH parent review and target acceptance pending. Contracts/tests/lifetime/failure/limits: `docs/phase8d21_implementation.md`.
|
||||||
|
|
||||||
- **8D.19 first service slice:** `web_ssh_settings.{c,h}` adds optional admin-only GET `/api/settings/ssh`, GET/POST `/api/settings/ssh-operation`; existing dispatcher queues only IDs to one login-bound slot. `ssh_transport_get_management_snapshot()` copies published state without owner wait/stack scan; `ssh_transport_manage_current()` checks saturated service generation under existing command mutex and exact session ID under SSH lock before canonical lifecycle/external-close admission. Exhausted session slots retire instead of wrapping. `web_ui.c` adds confirmed SSH-only Settings, sticky stale selection,15-second requests/manual Check Result/Refresh.36 handlers/six sockets/no new tasks/timers/depth/stacks/assets; CPU160 and8D.18 preserved. Tests `tests/ssh_management/run.py`, cookie `--ssh`, dispatcher, lifecycle27 and UI143. Contracts/resources/remaining8D.19 service audit/target checks: `docs/phase8d19_implementation.md`. SSH slice implemented/host/build verified; parent review/target sign-off pending, not full8D.19.
|
- **8D.19 first service slice:** `web_ssh_settings.{c,h}` adds optional admin-only GET `/api/settings/ssh`, GET/POST `/api/settings/ssh-operation`; existing dispatcher queues only IDs to one login-bound slot. `ssh_transport_get_management_snapshot()` copies published state without owner wait/stack scan; `ssh_transport_manage_current()` checks saturated service generation under existing command mutex and exact session ID under SSH lock before canonical lifecycle/external-close admission. Exhausted session slots retire instead of wrapping. `web_ui.c` adds confirmed SSH-only Settings, sticky stale selection,15-second requests/manual Check Result/Refresh.36 handlers/six sockets/no new tasks/timers/depth/stacks/assets; CPU160 and8D.18 preserved. Tests `tests/ssh_management/run.py`, cookie `--ssh`, dispatcher, lifecycle27 and UI143. Contracts/resources/remaining8D.19 service audit/target checks: `docs/phase8d19_implementation.md`. SSH slice implemented/host/build verified; parent review/target sign-off pending, not full8D.19.
|
||||||
|
|
||||||
**Responsibility:** authenticate SSH, route users to serial and administrators to the command dispatcher, and own wolfSSH lifecycle.
|
**Responsibility:** authenticate SSH, route users to serial and administrators to the command dispatcher, and own wolfSSH lifecycle.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do
|
|||||||
|
|
||||||
## Development state
|
## Development state
|
||||||
|
|
||||||
|
- **8D.21 remaining SSH identity slice implemented end-to-end (2026-09-13), host/build verified, independently reviewed; target acceptance pending:** Clean initial worktree; completed HTTPS history/shared reservations preserved. `ssh_security` adds atomic zero-wait public fingerprint/generation and task-bound nonreused reservation shared by direct rotate/reset; crypto/NVS outside security locks, commit-before-publication/wipe. `ssh_transport_replace_identity()` holds existing command mutex across service-before-identity comparison/reservation and canonical stop→persist→conditional restart, fails stop without mutation/new start, preserves stopped rotate/reset behavior and partial effects. Runtime start refuses orphan handles; failed stop retains context until owner retires all slots. Existing cleanup already freed wolfSSH sessions before broker failure—no preexisting UAF demonstrated. Existing19 routes/256-byte request/768-byte snapshot/96-byte result/ID dispatcher slot and UI now expose P-256 metadata and confirmed rotation with both generations, all-SSH/known_hosts/trusted UART0 `ssh host-key info`/partial-effect warnings,15-second/manual result/no replay; HTTPS stays accessible. No browser Reset/recovery/export, user authorized-key, deprecated shell-policy, task/timer/queue/route/assets/config/SDK/dependency changes. PASS SSH security5 including real-mbedTLS/NVS+actual combined-owner integration, runtime retention, management5, cookie SSH7+all prior variants/shared, UI158+HTML/CSP, HTTPS lifecycle44+2/security17, dispatcher/policy and broad regressions. Final production pio21.98s **100,556 RAM/1,828,573 flash (+24/+3,500 vs SSH baseline100,532/1,825,073)**; CPU160 defaults/active/generated verified, combined WS send untouched. Full21 allowed HTTPS+SSH identity implementation scope complete, not target/M3/8D.22 acceptance. Independent review found no confirmed actionable defects; installed wolfSSH copy/lifetime, owner failures and auth/UI scope audited. Reviewer reran SSH security5/retention/management/cookie SSH7/UI158/console suites/HTTPS lifecycle+security17/diff PASS. Parent final pio confirmation PASS7.15s100,556/1,828,573 B; diff check PASS. No target sign-off inferred. Full contracts/tests/resources/exclusions and hardware/runtime limits: `docs/phase8d21_implementation.md`. No upload/erase/commit/branch.
|
||||||
|
|
||||||
- **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.**
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs
|
|||||||
|
|
||||||
## Selected self-affecting admin SSH actions use bounded deferred control
|
## Selected self-affecting admin SSH actions use bounded deferred control
|
||||||
|
|
||||||
|
**8D.21 SSH host identity decision:** Reuse canonical deferred SSH self-rotation and typed19 SSH routes/dispatcher, not command strings or another executor. Compare/reserve service then identity before stop or storage; retain the existing command mutex and task-bound nonreused security token through canonical stop→commit→conditional restart. Direct security rotate/reset share identity exclusion. Crypto/NVS run outside security locks/spinlocks; failed stop must not mutate identity or attempt start, and failed persistence may already have disconnected SSH. Never roll back committed identity after restart failure. Retain wolfSSH context until every slot retires; only owner frees it before clearing cleanup admission, and start refuses orphan handles. Public metadata is zero-wait/atomic within security, not a cross-owner authorization; UI confirms both generations and warns changed known_hosts/trusted UART0 `ssh host-key info`, partial effects and no replay. HTTPS remains accessible, so existing SSH queue/response order is retained without20's self-cutting ACK gate. Canonical reset/recovery preserved but excluded from browser as duplicate healthy rotation plus distinct recovery semantics. Details and test limits: `docs/phase8d21_implementation.md`.
|
||||||
|
|
||||||
**8D.20 integrated typed HTTPS/reboot decision (supersedes prerequisite-only status below):** A successful synchronous HTTP response send return is the ACK boundary, not peer receipt. Queue exactly one nonreused-ID HTTPD callback after sending; that callback only submits an ID to the existing dispatcher, never waits or runs lifecycle. No captured request/fd/reusable slot pointer. A lost callback remains reserved even after its two-second admission deadline; only its arrival or successful HTTPD destruction releases that reservation. Do not retry queue submission or permit callback accumulation. Original-login/current-admin/post-validation30-second deadline precede canonical generation-conditional owner admission; no cancellation claim after admission, including detach failures and deliberately login-invalidating reserved restart. Typed reboot uses shared `esp_restart()` outside locks after HTTPS generation reservation, not a console string/self-cleanup wait or new runner. UI requires explicit confirmation, fences15-second whole requests and late results, retains unknown/duplicate gates across navigation, never restores/retries mutations and requires fresh login after HTTPS restart. Network controls remain the sole Wi-Fi domain; USB is UART1 serial recovery, not administration or uninterrupted whole-device reboot. Exact contracts and target limits: `docs/phase8d20_implementation.md`.
|
**8D.20 integrated typed HTTPS/reboot decision (supersedes prerequisite-only status below):** A successful synchronous HTTP response send return is the ACK boundary, not peer receipt. Queue exactly one nonreused-ID HTTPD callback after sending; that callback only submits an ID to the existing dispatcher, never waits or runs lifecycle. No captured request/fd/reusable slot pointer. A lost callback remains reserved even after its two-second admission deadline; only its arrival or successful HTTPD destruction releases that reservation. Do not retry queue submission or permit callback accumulation. Original-login/current-admin/post-validation30-second deadline precede canonical generation-conditional owner admission; no cancellation claim after admission, including detach failures and deliberately login-invalidating reserved restart. Typed reboot uses shared `esp_restart()` outside locks after HTTPS generation reservation, not a console string/self-cleanup wait or new runner. UI requires explicit confirmation, fences15-second whole requests and late results, retains unknown/duplicate gates across navigation, never restores/retries mutations and requires fresh login after HTTPS restart. Network controls remain the sole Wi-Fi domain; USB is UART1 serial recovery, not administration or uninterrupted whole-device reboot. Exact contracts and target limits: `docs/phase8d20_implementation.md`.
|
||||||
|
|
||||||
**Historical 8D.20 HTTPS owner prerequisite (superseded above):** Conditional HTTPS stop/restart compares an expected saturated lifecycle generation under the canonical server mutex, not snapshot-check-unlock-unconditional-stop. Restart retains transition ownership through stop and start; a failed stop never admits start, and failed cleanup requires canonical recovery. Repeated init must not clear the retained lifecycle failure; counter clear must not reset generation. The zero-wait management projection does not authorize a request or prove reachability. All lifecycle execution remains off HTTPD and outside the server mutex during owner waits. Future typed ACK handling must precede admission on the existing dispatcher, with original-login currentness; later revocation is not cancellation of an admitted restart. No ACK/API/UI/reboot integration exists in this prerequisite, and the full phase remains incomplete. `src/web_server.{c,h}`, `docs/phase8d20_implementation.md`.
|
**Historical 8D.20 HTTPS owner prerequisite (superseded above):** Conditional HTTPS stop/restart compares an expected saturated lifecycle generation under the canonical server mutex, not snapshot-check-unlock-unconditional-stop. Restart retains transition ownership through stop and start; a failed stop never admits start, and failed cleanup requires canonical recovery. Repeated init must not clear the retained lifecycle failure; counter clear must not reset generation. The zero-wait management projection does not authorize a request or prove reachability. All lifecycle execution remains off HTTPD and outside the server mutex during owner waits. Future typed ACK handling must precede admission on the existing dispatcher, with original-login currentness; later revocation is not cancellation of an admitted restart. No ACK/API/UI/reboot integration exists in this prerequisite, and the full phase remains incomplete. `src/web_server.{c,h}`, `docs/phase8d20_implementation.md`.
|
||||||
|
|||||||
@@ -1,6 +1,67 @@
|
|||||||
# 8D.21 — HTTPS-first security settings
|
# 8D.21 — HTTPS and SSH host identity settings
|
||||||
|
|
||||||
## Status (2026-09-13)
|
## Current aggregate status (2026-09-13)
|
||||||
|
|
||||||
|
**The explicitly authorized HTTPS and remaining SSH identity slices are implemented end-to-end and host/build verified.** This completes the allowed implementation scope of 8D.21, not target acceptance, runtime reserve approval, M3, or 8D.22. The HTTPS slice's independent-review history below is preserved. The SSH slice has now been independently reviewed with no confirmed actionable defects. Reviewer checked installed wolfSSH key copying/wiping, retained-context cleanup, reservation/lock ordering, failure effects, public metadata and auth/UI scope; independently reran SSH security5, retention/management, cookie SSH7+shared, UI158+renderer/HTML/CSP, console boundary/lifecycle/policy and HTTPS lifecycle/security17 PASS. Parent final `pio run` confirmation PASS7.15s,100,556 B RAM/1,828,573 B flash; diff check PASS. Target trust renewal, real scheduling, NVS power-loss and runtime margins remain unverified.
|
||||||
|
|
||||||
|
The SSH continuation began with a clean working tree and preserved the completed HTTPS shared reservations, 8D.19 SSH controller/routes, earlier UI work and 160 MHz throughput configuration. No upload, erase, commit, branch, assets regeneration, SDK/dependency/configuration changes, or new tasks/timers/queues/routes were performed.
|
||||||
|
|
||||||
|
### SSH delivered workflow and exclusions
|
||||||
|
|
||||||
|
Admin Settings → SSH now adds stored host-key OpenSSH SHA-256 fingerprint, identity generation, service generation and the sole canonical supported algorithm, `ecdsa-sha2-nistp256` (ECDSA P-256 / `nistp256`). This is a public metadata projection, not private/public key download or proof of the identity a peer actually served. No algorithm selector or invented host-key algorithm was added; user authorized-key algorithms and authentication are unrelated and unchanged.
|
||||||
|
|
||||||
|
**Rotate SSH host identity…** confirms the exact old fingerprint and both generations before any asynchronous session validation. It explicitly warns that all SSH sessions, including subsequently admitted sessions, close; already executing administration may finish; stopped ordinary rotation stays stopped; persistence and restart may fail after partial effects. Verify the NEW fingerprint using trusted UART0 **`ssh host-key info` before accepting changed `known_hosts` trust**. Do not blindly remove warnings. HTTPS remains accessible and its login/browser terminals are not deliberately stopped; Wi-Fi, users, UART0 administration and native USB UART1 access are unchanged. USB is not an administrative recovery console.
|
||||||
|
|
||||||
|
Reset audit: healthy `ssh reset --force` duplicates key replacement, additionally starts stopped SSH and permits unavailable/corrupt-material recovery. Preserve that canonical CLI/deferred-SSH behavior; do not add a duplicate browser Reset or new recovery endpoint. Full21 excludes user authorized-key work, key/certificate export, passwords/recovery secrets, unavailable-database recovery, factory erase/configuration wipe, OTA, NVS encryption and secure boot. Existing browser-shell SSH policy remains unchanged; typed SSH controls do not enable deprecated/restricted shell commands.
|
||||||
|
|
||||||
|
### SSH owner and material lifetime contract
|
||||||
|
|
||||||
|
- `ssh_transport_replace_identity(service_generation, identity_generation, reset, &committed)` is the shared off-HTTPD owner. Both nonzero generations select conditional rotation; both zero retain canonical semantics; reset plus conditional generations is invalid. The canonical `ssh_transport_replace_host_key()` wrapper and existing UART0/deferred admin-SSH paths use it. Conditional stopped rotation is supported without starting SSH.
|
||||||
|
- Take the existing command mutex with zero wait, compare current initialized/service generation/transition/cleanup state, then reserve identity **before stop or crypto/NVS**. Canonical rotate/reset also now fail busy rather than waiting on another identity owner. Ordinary start/stop retain their existing command-mutex waits. The combined command mutex spans stop, replacement and conditional restart, without releasing an interleaving gap.
|
||||||
|
- `ssh_security_reserve_identity()` uses the security mutex only for short admission. Direct canonical `ssh_security_rotate()` / `ssh_security_reset()` share this reservation. Nonreused uint32 tokens are task-owner-bound; only the reserving task can replace once and release; stale tokens cannot release or reuse a later reservation. Failed expected identity comparison has no service side effect. Init cannot publish unavailable material over a live reservation.
|
||||||
|
- After both reservations, advance saturated service generation; retain established **stop → generate/commit/publish → conditional restart** ordering. Crypto and NVS run outside the security mutex and all spinlocks; the service command mutex remains held. Read-only public metadata/DER copies can take their short security mutex without a crypto-held lock. Existing startup initialization retains its mutex semantics.
|
||||||
|
- Failed stop/timeout skips identity mutation and **never attempts another start**. Pending owner work is not cancelled by timeout. Failed cleanup retains canonical recovery. Generation/RNG/NVS failure after successful stop leaves live identity unchanged, but SSH clients have already disconnected; if previously running, make the established best-effort restart using unchanged material. That restart can itself fail. No blanket “failure means no effect” claim.
|
||||||
|
- Commit precedes publication and old-private-material wipe. `committed=true` remains true if the subsequent restart fails; no rollback. Identity generations change only after successful commit, saturating without wrap. Service generation advances on admitted replacement and admitted lifecycle transitions, including failures, independently of counter clear. Reservation exhaustion blocks future identity mutation until reboot; reboot invalidates old browser logins.
|
||||||
|
- Only the existing SSH owner task invokes runtime wolfSSH/context/socket operations. Installed `wolfSSH_CTX_UsePrivateKey_buffer()` delegates to `wolfSSH_ProcessBuffer()`; its ASN.1 path allocates and copies input DER (`internal.c` 2122–2129) before `SetHostPrivateKey`. The production caller wipes its bounded stack DER on every return path, so a running context does not borrow `s_material` or stack bytes. No SDK/library edit was needed.
|
||||||
|
- `start_runtime()` now rejects retained context/listener/non-free slots rather than overwriting orphan handles. `stop_runtime()` retains the context until all slots retire; `process_slots()` frees a retained context on the owner, outside the spinlock, before clearing cleanup admission. The prior `cleanup_slot()` already frees its wolfSSH object before a possible broker-disconnect failure: this review did **not** demonstrate a preexisting UAF. The change enforces the explicitly requested stronger retained-context invariant. Library destructor private-key wiping was source-inspected, not exercised by a real wolfSSH target test.
|
||||||
|
- Existing SSH self-affecting command drain/deferred-control behavior is preserved. No new dispatcher/task or command-string replay. The dispatcher does not execute its own SSH-shell host rotation synchronously; the existing control owner performs it after the handler/drain boundary. Existing authentication roles, shell-request policy, exact session generations and broker routing are unchanged.
|
||||||
|
|
||||||
|
### SSH HTTP/UI bounds and result semantics
|
||||||
|
|
||||||
|
Reuse GET `/api/settings/ssh` and GET/POST `/api/settings/ssh-operation`, current-admin cookie/Origin/CSRF/no-store protections, existing ID-only dispatcher and single original-login slot. No HTTPD crypto, NVS, lifecycle wait or wolfSSH call. Existing 256-byte/four-receive input, 768-byte snapshot and 96-byte result bounds remain unchanged; 39 total handlers/six sockets and unchanged queue depth/item capacity.
|
||||||
|
|
||||||
|
Service requests retain exactly `action`, `generation`, `target`. Rotation requires exactly four fields, e.g. `{"action":"rotate","generation":7,"target":0,"identity_generation":3}`; unknown/duplicate/escaped fields, unsupported action, nonzero rotation target, missing/zero/saturated identity generation, coercion, malformed or oversized bodies reject. Snapshot adds `identity_generation`, `algorithm`, `fingerprint`, `rotatable` to the existing four fields. Fingerprint is unpadded OpenSSH `SHA256:` base64. Security metadata is atomically copied under a zero-wait lock, with no private material. Service and identity observations are separate, not a cross-owner atomic authorization; execution compares/reserves both. Unavailable identity yields generation0/empty fingerprint/rotatable false without removing ordinary service controls.
|
||||||
|
|
||||||
|
Queue admission and response follow the existing SSH post-before-execute pattern, **not** the HTTPS self-cutting ACK gate: SSH rotation does not stop the invoking HTTPD/login. A lost response does not cancel queued work. Original-login/current-admin/30-second dequeue deadline checks precede combined owner admission; revocation after admission is not cancellation. Completed duplicate IDs are inert, IDs do not wrap, results are replaceable and login-isolated, not durable/idempotent history. Rotation errors conservatively report `failed`, even for rejected owner admission, because later failures can have partial effects; ordinary service conflict reporting is unchanged.
|
||||||
|
|
||||||
|
The existing controller keeps 15-second whole-request bounds, captured confirmation values, single-flight/pending gates, manual Check Operation Result then Refresh, navigation/late-response fencing and no mutation retry or restore. Terminal errors/results explicitly warn that SSH may have disconnected despite persistence failure, or a key may be persisted despite restart failure. HTTPS does not require fresh login due to this SSH operation. No new polling timer or separate controller.
|
||||||
|
|
||||||
|
### SSH validation and resources
|
||||||
|
|
||||||
|
Commands actually run, all PASS after the described harness fixes:
|
||||||
|
|
||||||
|
- `python3 tests/ssh_management/security.py`: five groups, full production security + real host mbedTLS and NVS fault doubles, plus extracted exact production combined owner functions. Covers P-256 generation/validation/copy/reload, RNG/NVS open/set/commit faults and unchanged stored/live bytes, commit-before-publication, stale admission before effects, competing canonical/direct owners during crypto, task/token ownership/reuse/one-shot/exhaustion, postcommit restart failure/no rollback, zero-wait metadata, malformed storage and canonical reset recovery. NVS handle closure and candidate wipes checked. No real power-loss/RTOS scheduling claims.
|
||||||
|
- `python3 tests/ssh_management/runtime.py`: exact production runtime start/stop/process-slot functions; deterministic retained-resource doubles prove failed-stop retention, no orphan overwrite/start, owner-only final retirement and listener-failure cleanup. Not a real wolfSSH allocator/socket test.
|
||||||
|
- `python3 tests/ssh_management/run.py`: five groups, existing published snapshot/session-close/ABA/retired-ID/timeout/exhaustion tests plus combined identity-owner comparison, busy reservation, failed-stop no start/mutation, persistence recovery and stopped/reset semantics.
|
||||||
|
- `python3 tests/web_cookie_auth/run.py --ssh`: seven SSH groups plus shared auth; strict rotation fields/generations, dispatcher-only single execution, revoked queued rotation, login isolation, metadata bounds, existing Origin/CSRF/session/receive/expiry/deadline/lost-response tests. Uses owner doubles, not crypto.
|
||||||
|
- `python3 tests/web_ui_session/run.py`: **158 groups** plus production C rendering/HTML/CSP checks. Adds rotation confirmation/fingerprint/both generations, exact request, all-SSH/trust/UART0/partial-effect warnings, duplicate suppression, HTTPS terminal isolation and malformed/unavailable identity metadata; all prior domains remain green.
|
||||||
|
- Broad PASS: `tests/admin_console_boundary/run.py`, `tests/admin_console_boundary/accounts.py`, `tests/admin_console_boundary/lifecycle.py`, `tests/admin_ssh_policy/run.py`, `tests/web_admin_transport/server_lifecycle.py` (44+2 integrated real HTTPS security groups), `tests/web_security/run.py` (17), all cookie variants (`--admin`, `--settings`, `--serial-settings`, `--accounts`, `--network`, `--display`, `--broker`, `--lifecycle`, `--ssh`), `tests/web_admin_transport/run.py --tickets`, `tests/web_session_store/run.py --serial`, `tests/web_serial_performance/run.py`, `tests/web_httpd_idle/run.py`, `tests/web_auth_parse/run.py`, `tests/web_network_settings/run.py`, `tests/session_broker_diagnostics/run.py`, `tests/web_diagnostics/run.py`, `tests/web_login_ui/run.py` (all invoked with `python3`).
|
||||||
|
- Initial compile caught an enum-type comparison in the new API action; fixed with the module's unsigned action value. Cookie harness needed real `-lmbedcrypto` for base64; integrated owner harness needed its extracted generation constant. These were corrected and affected suites rerun successfully.
|
||||||
|
- Baseline `pio run`: PASS7.20s, **100,532 B RAM / 1,825,073 B flash**. Final production build after retained-context changes: PASS21.98s, **100,556 B RAM / 1,828,573 B flash**, SSH slice delta **+24 B / +3,500 B**; aggregate21 delta versus pre-HTTPS100,508/1,821,505 is **+48 B / +7,068 B**. These are linked static/flash counts, not heap or stack reserves.
|
||||||
|
- Final confirmation `pio run` PASS7.07s at identical100,556/1,828,573 B; final SSH security/runtime/management/cookie/UI158 and console accounts/lifecycle reruns PASS. No independent-review attribution is implied by these same-agent reruns.
|
||||||
|
- Defaults, active sdkconfig and generated sdkconfig.h explicitly checked: CPU **160 MHz**. Board-banner240MHz is not the configured CPU clock. Combined binary WebSocket send path/config/assets untouched. `git diff --check` PASS.
|
||||||
|
|
||||||
|
### Pending SSH parent and target gates
|
||||||
|
|
||||||
|
Independent parent review is required after this implementation; no independent review was performed or fabricated. Review combined-owner lock ordering, command/control self-deferral, direct-security exclusion, retained-context recovery and actual DER-copy/destructor semantics, plus API/UI partial-effect contracts. No target acceptance is claimed.
|
||||||
|
|
||||||
|
On device: compare stored fingerprint with trusted UART0 `ssh host-key info` and actual peer host key; confirm changed-known_hosts verification; rotate with active admin/user SSH and concurrent HTTPS/USB/full mix; verify all SSH disconnect while HTTPS stays accessible; test stopped rotation/reset recovery, service restart/CLI interleavings, broker cleanup failures, reboot persistence and realistic NVS/power-loss faults. Measure dispatcher/control/SSH/HTTPD stack high-water, heap/internal/DMA minima and throughput at160MHz. Confirm unchanged account roles/authorized keys and ordinary/restricted shell behavior. Hardware trust, timing, power-loss, real-wolfSSH allocation/failure behavior and runtime margins remain unmeasured. No new phase is authorized by this record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Historical HTTPS slice status (2026-09-13)
|
||||||
|
|
||||||
|
The following record preserves the completed HTTPS slice and its independent-review evidence. Its statements that SSH/full21 remain unimplemented are historical and superseded by the aggregate status above; its target-pending limits still apply.
|
||||||
|
|
||||||
**Chosen HTTPS slice implemented end-to-end, host-tested and build-verified.**
|
**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
|
Independent review complete with no confirmed actionable findings; target validation/sign-off remains pending. Full
|
||||||
|
|||||||
@@ -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.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.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.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** | **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. |
|
| **8D.21 — Security/danger-zone settings** | **Allowed HTTPS+SSH identity implementation complete, host/build verified; SSH independent parent review and all target sign-off pending.** Remaining SSH slice reuses19 routes/controller/dispatcher for fixed P-256 fingerprint/algorithm and confirmed rotation, with service+identity reservation shared by canonical CLI/deferred SSH/direct security, retained-context failed-stop safety and manual15-second/no replay. SSH security5+runtime+management5, cookie SSH7/UI158 and broad regressions PASS; final100,556 RAM/1,828,573 flash, CPU160. [Current aggregate contract/evidence](phase8d21_implementation.md). **Preserved HTTPS slice history:** 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 continuation now implemented as described above**; 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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
+83
-45
@@ -48,6 +48,9 @@ static bool s_mutex_creating;
|
|||||||
static ssh_security_blob_t s_material;
|
static ssh_security_blob_t s_material;
|
||||||
static bool s_material_ready;
|
static bool s_material_ready;
|
||||||
static ssh_security_load_result_t s_load_result;
|
static ssh_security_load_result_t s_load_result;
|
||||||
|
static uint32_t s_identity_token, s_next_identity_token;
|
||||||
|
static TaskHandle_t s_identity_owner;
|
||||||
|
static bool s_identity_used;
|
||||||
|
|
||||||
static bool bytes_are_zero(const uint8_t *data, size_t size)
|
static bool bytes_are_zero(const uint8_t *data, size_t size)
|
||||||
{
|
{
|
||||||
@@ -338,6 +341,10 @@ esp_err_t ssh_security_init(ssh_security_load_result_t *load_result)
|
|||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (s_identity_token) {
|
||||||
|
xSemaphoreGive(s_security_mutex);
|
||||||
|
return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
ssh_security_blob_t candidate;
|
ssh_security_blob_t candidate;
|
||||||
bool missing = false;
|
bool missing = false;
|
||||||
error = load_blob(&candidate, &missing);
|
error = load_blob(&candidate, &missing);
|
||||||
@@ -411,60 +418,91 @@ esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata)
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t ssh_security_rotate(void)
|
esp_err_t ssh_security_get_identity_snapshot(ssh_security_identity_snapshot_t *snapshot)
|
||||||
{
|
{
|
||||||
if (s_security_mutex == NULL) {
|
if (!snapshot) return ESP_ERR_INVALID_ARG;
|
||||||
|
memset(snapshot, 0, sizeof(*snapshot));
|
||||||
|
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
|
||||||
|
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||||
|
esp_err_t error = s_material_ready ? ESP_OK : ESP_ERR_INVALID_STATE;
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
snapshot->metadata.generation = s_material.generation;
|
||||||
|
memcpy(snapshot->metadata.sha256_fingerprint, s_material.sha256_fingerprint,
|
||||||
|
sizeof(snapshot->metadata.sha256_fingerprint));
|
||||||
|
snapshot->busy = s_identity_token != 0 || s_next_identity_token == UINT32_MAX;
|
||||||
|
}
|
||||||
|
xSemaphoreGive(s_security_mutex);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token)
|
||||||
|
{
|
||||||
|
if (!token || (reset && generation)) return ESP_ERR_INVALID_ARG;
|
||||||
|
*token = 0;
|
||||||
|
if (reset) {
|
||||||
|
esp_err_t error = secure_random_init();
|
||||||
|
if (error == ESP_OK) error = ensure_mutex();
|
||||||
|
if (error != ESP_OK) return error;
|
||||||
|
}
|
||||||
|
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
|
||||||
|
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) ||
|
||||||
|
(generation && generation != s_material.generation)) {
|
||||||
|
xSemaphoreGive(s_security_mutex);
|
||||||
return ESP_ERR_INVALID_STATE;
|
return ESP_ERR_INVALID_STATE;
|
||||||
}
|
}
|
||||||
|
*token = s_identity_token = ++s_next_identity_token;
|
||||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
s_identity_owner = xTaskGetCurrentTaskHandle();
|
||||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
s_identity_used = false;
|
||||||
ssh_security_blob_t candidate;
|
|
||||||
memset(&candidate, 0, sizeof(candidate));
|
|
||||||
if (s_material_ready && s_material.generation != UINT32_MAX) {
|
|
||||||
error = generate_blob(&candidate, s_material.generation + 1U);
|
|
||||||
if (error == ESP_OK) {
|
|
||||||
error = save_blob(&candidate);
|
|
||||||
}
|
|
||||||
if (error == ESP_OK) {
|
|
||||||
install_blob(&candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
secure_wipe(&candidate, sizeof(candidate));
|
|
||||||
xSemaphoreGive(s_security_mutex);
|
xSemaphoreGive(s_security_mutex);
|
||||||
return error;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t ssh_security_reset(void)
|
esp_err_t ssh_security_replace_reserved(uint32_t token)
|
||||||
{
|
{
|
||||||
esp_err_t error = secure_random_init();
|
if (!s_security_mutex || !token) return ESP_ERR_INVALID_STATE;
|
||||||
if (error != ESP_OK) {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
error = ensure_mutex();
|
|
||||||
if (error != ESP_OK) {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||||
uint32_t generation = 1U;
|
if (s_identity_token != token || s_identity_used ||
|
||||||
if (s_material_ready) {
|
s_identity_owner != xTaskGetCurrentTaskHandle()) {
|
||||||
if (s_material.generation == UINT32_MAX) {
|
xSemaphoreGive(s_security_mutex);
|
||||||
xSemaphoreGive(s_security_mutex);
|
return ESP_ERR_INVALID_STATE;
|
||||||
return ESP_ERR_INVALID_STATE;
|
|
||||||
}
|
|
||||||
generation = s_material.generation + 1U;
|
|
||||||
}
|
}
|
||||||
|
s_identity_used = true;
|
||||||
ssh_security_blob_t candidate;
|
uint32_t generation = s_material_ready ? s_material.generation + 1U : 1U;
|
||||||
error = generate_blob(&candidate, generation);
|
|
||||||
if (error == ESP_OK) {
|
|
||||||
error = save_blob(&candidate);
|
|
||||||
}
|
|
||||||
if (error == ESP_OK) {
|
|
||||||
install_blob(&candidate);
|
|
||||||
}
|
|
||||||
secure_wipe(&candidate, sizeof(candidate));
|
|
||||||
xSemaphoreGive(s_security_mutex);
|
xSemaphoreGive(s_security_mutex);
|
||||||
|
|
||||||
|
/* Reservation excludes writers while crypto and flash run outside locks. */
|
||||||
|
ssh_security_blob_t candidate = {0};
|
||||||
|
esp_err_t error = generate_blob(&candidate, generation);
|
||||||
|
if (error == ESP_OK) error = save_blob(&candidate);
|
||||||
|
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||||
|
if (error == ESP_OK) install_blob(&candidate);
|
||||||
|
xSemaphoreGive(s_security_mutex);
|
||||||
|
secure_wipe(&candidate, sizeof(candidate));
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ssh_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_owner == xTaskGetCurrentTaskHandle()) {
|
||||||
|
s_identity_token = 0;
|
||||||
|
s_identity_owner = NULL;
|
||||||
|
}
|
||||||
|
xSemaphoreGive(s_security_mutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
static esp_err_t replace_identity(bool reset)
|
||||||
|
{
|
||||||
|
uint32_t token = 0;
|
||||||
|
esp_err_t error = ssh_security_reserve_identity(0, reset, &token);
|
||||||
|
if (error == ESP_OK) error = ssh_security_replace_reserved(token);
|
||||||
|
ssh_security_release_identity(token);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_err_t ssh_security_rotate(void) { return replace_identity(false); }
|
||||||
|
esp_err_t ssh_security_reset(void) { return replace_identity(true); }
|
||||||
|
|||||||
+17
-1
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
@@ -38,7 +39,22 @@ esp_err_t ssh_security_copy_private_key(uint8_t *output, size_t capacity,
|
|||||||
size_t *output_length);
|
size_t *output_length);
|
||||||
esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata);
|
esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata);
|
||||||
|
|
||||||
/* Caller must stop SSH first. Rotation requires valid live material; reset replaces any stored state. */
|
typedef struct {
|
||||||
|
ssh_security_metadata_t metadata;
|
||||||
|
bool busy;
|
||||||
|
} ssh_security_identity_snapshot_t;
|
||||||
|
|
||||||
|
/* Zero-wait atomic public projection; no private material. */
|
||||||
|
esp_err_t ssh_security_get_identity_snapshot(ssh_security_identity_snapshot_t *snapshot);
|
||||||
|
/* Owner transaction: nonreused token, reserve before side effects and retain through
|
||||||
|
* restart. Only the reserving task may replace once and release. Zero generation
|
||||||
|
* selects canonical semantics; reset additionally permits unavailable material. */
|
||||||
|
esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token);
|
||||||
|
esp_err_t ssh_security_replace_reserved(uint32_t token);
|
||||||
|
void ssh_security_release_identity(uint32_t token);
|
||||||
|
|
||||||
|
/* Rotation requires valid live material; reset replaces any stored state.
|
||||||
|
* Direct callers share the reservation but do not restart the transport. */
|
||||||
esp_err_t ssh_security_rotate(void);
|
esp_err_t ssh_security_rotate(void);
|
||||||
esp_err_t ssh_security_reset(void);
|
esp_err_t ssh_security_reset(void);
|
||||||
|
|
||||||
|
|||||||
+54
-33
@@ -707,6 +707,11 @@ static esp_err_t create_listener(void)
|
|||||||
|
|
||||||
static esp_err_t start_runtime(void)
|
static esp_err_t start_runtime(void)
|
||||||
{
|
{
|
||||||
|
/* Never overwrite an orphaned context/listener or sessions after failed stop. */
|
||||||
|
if (s_context != NULL || s_listen_fd >= 0) return ESP_ERR_INVALID_STATE;
|
||||||
|
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||||
|
if (s_slots[index].state != SSH_TRANSPORT_SESSION_FREE) return ESP_ERR_INVALID_STATE;
|
||||||
|
}
|
||||||
esp_err_t error = create_context();
|
esp_err_t error = create_context();
|
||||||
if (error == ESP_OK) {
|
if (error == ESP_OK) {
|
||||||
error = create_listener();
|
error = create_listener();
|
||||||
@@ -752,7 +757,7 @@ static esp_err_t stop_runtime(void)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (s_context != NULL) {
|
if (all_free && s_context != NULL) {
|
||||||
wolfSSH_CTX_free(s_context);
|
wolfSSH_CTX_free(s_context);
|
||||||
s_context = NULL;
|
s_context = NULL;
|
||||||
}
|
}
|
||||||
@@ -1393,10 +1398,18 @@ static void process_slots(void)
|
|||||||
}
|
}
|
||||||
if (all_free) {
|
if (all_free) {
|
||||||
taskENTER_CRITICAL(&s_lock);
|
taskENTER_CRITICAL(&s_lock);
|
||||||
if (!s_running) {
|
bool stopped = !s_running;
|
||||||
s_cleanup_pending = false;
|
|
||||||
}
|
|
||||||
taskEXIT_CRITICAL(&s_lock);
|
taskEXIT_CRITICAL(&s_lock);
|
||||||
|
/* The owner alone retires the retained context, before reopening admission. */
|
||||||
|
if (stopped) {
|
||||||
|
if (s_context != NULL) {
|
||||||
|
wolfSSH_CTX_free(s_context);
|
||||||
|
s_context = NULL;
|
||||||
|
}
|
||||||
|
taskENTER_CRITICAL(&s_lock);
|
||||||
|
s_cleanup_pending = false;
|
||||||
|
taskEXIT_CRITICAL(&s_lock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1563,46 +1576,54 @@ esp_err_t ssh_transport_stop(void)
|
|||||||
return request_running(false);
|
return request_running(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_err_t ssh_transport_replace_host_key(bool reset)
|
esp_err_t ssh_transport_replace_identity(uint32_t service_generation,
|
||||||
|
uint32_t identity_generation,
|
||||||
|
bool reset, bool *committed)
|
||||||
{
|
{
|
||||||
if (s_command_mutex == NULL) {
|
if (!committed || (!!service_generation != !!identity_generation) ||
|
||||||
return ESP_ERR_INVALID_STATE;
|
(reset && service_generation) || service_generation == UINT32_MAX ||
|
||||||
}
|
identity_generation == UINT32_MAX) return ESP_ERR_INVALID_ARG;
|
||||||
xSemaphoreTake(s_command_mutex, portMAX_DELAY);
|
*committed = false;
|
||||||
|
if (!s_command_mutex) return ESP_ERR_INVALID_STATE;
|
||||||
|
if (xSemaphoreTake(s_command_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||||
|
|
||||||
bool was_running;
|
|
||||||
bool cleanup_pending;
|
|
||||||
taskENTER_CRITICAL(&s_lock);
|
taskENTER_CRITICAL(&s_lock);
|
||||||
if (!s_initialized || s_transitioning) {
|
bool valid = s_initialized && !s_transitioning &&
|
||||||
taskEXIT_CRITICAL(&s_lock);
|
(!service_generation || (!s_cleanup_pending && service_generation == s_management_generation));
|
||||||
xSemaphoreGive(s_command_mutex);
|
bool was_running = s_running, cleanup_pending = s_cleanup_pending;
|
||||||
return ESP_ERR_INVALID_STATE;
|
|
||||||
}
|
|
||||||
was_running = s_running;
|
|
||||||
cleanup_pending = s_cleanup_pending;
|
|
||||||
taskEXIT_CRITICAL(&s_lock);
|
taskEXIT_CRITICAL(&s_lock);
|
||||||
|
uint32_t token = 0;
|
||||||
esp_err_t error = ESP_OK;
|
esp_err_t error = valid ? ssh_security_reserve_identity(identity_generation, reset, &token)
|
||||||
if (was_running || cleanup_pending) {
|
: ESP_ERR_INVALID_STATE;
|
||||||
error = request_running_locked(false);
|
|
||||||
}
|
|
||||||
if (error == ESP_OK) {
|
if (error == ESP_OK) {
|
||||||
error = reset ? ssh_security_reset() : ssh_security_rotate();
|
taskENTER_CRITICAL(&s_lock);
|
||||||
}
|
if (s_management_generation != UINT32_MAX) ++s_management_generation;
|
||||||
if (error != ESP_OK) {
|
taskEXIT_CRITICAL(&s_lock);
|
||||||
if (was_running) {
|
/* Keep the service mutex and identity reservation through stop/replace/start.
|
||||||
(void)request_running_locked(true);
|
* Failed stop must never mutate identity or attempt another start. */
|
||||||
|
if (was_running || cleanup_pending) error = request_running_locked(false);
|
||||||
|
if (error == ESP_OK) {
|
||||||
|
error = ssh_security_replace_reserved(token);
|
||||||
|
*committed = error == ESP_OK;
|
||||||
|
if (error != ESP_OK && was_running) {
|
||||||
|
/* Stop succeeded: restore service using unchanged committed material. */
|
||||||
|
(void)request_running_locked(true);
|
||||||
|
} else if (error == ESP_OK && (was_running || reset)) {
|
||||||
|
error = request_running_locked(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
xSemaphoreGive(s_command_mutex);
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
if (was_running || reset) {
|
|
||||||
error = request_running_locked(true);
|
|
||||||
}
|
}
|
||||||
|
ssh_security_release_identity(token);
|
||||||
xSemaphoreGive(s_command_mutex);
|
xSemaphoreGive(s_command_mutex);
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
esp_err_t ssh_transport_replace_host_key(bool reset)
|
||||||
|
{
|
||||||
|
bool committed;
|
||||||
|
return ssh_transport_replace_identity(0, 0, reset, &committed);
|
||||||
|
}
|
||||||
|
|
||||||
esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot)
|
esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot)
|
||||||
{
|
{
|
||||||
if (snapshot == NULL) {
|
if (snapshot == NULL) {
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ esp_err_t ssh_transport_init(void);
|
|||||||
esp_err_t ssh_transport_start(void);
|
esp_err_t ssh_transport_start(void);
|
||||||
esp_err_t ssh_transport_stop(void);
|
esp_err_t ssh_transport_stop(void);
|
||||||
|
|
||||||
|
/* Conditional off-HTTPD rotation: both generations checked/reserved before stop.
|
||||||
|
* Zero generations retain canonical rotate/reset semantics. A failed stop skips
|
||||||
|
* mutation/start; persistence failure may already have disconnected all SSH.
|
||||||
|
* committed reports irreversible publication even if restart subsequently fails. */
|
||||||
|
esp_err_t ssh_transport_replace_identity(uint32_t service_generation,
|
||||||
|
uint32_t identity_generation,
|
||||||
|
bool reset, bool *committed);
|
||||||
|
|
||||||
/* Serialize stop, persistent host-key replacement, and conditional restart. */
|
/* Serialize stop, persistent host-key replacement, and conditional restart. */
|
||||||
esp_err_t ssh_transport_replace_host_key(bool reset);
|
esp_err_t ssh_transport_replace_host_key(bool reset);
|
||||||
|
|
||||||
|
|||||||
+45
-16
@@ -9,54 +9,58 @@
|
|||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "secure_random.h"
|
#include "secure_random.h"
|
||||||
#include "ssh_transport.h"
|
#include "ssh_transport.h"
|
||||||
|
#include "ssh_security.h"
|
||||||
|
#include "mbedtls/base64.h"
|
||||||
#include "web_cookie_auth.h"
|
#include "web_cookie_auth.h"
|
||||||
#include "web_httpd_adapter.h"
|
#include "web_httpd_adapter.h"
|
||||||
|
|
||||||
enum { IDLE, PENDING, OK, FAILED, CANCELLED, CONFLICT };
|
enum { IDLE, PENDING, OK, FAILED, CANCELLED, CONFLICT };
|
||||||
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "conflict"};
|
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "conflict"};
|
||||||
static const char *const s_actions[] = {"start", "stop", "disconnect"};
|
enum { ROTATE = 3 };
|
||||||
|
static const char *const s_actions[] = {"start", "stop", "disconnect", "rotate"};
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uint32_t id;
|
uint32_t id;
|
||||||
web_session_id_t session;
|
web_session_id_t session;
|
||||||
user_principal_t principal;
|
user_principal_t principal;
|
||||||
int64_t deadline;
|
int64_t deadline;
|
||||||
uint32_t generation, target;
|
uint32_t generation, target, identity_generation;
|
||||||
ssh_transport_management_action_t action;
|
unsigned action;
|
||||||
unsigned state;
|
unsigned state;
|
||||||
} ssh_operation_t;
|
} ssh_operation_t;
|
||||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
static ssh_operation_t s_operation;
|
static ssh_operation_t s_operation;
|
||||||
static uint32_t s_next_id;
|
static uint32_t s_next_id;
|
||||||
|
|
||||||
/* Exact three-field flat JSON; no escapes, duplicates, extra fields or coercion. */
|
/* Three fields for service actions; rotation additionally requires identity_generation.
|
||||||
|
* No escapes, duplicates, extra fields or coercion. */
|
||||||
static bool parse(const char *body, size_t length, ssh_operation_t *operation)
|
static bool parse(const char *body, size_t length, ssh_operation_t *operation)
|
||||||
{
|
{
|
||||||
const char *keys[] = {"action", "generation", "target"};
|
const char *keys[] = {"action", "generation", "target", "identity_generation"};
|
||||||
unsigned seen = 0;
|
unsigned seen = 0;
|
||||||
size_t pos = 0;
|
size_t pos = 0;
|
||||||
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
|
#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)
|
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
|
||||||
TAKE('{');
|
TAKE('{');
|
||||||
for (unsigned field = 0; field < 3; ++field) {
|
for (unsigned field = 0; field < 4; ++field) {
|
||||||
if (field) { TAKE(','); }
|
if (field) { TAKE(','); }
|
||||||
TAKE('"');
|
TAKE('"');
|
||||||
size_t start = pos;
|
size_t start = pos;
|
||||||
while (pos < length && body[pos] != '"') ++pos;
|
while (pos < length && body[pos] != '"') ++pos;
|
||||||
if (pos == length) return false;
|
if (pos == length) return false;
|
||||||
unsigned key = 0;
|
unsigned key = 0;
|
||||||
for (; key < 3; ++key)
|
for (; key < 4; ++key)
|
||||||
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
||||||
if (key == 3 || (seen & (1U << key))) return false;
|
if (key == 4 || (seen & (1U << key))) return false;
|
||||||
++pos; TAKE(':'); SPACE();
|
++pos; TAKE(':'); SPACE();
|
||||||
if (key == 0) {
|
if (key == 0) {
|
||||||
TAKE('"'); start = pos;
|
TAKE('"'); start = pos;
|
||||||
while (pos < length && body[pos] != '"') ++pos;
|
while (pos < length && body[pos] != '"') ++pos;
|
||||||
if (pos == length) return false;
|
if (pos == length) return false;
|
||||||
unsigned action = 0;
|
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 (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 = (ssh_transport_management_action_t)action;
|
operation->action = action;
|
||||||
++pos;
|
++pos;
|
||||||
} else {
|
} else {
|
||||||
uint32_t number = 0;
|
uint32_t number = 0;
|
||||||
@@ -68,14 +72,19 @@ static bool parse(const char *body, size_t length, ssh_operation_t *operation)
|
|||||||
}
|
}
|
||||||
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
||||||
if (key == 1) operation->generation = number;
|
if (key == 1) operation->generation = number;
|
||||||
else operation->target = number;
|
else if (key == 2) operation->target = number;
|
||||||
|
else operation->identity_generation = number;
|
||||||
}
|
}
|
||||||
seen |= 1U << key;
|
seen |= 1U << key;
|
||||||
|
SPACE();
|
||||||
|
if (pos < length && body[pos] == '}') break;
|
||||||
}
|
}
|
||||||
TAKE('}'); SPACE();
|
TAKE('}'); SPACE();
|
||||||
#undef TAKE
|
#undef TAKE
|
||||||
#undef SPACE
|
#undef SPACE
|
||||||
return pos == length && seen == 7 && operation->generation &&
|
return pos == length &&
|
||||||
|
(operation->action == ROTATE ? seen == 15 && operation->identity_generation &&
|
||||||
|
operation->identity_generation != UINT32_MAX : seen == 7) && operation->generation &&
|
||||||
operation->generation != UINT32_MAX &&
|
operation->generation != UINT32_MAX &&
|
||||||
((operation->action == SSH_TRANSPORT_MANAGE_DISCONNECT) == (operation->target != 0U));
|
((operation->action == SSH_TRANSPORT_MANAGE_DISCONNECT) == (operation->target != 0U));
|
||||||
}
|
}
|
||||||
@@ -95,8 +104,12 @@ void web_ssh_settings_execute(uint32_t id)
|
|||||||
unsigned state = CANCELLED;
|
unsigned state = CANCELLED;
|
||||||
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
||||||
esp_timer_get_time() < operation.deadline) {
|
esp_timer_get_time() < operation.deadline) {
|
||||||
error = ssh_transport_manage_current(operation.action, operation.target, operation.generation);
|
bool committed = false;
|
||||||
|
error = operation.action == ROTATE
|
||||||
|
? ssh_transport_replace_identity(operation.generation, operation.identity_generation, false, &committed)
|
||||||
|
: ssh_transport_manage_current(operation.action, operation.target, operation.generation);
|
||||||
state = error == ESP_OK ? OK :
|
state = error == ESP_OK ? OK :
|
||||||
|
(operation.action == ROTATE) ? FAILED :
|
||||||
(error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED;
|
(error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED;
|
||||||
}
|
}
|
||||||
taskENTER_CRITICAL(&s_lock);
|
taskENTER_CRITICAL(&s_lock);
|
||||||
@@ -207,10 +220,26 @@ esp_err_t web_ssh_settings_handler(httpd_req_t *request)
|
|||||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"ssh_unavailable\"}");
|
error = respond(request, "503 Service Unavailable", "{\"error\":\"ssh_unavailable\"}");
|
||||||
goto done;
|
goto done;
|
||||||
}
|
}
|
||||||
|
ssh_security_identity_snapshot_t identity = {0};
|
||||||
|
unsigned char fingerprint[48] = {0};
|
||||||
|
size_t fingerprint_length = 0;
|
||||||
|
bool have_identity = ssh_security_get_identity_snapshot(&identity) == ESP_OK;
|
||||||
|
if (have_identity && mbedtls_base64_encode(fingerprint, sizeof(fingerprint), &fingerprint_length,
|
||||||
|
identity.metadata.sha256_fingerprint, sizeof(identity.metadata.sha256_fingerprint)) != 0) {
|
||||||
|
error = ESP_FAIL;
|
||||||
|
goto done;
|
||||||
|
}
|
||||||
|
while (fingerprint_length && fingerprint[fingerprint_length - 1] == '=') --fingerprint_length;
|
||||||
|
fingerprint[fingerprint_length] = 0;
|
||||||
char response[768];
|
char response[768];
|
||||||
int written = snprintf(response, sizeof(response),
|
int written = snprintf(response, sizeof(response),
|
||||||
"{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,\"sessions\":[",
|
"{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,"
|
||||||
snapshot.generation, snapshot.running ? "true" : "false", snapshot.transitioning ? "true" : "false");
|
"\"identity_generation\":%" PRIu32 ",\"algorithm\":\"%s\",\"fingerprint\":\"%s%s\",\"rotatable\":%s,\"sessions\":[",
|
||||||
|
snapshot.generation, snapshot.running ? "true" : "false", snapshot.transitioning ? "true" : "false",
|
||||||
|
have_identity ? identity.metadata.generation : 0, SSH_SECURITY_KEY_TYPE,
|
||||||
|
have_identity ? "SHA256:" : "", fingerprint,
|
||||||
|
have_identity && !identity.busy && identity.metadata.generation != UINT32_MAX &&
|
||||||
|
!snapshot.transitioning && snapshot.generation != UINT32_MAX ? "true" : "false");
|
||||||
if (written < 0 || (size_t)written >= sizeof(response)) { error = ESP_FAIL; goto done; }
|
if (written < 0 || (size_t)written >= sizeof(response)) { error = ESP_FAIL; goto done; }
|
||||||
size_t used = (size_t)written;
|
size_t used = (size_t)written;
|
||||||
unsigned count = 0;
|
unsigned count = 0;
|
||||||
|
|||||||
+9
-5
@@ -200,7 +200,7 @@ static const char s_index_html[] =
|
|||||||
"<button id=\"lifecycle-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"lifecycle-detail\" class=\"connection-detail\" role=\"status\"></p>"
|
"<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-rotate\" class=\"button\" type=\"button\">Rotate HTTPS identity…</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"
|
"<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=\"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-rotate\" class=\"button\" type=\"button\">Rotate SSH host identity…</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"
|
"<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"
|
||||||
"<div id=\"display-settings\" hidden><h2>Display</h2>\n"
|
"<div id=\"display-settings\" hidden><h2>Display</h2>\n"
|
||||||
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 0–86400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
|
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 0–86400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
|
||||||
@@ -953,7 +953,7 @@ static const char s_app_js[] =
|
|||||||
"element('lifecycle-refresh').addEventListener('click', () => lifecycleRequest(null, true));\n"
|
"element('lifecycle-refresh').addEventListener('click', () => lifecycleRequest(null, true));\n"
|
||||||
"element('lifecycle-result').addEventListener('click', () => lifecycleRequest(null));\n"
|
"element('lifecycle-result').addEventListener('click', () => lifecycleRequest(null));\n"
|
||||||
"for (const action of lifecycleActions) element('lifecycle-' + action).addEventListener('click', () => lifecycleRequest(action));\n"
|
"for (const action of lifecycleActions) element('lifecycle-' + action).addEventListener('click', () => lifecycleRequest(action));\n"
|
||||||
"const sshActions = ['start','stop','disconnect'];\n"
|
"const sshActions = ['start','stop','disconnect','rotate'];\n"
|
||||||
"let sshSnapshot = null, sshSelection = null, sshAbort = null, sshPending = false, sshAwaitingAck = false, sshId = 0, sshAction = '';\n"
|
"let sshSnapshot = null, sshSelection = null, sshAbort = null, sshPending = false, sshAwaitingAck = false, sshId = 0, sshAction = '';\n"
|
||||||
"const sshLabel = s => String(s.id) + ' / ' + ['Handshake','Serial','Admin console'][s.route] + ' / ' + (brokerName(s) || 'not authenticated');\n"
|
"const sshLabel = s => String(s.id) + ' / ' + ['Handshake','Serial','Admin console'][s.route] + ' / ' + (brokerName(s) || 'not authenticated');\n"
|
||||||
"function sshButtons() {\n"
|
"function sshButtons() {\n"
|
||||||
@@ -963,6 +963,7 @@ static const char s_app_js[] =
|
|||||||
" element('ssh-start').disabled = busy || sshPending || !available || sshSnapshot.running;\n"
|
" element('ssh-start').disabled = busy || sshPending || !available || sshSnapshot.running;\n"
|
||||||
" element('ssh-stop').disabled = busy || sshPending || !available || !sshSnapshot.running;\n"
|
" element('ssh-stop').disabled = busy || sshPending || !available || !sshSnapshot.running;\n"
|
||||||
" element('ssh-disconnect').disabled = busy || sshPending || !available || !sshSelection || sshSelection.stale;\n"
|
" element('ssh-disconnect').disabled = busy || sshPending || !available || !sshSelection || sshSelection.stale;\n"
|
||||||
|
" element('ssh-rotate').disabled = busy || sshPending || !available || !sshSnapshot.rotatable;\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
"function clearSsh() {\n"
|
"function clearSsh() {\n"
|
||||||
" if (sshAbort) sshAbort.abort(); sshAbort = null; sshSnapshot = null; sshSelection = null;\n"
|
" if (sshAbort) sshAbort.abort(); sshAbort = null; sshSnapshot = null; sshSelection = null;\n"
|
||||||
@@ -973,7 +974,7 @@ static const char s_app_js[] =
|
|||||||
" sshButtons();\n"
|
" sshButtons();\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
"function sshValid(v) {\n"
|
"function sshValid(v) {\n"
|
||||||
" return v && Object.keys(v).length === 4 && brokerUint(v.generation) && v.generation > 0 && typeof v.running === 'boolean' && typeof v.transitioning === 'boolean' &&\n"
|
" return v && Object.keys(v).length === 8 && brokerUint(v.identity_generation) && v.algorithm === 'ecdsa-sha2-nistp256' && typeof v.rotatable === 'boolean' && typeof v.fingerprint === 'string' && (v.identity_generation ? /^SHA256:[A-Za-z0-9+/]{43}$/.test(v.fingerprint) : v.fingerprint === '') && (!v.rotatable || (v.identity_generation > 0 && v.identity_generation < 4294967295 && !v.transitioning && v.generation < 4294967295)) && brokerUint(v.generation) && v.generation > 0 && typeof v.running === 'boolean' && typeof v.transitioning === 'boolean' &&\n"
|
||||||
" Array.isArray(v.sessions) && v.sessions.length <= 2 && new Set(v.sessions.map(s => s.id)).size === v.sessions.length && v.sessions.every(s =>\n"
|
" Array.isArray(v.sessions) && v.sessions.length <= 2 && new Set(v.sessions.map(s => s.id)).size === v.sessions.length && v.sessions.every(s =>\n"
|
||||||
" s && Object.keys(s).length === 5 && brokerUint(s.id) && s.id > 0 && Number.isInteger(s.state) && s.state >= 1 && s.state <= 3 &&\n"
|
" s && Object.keys(s).length === 5 && brokerUint(s.id) && s.id > 0 && Number.isInteger(s.state) && s.state >= 1 && s.state <= 3 &&\n"
|
||||||
" Number.isInteger(s.route) && s.route >= 0 && s.route <= 2 && typeof s.closing === 'boolean' && typeof s.name_hex === 'string' && /^(?:[0-9a-f]{2}){0,16}$/.test(s.name_hex));\n"
|
" Number.isInteger(s.route) && s.route >= 0 && s.route <= 2 && typeof s.closing === 'boolean' && typeof s.name_hex === 'string' && /^(?:[0-9a-f]{2}){0,16}$/.test(s.name_hex));\n"
|
||||||
@@ -987,8 +988,10 @@ static const char s_app_js[] =
|
|||||||
" if (action === 'disconnect' && (!sshSelection || sshSelection.stale || !row || row.closing || row.state === 3 || sshSelection.target !== target || sshSelection.generation !== sshSnapshot.generation)) return;\n"
|
" if (action === 'disconnect' && (!sshSelection || sshSelection.stale || !row || row.closing || row.state === 3 || sshSelection.target !== target || sshSelection.generation !== sshSnapshot.generation)) return;\n"
|
||||||
" if ((action === 'start' && sshSnapshot.running) || (action === 'stop' && !sshSnapshot.running)) return;\n"
|
" if ((action === 'start' && sshSnapshot.running) || (action === 'stop' && !sshSnapshot.running)) return;\n"
|
||||||
" const value = {action, generation: action === 'disconnect' ? sshSelection.generation : sshSnapshot.generation, target: action === 'disconnect' ? target : 0};\n"
|
" const value = {action, generation: action === 'disconnect' ? sshSelection.generation : sshSnapshot.generation, target: action === 'disconnect' ? target : 0};\n"
|
||||||
|
" if (action === 'rotate') { if (!sshSnapshot.rotatable) return; value.identity_generation = sshSnapshot.identity_generation; }\n"
|
||||||
" const scope = action === 'disconnect' ? 'Disconnect only SSH session ' + sshLabel(row) + '?' : action === 'stop' ? 'Stop SSH and close ALL SSH sessions, including sessions admitted before execution?' : 'Start the SSH listener on TCP port 22?';\n"
|
" const scope = action === 'disconnect' ? 'Disconnect only SSH session ' + sshLabel(row) + '?' : action === 'stop' ? 'Stop SSH and close ALL SSH sessions, including sessions admitted before execution?' : 'Start the SSH listener on TCP port 22?';\n"
|
||||||
" if (!window.confirm(scope + ' Settings and host identity are unchanged. HTTPS, Wi-Fi, USB and UART0 remain available. Already executing SSH commands may finish.')) return;\n"
|
" const rotation = 'Rotate and persist SSH host identity ' + sshSnapshot.algorithm + ', ' + sshSnapshot.fingerprint + ', identity generation ' + sshSnapshot.identity_generation + ', service generation ' + sshSnapshot.generation + '? ALL SSH sessions close, including those admitted before execution. Stopped SSH remains stopped. known_hosts trust changes: verify the NEW fingerprint over trusted UART0 using ssh host-key info BEFORE accepting it. Do not blindly remove known_hosts warnings. Stop/persistence/restart can fail after partial effects: SSH may be disconnected even if persistence fails, or the new identity may be saved while restart fails. HTTPS stays accessible; accounts, Wi-Fi, USB UART1 and UART0 are unchanged. Already executing SSH commands may finish. No automatic retry.';\n"
|
||||||
|
" if (!window.confirm(action === 'rotate' ? rotation : scope + ' Settings and host identity are unchanged. HTTPS, Wi-Fi, USB and UART0 remain available. Already executing SSH commands may finish.')) return;\n"
|
||||||
" body = JSON.stringify(value);\n"
|
" body = JSON.stringify(value);\n"
|
||||||
" }\n"
|
" }\n"
|
||||||
" const controller = new AbortController(), generation = workGeneration; sshAbort = controller; sshButtons();\n"
|
" const controller = new AbortController(), generation = workGeneration; sshAbort = controller; sshButtons();\n"
|
||||||
@@ -1005,6 +1008,7 @@ static const char s_app_js[] =
|
|||||||
" sshSnapshot = v;\n"
|
" sshSnapshot = v;\n"
|
||||||
" if (sshSelection && (sshSelection.generation !== v.generation || v.transitioning || !v.sessions.some(s => s.id === sshSelection.target && s.route === sshSelection.route && s.name_hex === sshSelection.name_hex && !s.closing && s.state !== 3))) sshSelection.stale = true;\n"
|
" if (sshSelection && (sshSelection.generation !== v.generation || v.transitioning || !v.sessions.some(s => s.id === sshSelection.target && s.route === sshSelection.route && s.name_hex === sshSelection.name_hex && !s.closing && s.state !== 3))) sshSelection.stale = true;\n"
|
||||||
" const list = element('ssh-values'); list.textContent = '';\n"
|
" const list = element('ssh-values'); list.textContent = '';\n"
|
||||||
|
" for (const [label, value] of [['Stored host algorithm',v.algorithm],['Stored fingerprint',v.fingerprint || 'Unavailable'],['Identity generation',v.identity_generation],['Service generation',v.generation]]) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = label; dd.textContent = String(value); list.appendChild(dt); list.appendChild(dd); }\n"
|
||||||
" for (let i = 0; i < 2; ++i) {\n"
|
" for (let i = 0; i < 2; ++i) {\n"
|
||||||
" const s = v.sessions[i], o = element('ssh-option-' + i); o.hidden = !s; o.disabled = !s || s.closing || s.state === 3; o.value = s ? String(s.id) : ''; o.textContent = s ? sshLabel(s) : '';\n"
|
" const s = v.sessions[i], o = element('ssh-option-' + i); o.hidden = !s; o.disabled = !s || s.closing || s.state === 3; o.value = s ? String(s.id) : ''; o.textContent = s ? sshLabel(s) : '';\n"
|
||||||
" if (s) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = sshLabel(s); dd.textContent = s.closing ? 'Close requested' : ['Free','Handshake','Active','Closing'][s.state]; list.appendChild(dt); list.appendChild(dd); }\n"
|
" if (s) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = sshLabel(s); dd.textContent = s.closing ? 'Close requested' : ['Free','Handshake','Active','Closing'][s.state]; list.appendChild(dt); list.appendChild(dd); }\n"
|
||||||
@@ -1016,7 +1020,7 @@ static const char s_app_js[] =
|
|||||||
" const warning = !action && sshAwaitingAck ? 'Acknowledgement was lost; latest result may belong to another tab or earlier operation. ' : !action && sshId && sshId !== v.id ? 'Previous result replaced/unavailable; previous outcome unknown. ' : '';\n"
|
" const warning = !action && sshAwaitingAck ? 'Acknowledgement was lost; latest result may belong to another tab or earlier operation. ' : !action && sshId && sshId !== v.id ? 'Previous result replaced/unavailable; previous outcome unknown. ' : '';\n"
|
||||||
" sshId = v.id; sshAction = v.action; sshPending = v.state === 'pending'; sshAwaitingAck = false; sshSnapshot = null; if (sshSelection) sshSelection.stale = true;\n"
|
" sshId = v.id; sshAction = v.action; sshPending = v.state === 'pending'; sshAwaitingAck = false; sshSnapshot = null; if (sshSelection) sshSelection.stale = true;\n"
|
||||||
" const messages = {idle:'No retained result; inspect SSH before retrying.',pending:'Queued or executing. Select Check Operation Result; do not resubmit.',ok:'Accepted/completed at execution time. Disconnect acknowledgement is an owner close request, not proof of peer closure. Refresh to inspect.',failed:'Failed or lifecycle timed out. Admitted work may still finish. Refresh and inspect before retrying.',conflict:'Service changed, unavailable, or target absent/closing. No action admitted; Refresh and reselect.',cancelled:'Rejected before execution: login/currentness or queue deadline expired.'};\n"
|
" const messages = {idle:'No retained result; inspect SSH before retrying.',pending:'Queued or executing. Select Check Operation Result; do not resubmit.',ok:'Accepted/completed at execution time. Disconnect acknowledgement is an owner close request, not proof of peer closure. Refresh to inspect.',failed:'Failed or lifecycle timed out. Admitted work may still finish. Refresh and inspect before retrying.',conflict:'Service changed, unavailable, or target absent/closing. No action admitted; Refresh and reselect.',cancelled:'Rejected before execution: login/currentness or queue deadline expired.'};\n"
|
||||||
" detail.textContent = warning + v.action + ': ' + messages[v.state];\n"
|
" detail.textContent = warning + v.action + ': ' + messages[v.state] + (v.action === 'rotate' ? ' SSH may have disconnected even if persistence failed; a new key may be persisted even if restart failed. HTTPS stays accessible. Check stored identity and service state; verify the NEW fingerprint with trusted UART0 ssh host-key info before accepting changed known_hosts trust. No automatic retry.' : '');\n"
|
||||||
" element('ssh-detail').textContent = 'Snapshot stale. Select Refresh to inspect current service state.';\n"
|
" element('ssh-detail').textContent = 'Snapshot stale. Select Refresh to inspect current service state.';\n"
|
||||||
" }\n"
|
" }\n"
|
||||||
" } catch (error) {\n"
|
" } catch (error) {\n"
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ static bool s_initialized, s_running, s_transitioning, s_cleanup_pending, s_desi
|
|||||||
static uint32_t s_management_generation, s_requested_sequence, s_completed_sequence;
|
static uint32_t s_management_generation, s_requested_sequence, s_completed_sequence;
|
||||||
static int s_command_result;
|
static int s_command_result;
|
||||||
static bool owner_stalled, owner_fail;
|
static bool owner_stalled, owner_fail;
|
||||||
|
static uint32_t identity_generation = 3, identity_token;
|
||||||
|
static unsigned replacements;
|
||||||
|
static bool persist_fail;
|
||||||
|
static esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token) {
|
||||||
|
(void)reset; assert(mutex_storage && !depth); *token = 0;
|
||||||
|
if (identity_token || (generation && generation != identity_generation)) return ESP_ERR_INVALID_STATE;
|
||||||
|
*token = identity_token = 1; return ESP_OK;
|
||||||
|
}
|
||||||
|
static esp_err_t ssh_security_replace_reserved(uint32_t token) {
|
||||||
|
assert(token == identity_token && mutex_storage && !depth && !s_running && !s_cleanup_pending);
|
||||||
|
++replacements; if (persist_fail) return ESP_FAIL; ++identity_generation; return ESP_OK;
|
||||||
|
}
|
||||||
|
static void ssh_security_release_identity(uint32_t token) { if (token) { assert(identity_token == token && mutex_storage && !depth); identity_token = 0; } }
|
||||||
static int s_lock;
|
static int s_lock;
|
||||||
#define taskENTER_CRITICAL(p) do { (void)(p); assert(depth++ == 0); } while(0)
|
#define taskENTER_CRITICAL(p) do { (void)(p); assert(depth++ == 0); } while(0)
|
||||||
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(--depth == 0); } while(0)
|
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(--depth == 0); } while(0)
|
||||||
@@ -71,6 +84,24 @@ static void reset(void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
int main(void) {
|
int main(void) {
|
||||||
|
reset(); bool committed = true;
|
||||||
|
assert(ssh_transport_replace_identity(6,3,false,&committed)==ESP_ERR_INVALID_STATE && !committed && !notifications && !replacements);
|
||||||
|
assert(ssh_transport_replace_identity(7,2,false,&committed)==ESP_ERR_INVALID_STATE && !notifications && !replacements);
|
||||||
|
identity_token=1; assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_ERR_INVALID_STATE && !notifications); identity_token=0;
|
||||||
|
mutex_storage=1; assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_ERR_TIMEOUT && !notifications); mutex_storage=0;
|
||||||
|
owner_fail=true; assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_FAIL && !committed && !replacements && notifications==1 && s_cleanup_pending && !identity_token);
|
||||||
|
assert(ssh_transport_start()==ESP_ERR_INVALID_STATE && notifications==1);
|
||||||
|
reset(); owner_stalled=true;
|
||||||
|
assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_ERR_TIMEOUT && !committed && !replacements && notifications==1 && s_transitioning && !identity_token);
|
||||||
|
reset(); persist_fail=true;
|
||||||
|
assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_FAIL && !committed && replacements==1 && notifications==2 && s_running && identity_generation==3);
|
||||||
|
reset(); persist_fail=false;
|
||||||
|
assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_OK && committed && s_running && identity_generation==4 && notifications==2);
|
||||||
|
assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_ERR_INVALID_STATE && !committed && notifications==2);
|
||||||
|
reset(); s_running=false;
|
||||||
|
assert(ssh_transport_replace_identity(7,4,false,&committed)==ESP_OK && committed && !s_running && !notifications);
|
||||||
|
assert(ssh_transport_replace_host_key(true)==ESP_OK && s_running && notifications==1);
|
||||||
|
puts("PASS SSH combined generation/service-owner admission, competing reservation, failed-stop no mutation/start, timeout retention, persistence recovery, replay fence and stopped/reset semantics");
|
||||||
reset(); ssh_transport_management_snapshot_t v;
|
reset(); ssh_transport_management_snapshot_t v;
|
||||||
assert(ssh_transport_get_management_snapshot(NULL) == ESP_ERR_INVALID_ARG);
|
assert(ssh_transport_get_management_snapshot(NULL) == ESP_ERR_INVALID_ARG);
|
||||||
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.generation == 7 && v.running && !v.transitioning);
|
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.generation == 7 && v.running && !v.transitioning);
|
||||||
@@ -128,7 +159,7 @@ int main(void) {
|
|||||||
puts("PASS SSH admitted timeout is not cancellation; failed cleanup and saturated versions preserve CLI recovery");
|
puts("PASS SSH admitted timeout is not cancellation; failed cleanup and saturated versions preserve CLI recovery");
|
||||||
}
|
}
|
||||||
'''
|
'''
|
||||||
names = ('next_generation', 'make_session_id', 'consume_external_close', 'find_free_slot', 'request_running_locked', 'request_running', 'ssh_transport_start', 'ssh_transport_stop', 'ssh_transport_get_management_snapshot', 'ssh_transport_manage_current')
|
names = ('next_generation', 'make_session_id', 'consume_external_close', 'find_free_slot', 'request_running_locked', 'request_running', 'ssh_transport_start', 'ssh_transport_stop', 'ssh_transport_replace_identity', 'ssh_transport_replace_host_key', 'ssh_transport_get_management_snapshot', 'ssh_transport_manage_current')
|
||||||
# Guard the accept path, which is not executed with the socket double here.
|
# Guard the accept path, which is not executed with the socket double here.
|
||||||
assert 'uint32_t generation = slot->generation + 1U;' in function('accept_connections')
|
assert 'uint32_t generation = slot->generation + 1U;' in function('accept_connections')
|
||||||
assert 'next_generation(slot->generation)' not in source
|
assert 'next_generation(slot->generation)' not in source
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exact production stop/start/process-slot functions with retained-resource doubles."""
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
root=Path(__file__).resolve().parents[2]
|
||||||
|
source=(root/'src/ssh_transport.c').read_text()
|
||||||
|
def function(name):
|
||||||
|
m=re.search(r'^static [^\n]+\b'+name+r'\([^;]*?\n\{.*?^\}',source,re.M|re.S)
|
||||||
|
assert m,name
|
||||||
|
return m.group()+'\n'
|
||||||
|
fakes=r'''
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#define SSH_TRANSPORT_MAX_SESSIONS 2
|
||||||
|
#define SSH_TRANSPORT_SESSION_FREE 0
|
||||||
|
#define SSH_TRANSPORT_SESSION_CLOSING 3
|
||||||
|
#define SSH_TRANSPORT_SESSION_HANDSHAKE 1
|
||||||
|
#define SSH_TRANSPORT_SESSION_ACTIVE 2
|
||||||
|
#define ESP_OK 0
|
||||||
|
#define ESP_FAIL 1
|
||||||
|
#define ESP_ERR_TIMEOUT 2
|
||||||
|
#define ESP_ERR_INVALID_STATE 3
|
||||||
|
#define pdMS_TO_TICKS(n) (n)
|
||||||
|
typedef int esp_err_t;
|
||||||
|
typedef struct { int state; bool close_requested; } ssh_slot_t;
|
||||||
|
static ssh_slot_t s_slots[2];
|
||||||
|
static void *s_context;
|
||||||
|
static int s_listen_fd=-1, s_lock;
|
||||||
|
static unsigned depth, frees, creates;
|
||||||
|
static bool s_running, s_cleanup_pending, cleanup_fail, listener_fail;
|
||||||
|
#define taskENTER_CRITICAL(p) do { (void)(p); assert(!depth++); } while(0)
|
||||||
|
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(!--depth); } while(0)
|
||||||
|
static void wolfSSH_CTX_free(void *p) { assert(!depth && p==s_context); for(unsigned i=0;i<2;++i) assert(!s_slots[i].state); ++frees; }
|
||||||
|
static void close_socket(int *fd) { assert(!depth); *fd=-1; }
|
||||||
|
static void vTaskDelay(unsigned n) { (void)n; assert(!depth); }
|
||||||
|
static void request_slot_close(ssh_slot_t *s,bool revoked) { (void)revoked; if(s->state)s->close_requested=true; }
|
||||||
|
static bool cleanup_slot(ssh_slot_t *s) { if(cleanup_fail)return false; s->state=0;return true; }
|
||||||
|
static void publish_slot(ssh_slot_t *s,size_t i) { (void)s;(void)i; }
|
||||||
|
static bool consume_external_close(ssh_slot_t *s,size_t i) { (void)s;(void)i;return false; }
|
||||||
|
static void process_handshake(ssh_slot_t *s,size_t i) { (void)s;(void)i;assert(0); }
|
||||||
|
static void process_active(ssh_slot_t *s,size_t i) { (void)s;(void)i;assert(0); }
|
||||||
|
static esp_err_t create_context(void) { assert(!s_context && !depth); ++creates;s_context=(void *)1;return ESP_OK; }
|
||||||
|
static esp_err_t create_listener(void) { if(listener_fail)return ESP_FAIL;s_listen_fd=22;return ESP_OK; }
|
||||||
|
'''
|
||||||
|
tests=r'''
|
||||||
|
int main(void) {
|
||||||
|
assert(start_runtime()==ESP_OK && creates==1);
|
||||||
|
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1 && !frees);
|
||||||
|
s_slots[0].state=2;cleanup_fail=true;
|
||||||
|
assert(stop_runtime()==ESP_ERR_TIMEOUT && s_context && !frees && s_listen_fd==-1);
|
||||||
|
s_cleanup_pending=true;s_running=false;
|
||||||
|
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1);
|
||||||
|
process_slots();assert(s_context && s_cleanup_pending && !frees);
|
||||||
|
cleanup_fail=false;process_slots();assert(!s_context && !s_cleanup_pending && frees==1);
|
||||||
|
process_slots();assert(frees==1);
|
||||||
|
assert(start_runtime()==ESP_OK && creates==2);assert(stop_runtime()==ESP_OK && frees==2);
|
||||||
|
listener_fail=true;assert(start_runtime()==ESP_FAIL && !s_context && frees==3 && s_listen_fd==-1);
|
||||||
|
s_slots[1].state=2;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);s_slots[1].state=0;
|
||||||
|
s_listen_fd=22;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);
|
||||||
|
puts("PASS SSH actual runtime stop failure retains context, rejects orphan overwrite, owner retires only after all slots free, failed listener frees context exactly once");
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
with tempfile.TemporaryDirectory(prefix='ssh-runtime-') as directory:
|
||||||
|
out=Path(directory)
|
||||||
|
(out/'test.c').write_text(fakes+''.join(function(n) for n in ('start_runtime','stop_runtime','process_slots'))+tests)
|
||||||
|
subprocess.run(['cc','-std=c11','-Wall','-Wextra','-Werror',str(out/'test.c'),'-o',str(out/'test')],check=True,timeout=30)
|
||||||
|
subprocess.run([str(out/'test')],check=True,timeout=10)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/* Full canonical storage/crypto; no hardware/power-loss/scheduler claims. */
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <sys/random.h>
|
||||||
|
#include "../../src/ssh_security.c"
|
||||||
|
static unsigned depth, handles, wipes;
|
||||||
|
static bool locked, busy, rng_fail, command_locked;
|
||||||
|
static SemaphoreHandle_t s_command_mutex = (void *)2;
|
||||||
|
static int s_lock;
|
||||||
|
static bool s_initialized=true, s_running=true, s_transitioning, s_cleanup_pending, s_desired_running;
|
||||||
|
static uint32_t s_management_generation=7, s_requested_sequence, s_completed_sequence;
|
||||||
|
static esp_err_t s_command_result;
|
||||||
|
static unsigned ticks, notifications;
|
||||||
|
static bool stop_fail, start_fail;
|
||||||
|
typedef unsigned TickType_t;
|
||||||
|
#define pdMS_TO_TICKS(n) (n)
|
||||||
|
#define SSH_TRANSPORT_COMMAND_TIMEOUT_MS 100
|
||||||
|
static unsigned xTaskGetTickCount(void) { return ticks; }
|
||||||
|
static void notify_task(void) { assert(!depth && command_locked); ++notifications; }
|
||||||
|
static int fault;
|
||||||
|
static void *task = (void *)1;
|
||||||
|
static void (*hook)(void);
|
||||||
|
static ssh_security_blob_t stored, pending, before;
|
||||||
|
static bool present, staged;
|
||||||
|
void enter(void) { assert(!depth++); }
|
||||||
|
void leave(void) { assert(!--depth); }
|
||||||
|
TaskHandle_t xTaskGetCurrentTaskHandle(void) { return task; }
|
||||||
|
void vTaskDelay(unsigned n) {
|
||||||
|
assert(command_locked && !depth && !locked); ticks+=n;
|
||||||
|
s_completed_sequence=s_requested_sequence;
|
||||||
|
s_command_result=(s_desired_running ? start_fail : stop_fail) ? ESP_FAIL : ESP_OK;
|
||||||
|
s_running=s_command_result==ESP_OK && s_desired_running;
|
||||||
|
s_cleanup_pending=s_command_result!=ESP_OK; s_transitioning=false;
|
||||||
|
}
|
||||||
|
SemaphoreHandle_t xSemaphoreCreateMutex(void) { assert(!depth); return (void *)1; }
|
||||||
|
int xSemaphoreTake(SemaphoreHandle_t m, unsigned wait) { if(m==s_command_mutex) { assert(!depth);if(command_locked){assert(!wait);return 0;}command_locked=true;return 1;} assert(m && !depth && !locked); if (busy) { assert(!wait); return 0; } locked = true; return 1; }
|
||||||
|
int xSemaphoreGive(SemaphoreHandle_t m) { if(m==s_command_mutex){assert(!depth && command_locked);command_locked=false;return 1;} assert(m && locked && !depth); locked = false; return 1; }
|
||||||
|
void secure_wipe(void *p, size_t n) { volatile unsigned char *b = p; for (size_t i=0;i<n;++i) b[i]=0; ++wipes; }
|
||||||
|
esp_err_t secure_random_init(void) { assert(!depth); return ESP_OK; }
|
||||||
|
esp_err_t secure_random_fill(void *p, size_t n) {
|
||||||
|
assert(!depth && (!s_identity_token || !locked));
|
||||||
|
if (hook) { void (*f)(void)=hook; hook=NULL; f(); }
|
||||||
|
return !rng_fail && getrandom(p,n,0)==(ssize_t)n ? ESP_OK : ESP_FAIL;
|
||||||
|
}
|
||||||
|
int secure_random_mbedtls(void *ctx, unsigned char *p, size_t n) { (void)ctx; return secure_random_fill(p,n)==ESP_OK ? 0 : -1; }
|
||||||
|
esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *h) {
|
||||||
|
assert(!depth && !strcmp(name,SSH_SECURITY_NVS_NAMESPACE));
|
||||||
|
if (fault==1 && mode==NVS_READWRITE) return ESP_FAIL;
|
||||||
|
assert(!handles++); *h=mode; return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t nvs_get_blob(nvs_handle_t h,const char *key,void *p,size_t *n) {
|
||||||
|
assert(handles && h==NVS_READONLY && !strcmp(key,"material"));
|
||||||
|
if (!present) return ESP_ERR_NVS_NOT_FOUND;
|
||||||
|
if (p) { assert(*n>=sizeof(stored)); memcpy(p,&stored,sizeof(stored)); }
|
||||||
|
*n=sizeof(stored); return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t nvs_set_blob(nvs_handle_t h,const char *key,const void *p,size_t n) {
|
||||||
|
assert(handles && h==NVS_READWRITE && !strcmp(key,"material") && n==312 && !depth);
|
||||||
|
if (s_identity_token) assert(!locked && !memcmp(&s_material,&before,sizeof(before)));
|
||||||
|
if (fault==2) return ESP_FAIL;
|
||||||
|
memcpy(&pending,p,n); staged=true; return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t nvs_commit(nvs_handle_t h) {
|
||||||
|
assert(handles && h==NVS_READWRITE && staged && !depth);
|
||||||
|
if (fault==3) return ESP_FAIL;
|
||||||
|
stored=pending; present=true; return ESP_OK;
|
||||||
|
}
|
||||||
|
void nvs_close(nvs_handle_t h) { (void)h; assert(handles--==1); staged=false; secure_wipe(&pending,sizeof(pending)); }
|
||||||
|
#include "owner.inc"
|
||||||
|
static void competitor(void) {
|
||||||
|
assert(!depth && !locked);
|
||||||
|
ssh_security_identity_snapshot_t v;
|
||||||
|
assert(ssh_security_get_identity_snapshot(&v)==ESP_OK && v.busy);
|
||||||
|
assert(v.metadata.generation==before.generation);
|
||||||
|
assert(!memcmp(v.metadata.sha256_fingerprint,before.sha256_fingerprint,32));
|
||||||
|
assert(ssh_security_rotate()==ESP_ERR_INVALID_STATE);
|
||||||
|
assert(ssh_security_reset()==ESP_ERR_INVALID_STATE);
|
||||||
|
task=(void *)2;
|
||||||
|
if(command_locked) {
|
||||||
|
bool committed;
|
||||||
|
assert(ssh_transport_replace_identity(s_management_generation,before.generation,false,&committed)==ESP_ERR_TIMEOUT);
|
||||||
|
assert(ssh_transport_replace_host_key(true)==ESP_ERR_TIMEOUT);
|
||||||
|
|
||||||
|
}
|
||||||
|
assert(ssh_security_replace_reserved(s_identity_token)==ESP_ERR_INVALID_STATE);
|
||||||
|
uint32_t token=s_identity_token; ssh_security_release_identity(token); assert(s_identity_token==token);
|
||||||
|
task=(void *)1;
|
||||||
|
}
|
||||||
|
int main(void) {
|
||||||
|
ssh_security_load_result_t result;
|
||||||
|
assert(ssh_security_init(&result)==ESP_OK && result==SSH_SECURITY_LOAD_GENERATED_MISSING);
|
||||||
|
assert(s_material.generation==1 && validate_blob(&s_material)==ESP_OK && !handles);
|
||||||
|
before=s_material;
|
||||||
|
uint8_t der[256]; size_t size=0;
|
||||||
|
assert(ssh_security_copy_private_key(der,sizeof(der),&size)==ESP_OK && size==before.private_key_length);
|
||||||
|
assert(!memcmp(der,before.private_key_der,size)); secure_wipe(der,sizeof(der));
|
||||||
|
s_material_ready=false; assert(ssh_security_init(&result)==ESP_OK && !memcmp(&s_material,&before,sizeof(before)));
|
||||||
|
puts("PASS SSH real P256 generation/validation, bounded DER copy, exact persisted reload and handle closure");
|
||||||
|
for (fault=1;fault<=3;++fault) {
|
||||||
|
before=s_material; hook=competitor;
|
||||||
|
assert(ssh_security_rotate()!=ESP_OK && !s_identity_token && !handles);
|
||||||
|
assert(!memcmp(&s_material,&before,sizeof(before)) && !memcmp(&stored,&before,sizeof(before)));
|
||||||
|
}
|
||||||
|
fault=0; rng_fail=true; before=s_material;
|
||||||
|
assert(ssh_security_rotate()!=ESP_OK && !s_identity_token && !handles);
|
||||||
|
assert(!memcmp(&s_material,&before,sizeof(before))); rng_fail=false;
|
||||||
|
puts("PASS SSH real crypto RNG/NVS open-set-commit faults, unchanged live/stored bytes, reservation exclusion outside locks and wipes");
|
||||||
|
bool committed;
|
||||||
|
before=s_material;
|
||||||
|
unsigned old_notifications=notifications;
|
||||||
|
assert(ssh_transport_replace_identity(6,1,false,&committed)==ESP_ERR_INVALID_STATE && notifications==old_notifications);
|
||||||
|
assert(ssh_transport_replace_identity(7,2,false,&committed)==ESP_ERR_INVALID_STATE && notifications==old_notifications);
|
||||||
|
stop_fail=true;
|
||||||
|
assert(ssh_transport_replace_identity(7,1,false,&committed)==ESP_FAIL && !committed && notifications==old_notifications+1);
|
||||||
|
assert(!memcmp(&s_material,&before,sizeof(before)) && !memcmp(&stored,&before,sizeof(before)));
|
||||||
|
stop_fail=false;assert(ssh_transport_stop()==ESP_OK);assert(ssh_transport_start()==ESP_OK);
|
||||||
|
for(fault=1;fault<=3;++fault) {
|
||||||
|
before=s_material;hook=competitor;
|
||||||
|
assert(ssh_transport_replace_identity(s_management_generation,1,false,&committed)==ESP_FAIL && !committed && s_running);
|
||||||
|
assert(!memcmp(&s_material,&before,sizeof(before)) && !memcmp(&stored,&before,sizeof(before)) && !handles);
|
||||||
|
}
|
||||||
|
fault=0;start_fail=true;before=s_material;hook=competitor;
|
||||||
|
assert(ssh_transport_replace_identity(s_management_generation,1,false,&committed)==ESP_FAIL && committed && !s_running);
|
||||||
|
assert(s_material.generation==2 && !memcmp(&stored,&s_material,sizeof(stored)));
|
||||||
|
start_fail=false;assert(ssh_transport_stop()==ESP_OK);assert(ssh_transport_start()==ESP_OK);
|
||||||
|
puts("PASS integrated canonical SSH owner + real crypto/NVS: stale admission untouched, failed stop skips crypto/start, persistence failures restore old identity, committed restart failure never rolls back, competing CLI/direct owners excluded");
|
||||||
|
before=s_material; hook=competitor; assert(ssh_security_rotate()==ESP_OK);
|
||||||
|
assert(s_material.generation==3 && memcmp(before.sha256_fingerprint,s_material.sha256_fingerprint,32));
|
||||||
|
assert(validate_blob(&s_material)==ESP_OK && !memcmp(&stored,&s_material,sizeof(stored)));
|
||||||
|
uint32_t token=0, newer=0;
|
||||||
|
assert(ssh_security_reserve_identity(1,false,&token)==ESP_ERR_INVALID_STATE && !token);
|
||||||
|
assert(ssh_security_reserve_identity(3,false,&token)==ESP_OK);
|
||||||
|
ssh_security_release_identity(token);
|
||||||
|
assert(ssh_security_reserve_identity(3,false,&newer)==ESP_OK && newer!=token);
|
||||||
|
ssh_security_release_identity(token); assert(s_identity_token==newer);
|
||||||
|
assert(ssh_security_replace_reserved(token)==ESP_ERR_INVALID_STATE);
|
||||||
|
before=s_material; assert(ssh_security_replace_reserved(newer)==ESP_OK);
|
||||||
|
assert(ssh_security_replace_reserved(newer)==ESP_ERR_INVALID_STATE); ssh_security_release_identity(newer);
|
||||||
|
puts("PASS SSH expected generation, owner-only nonreused token, stale release/replace and one-shot replacement");
|
||||||
|
ssh_security_identity_snapshot_t v;
|
||||||
|
busy=true; assert(ssh_security_get_identity_snapshot(&v)==ESP_ERR_TIMEOUT && !v.metadata.generation);
|
||||||
|
busy=false; s_next_identity_token=UINT32_MAX;
|
||||||
|
assert(ssh_security_get_identity_snapshot(&v)==ESP_OK && v.busy);
|
||||||
|
assert(ssh_security_rotate()==ESP_ERR_INVALID_STATE);
|
||||||
|
s_next_identity_token=0; s_material.generation=UINT32_MAX;
|
||||||
|
assert(ssh_security_reset()==ESP_ERR_INVALID_STATE);
|
||||||
|
s_material_ready=false; stored.schema_version=99;
|
||||||
|
assert(ssh_security_init(NULL)==ESP_ERR_INVALID_VERSION && stored.schema_version==99);
|
||||||
|
before=s_material; assert(ssh_security_reset()==ESP_OK && s_material.generation==1 && validate_blob(&s_material)==ESP_OK);
|
||||||
|
assert(!handles && !locked && !depth && wipes);
|
||||||
|
puts("PASS SSH zero-wait public snapshot, saturation, corrupt-material fail-closed and canonical reset recovery");
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Full production SSH security with real mbedTLS and fault-injected NVS/RTOS."""
|
||||||
|
import ast
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
tree = ast.parse((ROOT / 'tests/web_security/run.py').read_text())
|
||||||
|
headers = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'HEADERS' for t in n.targets)))
|
||||||
|
headers['freertos/FreeRTOS.h'] += '''
|
||||||
|
typedef int portMUX_TYPE;
|
||||||
|
#define portMUX_INITIALIZER_UNLOCKED 0
|
||||||
|
void enter(void);
|
||||||
|
void leave(void);
|
||||||
|
#define taskENTER_CRITICAL(p) do { (void)(p); enter(); } while (0)
|
||||||
|
#define taskEXIT_CRITICAL(p) do { (void)(p); leave(); } while (0)
|
||||||
|
'''
|
||||||
|
headers['freertos/task.h'] = '''#pragma once
|
||||||
|
typedef void *TaskHandle_t;
|
||||||
|
TaskHandle_t xTaskGetCurrentTaskHandle(void);
|
||||||
|
void vTaskDelay(unsigned);
|
||||||
|
'''
|
||||||
|
with tempfile.TemporaryDirectory(prefix='ssh-security-') as directory:
|
||||||
|
out = Path(directory)
|
||||||
|
for name, text in headers.items():
|
||||||
|
path = out / name
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(text)
|
||||||
|
source = (ROOT/'src/ssh_transport.c').read_text()
|
||||||
|
names = ('next_generation','request_running_locked','request_running','ssh_transport_start','ssh_transport_stop','ssh_transport_replace_identity','ssh_transport_replace_host_key')
|
||||||
|
owner = ''.join(re.search(r'^(?:static )?[^\n]+\b'+name+r'\([^;]*?\n\{.*?^\}',source,re.M|re.S).group()+'\n' for name in names)
|
||||||
|
(out/'owner.inc').write_text(re.search(r'^#define SSH_TRANSPORT_GENERATION_MAX .+$',source,re.M).group()+'\n'+owner)
|
||||||
|
subprocess.run(['cc','-std=c11','-Wall','-Wextra','-Werror','-g','-I'+str(out),'-I'+str(ROOT/'src'),str(ROOT/'tests/ssh_management/security.c'),'-lmbedcrypto','-o',str(out/'test')],check=True,timeout=30)
|
||||||
|
subprocess.run([str(out/'test')],check=True,timeout=30)
|
||||||
@@ -253,7 +253,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
|||||||
*(["-DHOST_BROKER"] if broker else []),
|
*(["-DHOST_BROKER"] if broker else []),
|
||||||
*(["-DHOST_SSH_SETTINGS"] if ssh_settings else []),
|
*(["-DHOST_SSH_SETTINGS"] if ssh_settings else []),
|
||||||
*(["-DHOST_LIFECYCLE"] if lifecycle else []),
|
*(["-DHOST_LIFECYCLE"] if lifecycle else []),
|
||||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
|
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto", *(["-lmbedcrypto"] if ssh_settings else []),
|
||||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||||
if lifecycle:
|
if lifecycle:
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ static uint32_t queued_id;
|
|||||||
static unsigned mutations, snapshots;
|
static unsigned mutations, snapshots;
|
||||||
static esp_err_t owner_error;
|
static esp_err_t owner_error;
|
||||||
static ssh_transport_management_snapshot_t owner_snapshot;
|
static ssh_transport_management_snapshot_t owner_snapshot;
|
||||||
|
esp_err_t ssh_security_get_identity_snapshot(ssh_security_identity_snapshot_t *out) {
|
||||||
|
assert(!host_lock_depth && !on_dispatcher);
|
||||||
|
memset(out, 0, sizeof(*out)); out->metadata.generation = 3;
|
||||||
|
return ESP_OK;
|
||||||
|
}
|
||||||
|
esp_err_t ssh_transport_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
|
||||||
|
assert(on_dispatcher && !host_lock_depth && service == 7 && identity == 3 && !reset);
|
||||||
|
++mutations; *committed = owner_error == ESP_OK; return owner_error;
|
||||||
|
}
|
||||||
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *out) {
|
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *out) {
|
||||||
assert(!host_lock_depth && !on_dispatcher); ++snapshots;
|
assert(!host_lock_depth && !on_dispatcher); ++snapshots;
|
||||||
*out = owner_snapshot; return owner_error;
|
*out = owner_snapshot; return owner_error;
|
||||||
@@ -132,6 +141,28 @@ static void ssh_settings_tests(void) {
|
|||||||
puts("PASS SSH queue deadline, expiry/revocation/currentness failure, HTTPS auth lifecycle fencing");
|
puts("PASS SSH queue deadline, expiry/revocation/currentness failure, HTTPS auth lifecycle fencing");
|
||||||
send_fail = true; submit(&admin); send_fail = false; execute(); assert(s_operation.state == OK);
|
send_fail = true; submit(&admin); send_fail = false; execute(); assert(s_operation.state == OK);
|
||||||
operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, "ok"));
|
operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, "ok"));
|
||||||
|
const char *rotate = "{\"action\":\"rotate\",\"generation\":7,\"target\":0,\"identity_generation\":3}";
|
||||||
|
const char *bad_rotations[] = {
|
||||||
|
"{\"action\":\"rotate\",\"generation\":7,\"target\":0}",
|
||||||
|
"{\"action\":\"rotate\",\"generation\":7,\"target\":0,\"identity_generation\":0}",
|
||||||
|
"{\"action\":\"rotate\",\"generation\":7,\"target\":9,\"identity_generation\":3}",
|
||||||
|
"{\"action\":\"stop\",\"generation\":7,\"target\":0,\"identity_generation\":3}",
|
||||||
|
"{\"action\":\"rotate\",\"generation\":7,\"target\":0,\"identity_generation\":4294967295}"};
|
||||||
|
for (unsigned i=0;i<sizeof(bad_rotations)/sizeof(*bad_rotations);++i) {
|
||||||
|
operation_begin(&admin,bad_rotations[i]); expect_ssh("400 Bad Request",false);
|
||||||
|
}
|
||||||
|
for (unsigned i=0;i<2;++i) {
|
||||||
|
owner_error=i ? ESP_FAIL : ESP_OK;
|
||||||
|
operation_begin(&admin,rotate); expect_ssh("202 Accepted",false);
|
||||||
|
before=mutations; execute(); assert(mutations==before+1 && s_operation.state==(i ? FAILED : OK));
|
||||||
|
execute(); assert(mutations==before+1);
|
||||||
|
operation_begin(&other,NULL); expect_ssh("401 Unauthorized",false);
|
||||||
|
}
|
||||||
|
owner_error=ESP_OK;
|
||||||
|
operation_begin(&admin,rotate);expect_ssh("202 Accepted",false);
|
||||||
|
before=mutations;web_session_store_invalidate(admin.view.id);execute();assert(s_operation.state==CANCELLED&&mutations==before);
|
||||||
|
admin=mint(&alice);
|
||||||
|
puts("PASS SSH rotation requires both generations, rejects extras/targets/exhaustion, executes once, preserves login isolation and cancels revoked queued work");
|
||||||
s_next_id = UINT32_MAX; operation_begin(&admin, disconnect_body); expect_ssh("503 Service Unavailable", false);
|
s_next_id = UINT32_MAX; operation_begin(&admin, disconnect_body); expect_ssh("503 Service Unavailable", false);
|
||||||
puts("PASS SSH lost acknowledgement retained result and nonwrapping operation IDs");
|
puts("PASS SSH lost acknowledgement retained result and nonwrapping operation IDs");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
|||||||
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
|
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
|
||||||
const path='/api/settings/ssh', op=path+'-operation';
|
const path='/api/settings/ssh', op=path+'-operation';
|
||||||
const row=(id=9, extra={})=>({id,state:2,route:1,name_hex:'3c696d673e',closing:false,...extra});
|
const row=(id=9, extra={})=>({id,state:2,route:1,name_hex:'3c696d673e',closing:false,...extra});
|
||||||
const fixture=(extra={})=>({generation:7,running:true,transitioning:false,sessions:[row(),row(10,{route:2})],...extra});
|
const fixture=(extra={})=>({generation:7,running:true,transitioning:false,identity_generation:3,algorithm:'ecdsa-sha2-nistp256',fingerprint:'SHA256:'+'A'.repeat(43),rotatable:!extra.transitioning&&extra.generation!==4294967295,sessions:[row(),row(10,{route:2})],...extra});
|
||||||
const reply=(state='pending',id=42,status=200,action='disconnect')=>new Response(JSON.stringify({id,action,state}),{status});
|
const reply=(state='pending',id=42,status=200,action='disconnect')=>new Response(JSON.stringify({id,action,state}),{status});
|
||||||
const n=(b,id)=>b.nodes['ssh-'+id], posts=b=>b.calls.filter(c=>c.url===op&&c.method==='POST');
|
const n=(b,id)=>b.nodes['ssh-'+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-ssh');await tick();return b;}
|
async function open(v=fixture()) {const b=await adminBrowser();b.click('select-settings');await tick();b.queues[path].push(json(v));b.click('settings-ssh');await tick();return b;}
|
||||||
@@ -13,7 +13,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
|||||||
await test('SSH admin-only view, safe rows, no navigation/selection mutation and both terminal isolation',async()=>{
|
await test('SSH admin-only view, safe rows, no navigation/selection mutation and both terminal isolation',async()=>{
|
||||||
const u=browser();u.start();await tick();u.click('settings-ssh');await tick();assert.equal(u.calls.filter(c=>c.url===path).length,0);
|
const u=browser();u.start();await tick();u.click('settings-ssh');await tick();assert.equal(u.calls.filter(c=>c.url===path).length,0);
|
||||||
const b=await open();assert.equal(b.nodes['ssh-settings'].hidden,false);
|
const b=await open();assert.equal(b.nodes['ssh-settings'].hidden,false);
|
||||||
assert.equal(n(b,'values').children[0].textContent,'9 / Serial / <img>');select(b);assert.equal(posts(b).length,0);
|
assert.equal(n(b,'values').children[8].textContent,'9 / Serial / <img>');select(b);assert.equal(posts(b).length,0);
|
||||||
for(let i=0;i<2;++i){b.sockets[i].emit('message',{data:Uint8Array.of(0,255,i).buffer});assert.deepEqual(b.terminals[i].writes.at(-1),[0,255,i]);b.terminals[i].input('blocked');assert.equal(b.sockets[i].sent.length,0);}
|
for(let i=0;i<2;++i){b.sockets[i].emit('message',{data:Uint8Array.of(0,255,i).buffer});assert.deepEqual(b.terminals[i].writes.at(-1),[0,255,i]);b.terminals[i].input('blocked');assert.equal(b.sockets[i].sent.length,0);}
|
||||||
assert.match(html,/Stop closes all SSH sessions/);assert.match(html,/HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped/);
|
assert.match(html,/Stop closes all SSH sessions/);assert.match(html,/HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped/);
|
||||||
b.click('settings-broker');await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(posts(b).length,0);
|
b.click('settings-broker');await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(posts(b).length,0);
|
||||||
@@ -29,6 +29,20 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
|||||||
await refresh(b);assert.equal(posts(b).length,1);assert.equal(n(b,'target').value,'');
|
await refresh(b);assert.equal(posts(b).length,1);assert.equal(n(b,'target').value,'');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await test('SSH host rotation confirms fingerprint and both generations, preserves HTTPS and never replays',async()=>{
|
||||||
|
const b=await open(); let text=''; b.window.confirm=t=>{text=t;return false;}; b.click('ssh-rotate');await tick();assert.equal(posts(b).length,0);
|
||||||
|
for(const pattern of [/SHA256:/,/identity generation 3/,/service generation 7/,/ALL SSH sessions/,/known_hosts/,/trusted UART0/,/ssh host-key info/,/HTTPS stays accessible/,/persistence fails/])assert.match(text,pattern);
|
||||||
|
await submit(b,'rotate');assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'rotate',generation:7,target:0,identity_generation:3});
|
||||||
|
b.click('ssh-rotate');await tick();assert.equal(posts(b).length,1);assert.equal(b.sockets.length,2);
|
||||||
|
b.queues[op].push(reply('failed',42,200,'rotate'));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/persisted even if restart failed/);
|
||||||
|
await refresh(b,fixture({identity_generation:4}));assert.equal(posts(b).length,1);
|
||||||
|
});
|
||||||
|
await test('SSH host metadata malformed or unavailable fails closed for rotation only',async()=>{
|
||||||
|
for(const extra of [{identity_generation:0,fingerprint:''},{identity_generation:4294967295},{fingerprint:'private-key'},{algorithm:'ssh-rsa'}]){
|
||||||
|
const b=await open(fixture(extra));assert.ok(n(b,'rotate').disabled);b.click('ssh-rotate');await tick();assert.equal(posts(b).length,0);
|
||||||
|
}
|
||||||
|
const b=await open(fixture({identity_generation:0,fingerprint:'',rotatable:false}));assert.ok(n(b,'rotate').disabled);assert.equal(n(b,'stop').disabled,false);
|
||||||
|
});
|
||||||
await test('SSH refresh never rebases explicit identity or resurrects stale and absent selections',async()=>{
|
await test('SSH refresh never rebases explicit identity or resurrects stale and absent selections',async()=>{
|
||||||
for(const v of [fixture({generation:8}),fixture({sessions:[row(13)]}),fixture({sessions:[row(9,{closing:true})]}),fixture({sessions:[row(9,{name_hex:'61'})]}),fixture({transitioning:true})]){
|
for(const v of [fixture({generation:8}),fixture({sessions:[row(13)]}),fixture({sessions:[row(9,{closing:true})]}),fixture({sessions:[row(9,{name_hex:'61'})]}),fixture({transitioning:true})]){
|
||||||
const b=await open();select(b);await refresh(b,v);assert.ok(n(b,'disconnect').disabled);await refresh(b);assert.ok(n(b,'disconnect').disabled);assert.equal(n(b,'target').value,'');
|
const b=await open();select(b);await refresh(b,v);assert.ok(n(b,'disconnect').disabled);await refresh(b);assert.ok(n(b,'disconnect').disabled);assert.equal(n(b,'target').value,'');
|
||||||
|
|||||||
Reference in New Issue
Block a user