From 8df1d2218b29f9921ff676d661e1b9e378508de7 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sun, 13 Sep 2026 19:58:05 +0200 Subject: [PATCH] Add SSH host identity rotation controls --- docs/agent/architecture.md | 2 + docs/agent/code-map.md | 2 + docs/agent/current-state.md | 2 + docs/agent/design-decisions.md | 2 + docs/phase8d21_implementation.md | 65 ++++++++- docs/phase8d_plan.md | 2 +- src/ssh_security.c | 128 +++++++++++------- src/ssh_security.h | 18 ++- src/ssh_transport.c | 87 ++++++++----- src/ssh_transport.h | 8 ++ src/web_ssh_settings.c | 61 ++++++--- src/web_ui.c | 14 +- tests/ssh_management/run.py | 33 ++++- tests/ssh_management/runtime.py | 71 ++++++++++ tests/ssh_management/security.c | 152 ++++++++++++++++++++++ tests/ssh_management/security.py | 35 +++++ tests/web_cookie_auth/run.py | 2 +- tests/web_cookie_auth/ssh_settings_test.c | 31 +++++ tests/web_ui_session/ssh.cjs | 18 ++- 19 files changed, 626 insertions(+), 107 deletions(-) create mode 100644 tests/ssh_management/runtime.py create mode 100644 tests/ssh_management/security.c create mode 100644 tests/ssh_management/security.py diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 6ffd31a..dae09cb 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -152,6 +152,8 @@ The 8D.6 document binds retained terminal state to its first validated username/ ### 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`. `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. diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 163f1e7..f65198e 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -124,6 +124,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the ## 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. **Responsibility:** authenticate SSH, route users to serial and administrators to the command dispatcher, and own wolfSSH lifecycle. diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 88a003d..e24e5dd 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do ## 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.** - **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. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index b85f977..4a203a5 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -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 +**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`. **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`. diff --git a/docs/phase8d21_implementation.md b/docs/phase8d21_implementation.md index c02ee56..7999de5 100644 --- a/docs/phase8d21_implementation.md +++ b/docs/phase8d21_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.** Independent review complete with no confirmed actionable findings; target validation/sign-off remains pending. Full diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index 9727db9..c6d938e 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -192,7 +192,7 @@ Typed operations must preserve subsystem owner/lock/persistence contracts and co | **8D.18 — Client/writer contextual dialogs** | **Implemented, host/build verified; independent parent review and target sign-off pending.** UI-only reuse of8D.16 and8D.17's single host for live client popover and confirmed Active writer dialog. [Contract/tests/checklist](phase8d18_implementation.md). | Native pointer/keyboard/touch entrances; single-flight5-second live refresh/deadline, explicit selection preserved without lease-token renewal, sticky stale/absent rejection, full-page draft protection and focus-safe updates. Ordinary users retain only ordinary status.135 UI groups plus broad broker/auth/lifecycle regressions pass; real browser/device checks pending. No new writer policy/backend/icons/8D.19/later. | | **8D.19 — Ordinary service/session controls** | **First service slice SSH implemented, host/build verified; independent parent review and target sign-off pending. Phase incomplete.** Typed SSH status and confirmed exact-session disconnect/start/stop via existing dispatcher/SSH owner, excluding invoking HTTPS-session-cutting actions. [SSH contract/tests/resources](phase8d19_implementation.md). | Explicit SSH/all-SSH/one-session confirmation; owner lock/service generation/retired session IDs reject stale/reused targets and stop/start ABA. No settings/identity clear. SSH4, cookie SSH6+shared, dispatcher, lifecycle27 and UI143 PASS. Split-by-service rule applied: all web-session/HTTPS/USB controls excluded; next other-session web slice requires explicit login/owner-safety audit, USB actions are not promised. No generic broker disconnect or8D.20/21. Target full-mix/heap/stack/recovery checks pending. | | **8D.20 — Self-affecting service actions and reboot** | **User-authorized HTTPS stop/restart/reboot integration implemented, host/build verified; independent parent review and target sign-off pending.** Current-admin typed routes, bounded send-return/HTTPD ID callback/existing dispatcher handoff, canonical generation/reserved lifecycle and shared reset API; explicit Settings HTTPS/Reboot and link to existing Network. [Contracts, tests, costs and checklist](phase8d20_implementation.md). | PASS lifecycle41, cookie lifecycle8+shared, UI153+HTML/CSP, dispatcher and broad regressions. Tests cover queue/send/lost/late callback/request-lifetime/ABA/login revocation/deadlines/owner failures, no replay/late result adoption, all-client/unsaved-state and accurate UART0/SSH/USB recovery. Final pio100,508 RAM/1,821,505 flash (+104/+13,064 vs pre-phase).39 handlers/six sockets, no new tasks/timers/queue growth/assets/config/identity or unrelated19/21. Real TLS/scheduling/reboot/full-mix/runtime reserves and independent review remain pending. | -| **8D.21 — Security/danger-zone settings** | **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. diff --git a/src/ssh_security.c b/src/ssh_security.c index cfc3671..7d9644d 100644 --- a/src/ssh_security.c +++ b/src/ssh_security.c @@ -48,6 +48,9 @@ static bool s_mutex_creating; static ssh_security_blob_t s_material; static bool s_material_ready; 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) { @@ -338,6 +341,10 @@ esp_err_t ssh_security_init(ssh_security_load_result_t *load_result) return ESP_OK; } + if (s_identity_token) { + xSemaphoreGive(s_security_mutex); + return ESP_ERR_INVALID_STATE; + } ssh_security_blob_t candidate; bool missing = false; error = load_blob(&candidate, &missing); @@ -411,60 +418,91 @@ esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata) 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; } - - xSemaphoreTake(s_security_mutex, portMAX_DELAY); - esp_err_t error = ESP_ERR_INVALID_STATE; - 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)); + *token = s_identity_token = ++s_next_identity_token; + s_identity_owner = xTaskGetCurrentTaskHandle(); + s_identity_used = false; 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 (error != ESP_OK) { - return error; - } - error = ensure_mutex(); - if (error != ESP_OK) { - return error; - } - + if (!s_security_mutex || !token) return ESP_ERR_INVALID_STATE; xSemaphoreTake(s_security_mutex, portMAX_DELAY); - uint32_t generation = 1U; - if (s_material_ready) { - if (s_material.generation == UINT32_MAX) { - xSemaphoreGive(s_security_mutex); - return ESP_ERR_INVALID_STATE; - } - generation = s_material.generation + 1U; + if (s_identity_token != token || s_identity_used || + s_identity_owner != xTaskGetCurrentTaskHandle()) { + xSemaphoreGive(s_security_mutex); + return ESP_ERR_INVALID_STATE; } - - ssh_security_blob_t candidate; - 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)); + s_identity_used = true; + uint32_t generation = s_material_ready ? s_material.generation + 1U : 1U; 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; } + +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); } diff --git a/src/ssh_security.h b/src/ssh_security.h index b9dddfd..aec31ab 100644 --- a/src/ssh_security.h +++ b/src/ssh_security.h @@ -3,6 +3,7 @@ #pragma once +#include #include #include @@ -38,7 +39,22 @@ esp_err_t ssh_security_copy_private_key(uint8_t *output, size_t capacity, size_t *output_length); 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_reset(void); diff --git a/src/ssh_transport.c b/src/ssh_transport.c index 6301861..62ec83f 100644 --- a/src/ssh_transport.c +++ b/src/ssh_transport.c @@ -707,6 +707,11 @@ static esp_err_t create_listener(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(); if (error == ESP_OK) { error = create_listener(); @@ -752,7 +757,7 @@ static esp_err_t stop_runtime(void) break; } } - if (s_context != NULL) { + if (all_free && s_context != NULL) { wolfSSH_CTX_free(s_context); s_context = NULL; } @@ -1393,10 +1398,18 @@ static void process_slots(void) } if (all_free) { taskENTER_CRITICAL(&s_lock); - if (!s_running) { - s_cleanup_pending = false; - } + bool stopped = !s_running; 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); } -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) { - return ESP_ERR_INVALID_STATE; - } - xSemaphoreTake(s_command_mutex, portMAX_DELAY); + if (!committed || (!!service_generation != !!identity_generation) || + (reset && service_generation) || service_generation == UINT32_MAX || + identity_generation == UINT32_MAX) return ESP_ERR_INVALID_ARG; + *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); - if (!s_initialized || s_transitioning) { - taskEXIT_CRITICAL(&s_lock); - xSemaphoreGive(s_command_mutex); - return ESP_ERR_INVALID_STATE; - } - was_running = s_running; - cleanup_pending = s_cleanup_pending; + bool valid = s_initialized && !s_transitioning && + (!service_generation || (!s_cleanup_pending && service_generation == s_management_generation)); + bool was_running = s_running, cleanup_pending = s_cleanup_pending; taskEXIT_CRITICAL(&s_lock); - - esp_err_t error = ESP_OK; - if (was_running || cleanup_pending) { - error = request_running_locked(false); - } + uint32_t token = 0; + esp_err_t error = valid ? ssh_security_reserve_identity(identity_generation, reset, &token) + : ESP_ERR_INVALID_STATE; if (error == ESP_OK) { - error = reset ? ssh_security_reset() : ssh_security_rotate(); - } - if (error != ESP_OK) { - if (was_running) { - (void)request_running_locked(true); + taskENTER_CRITICAL(&s_lock); + if (s_management_generation != UINT32_MAX) ++s_management_generation; + taskEXIT_CRITICAL(&s_lock); + /* Keep the service mutex and identity reservation through stop/replace/start. + * 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); 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) { if (snapshot == NULL) { diff --git a/src/ssh_transport.h b/src/ssh_transport.h index c3068f5..d2214c4 100644 --- a/src/ssh_transport.h +++ b/src/ssh_transport.h @@ -124,6 +124,14 @@ esp_err_t ssh_transport_init(void); esp_err_t ssh_transport_start(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. */ esp_err_t ssh_transport_replace_host_key(bool reset); diff --git a/src/web_ssh_settings.c b/src/web_ssh_settings.c index e515f54..0de84fd 100644 --- a/src/web_ssh_settings.c +++ b/src/web_ssh_settings.c @@ -9,54 +9,58 @@ #include "freertos/FreeRTOS.h" #include "secure_random.h" #include "ssh_transport.h" +#include "ssh_security.h" +#include "mbedtls/base64.h" #include "web_cookie_auth.h" #include "web_httpd_adapter.h" enum { 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 { uint32_t id; web_session_id_t session; user_principal_t principal; int64_t deadline; - uint32_t generation, target; - ssh_transport_management_action_t action; + uint32_t generation, target, identity_generation; + unsigned action; unsigned state; } ssh_operation_t; static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; static ssh_operation_t s_operation; 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) { - const char *keys[] = {"action", "generation", "target"}; + const char *keys[] = {"action", "generation", "target", "identity_generation"}; unsigned seen = 0; size_t pos = 0; #define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos #define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0) TAKE('{'); - for (unsigned field = 0; field < 3; ++field) { + for (unsigned field = 0; field < 4; ++field) { if (field) { TAKE(','); } TAKE('"'); size_t start = pos; while (pos < length && body[pos] != '"') ++pos; if (pos == length) return false; unsigned key = 0; - for (; key < 3; ++key) + for (; key < 4; ++key) 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(); if (key == 0) { TAKE('"'); start = pos; while (pos < length && body[pos] != '"') ++pos; if (pos == length) return false; unsigned action = 0; - for (; action < 3; ++action) + for (; action < 4; ++action) if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break; - if (action == 3) return false; - operation->action = (ssh_transport_management_action_t)action; + if (action == 4) return false; + operation->action = action; ++pos; } else { 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 (key == 1) operation->generation = number; - else operation->target = number; + else if (key == 2) operation->target = number; + else operation->identity_generation = number; } seen |= 1U << key; + SPACE(); + if (pos < length && body[pos] == '}') break; } TAKE('}'); SPACE(); #undef TAKE #undef SPACE - return pos == length && seen == 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->action == SSH_TRANSPORT_MANAGE_DISCONNECT) == (operation->target != 0U)); } @@ -95,8 +104,12 @@ void web_ssh_settings_execute(uint32_t id) unsigned state = CANCELLED; if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN && esp_timer_get_time() < operation.deadline) { - 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 : + (operation.action == ROTATE) ? FAILED : (error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED; } 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\"}"); 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]; int written = snprintf(response, sizeof(response), - "{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,\"sessions\":[", - snapshot.generation, snapshot.running ? "true" : "false", snapshot.transitioning ? "true" : "false"); + "{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s," + "\"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; } size_t used = (size_t)written; unsigned count = 0; diff --git a/src/web_ui.c b/src/web_ui.c index e18eb0c..179e7f9 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -200,7 +200,7 @@ static const char s_index_html[] = "

" "
" "

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.

Reload / sign in after recovery\n" - "\n" + "\n" "\n" "