Author SHA1 Message Date
Commander1024 91267b371e Consolidate Phase 8 documentation
Mark web administration complete, centralize current contracts and
acceptance evidence, and remove superseded slice records. Update
roadmap,
architecture notes, and test references without changing firmware
sources.
2026-09-13 22:27:10 +02:00
Commander1024 1608641d50 Close scope for remaining Phase 8D.19 work 2026-09-13 20:25:01 +02:00
Commander1024 8df1d2218b Add SSH host identity rotation controls 2026-09-13 19:58:05 +02:00
Commander1024 aa4bbc2c8c Add HTTPS identity rotation support 2026-09-13 18:21:37 +02:00
Commander1024 36e80811e8 Implement HTTPS lifecycle and reboot controls 2026-09-13 17:24:00 +02:00
Commander1024 737bd29f9e Add Typed SSH Service Controls
Provide admin-only SSH status plus generation-safe start, stop, and
single-session disconnect operations through the bounded dispatcher.
Include
Settings UI coverage, lifecycle safeguards, and host-side regression
tests.
2026-09-13 16:07:04 +02:00
Commander1024 7ccc8799e9 Add Broker client and writer quick dialogs 2026-09-13 14:40:51 +02:00
Commander1024 9f6ebf2053 Add Serial and Wi-Fi quick settings
Reuse the existing settings view and typed controllers for accessible
quick editing while keeping network credentials out of quick mode.
Expand browser and layout coverage for focus, dismissal, bounds, and
expiry.
2026-09-13 14:22:31 +02:00
Commander1024 add399908a Remove stray blank line in Phase 8D plan 2026-09-13 14:01:49 +02:00
Commander1024 fa12440606 Add broker management and writer transfer UI 2026-09-13 13:59:28 +02:00
Commander1024 29a4953df0 Remove network diagnostics from phase 8D plan 2026-09-09 10:32:55 +02:00
Commander1024 d9ec3c08de Add Typed Display Settings Administration
Implements admin-only Display settings with generation-checked
Apply, Save, Load, Defaults, and Reset operations across the web UI,
CLI, SSH dispatcher, and local UI owner. Adds bounded HTTP handling,
session-isolated operation results, browser lifecycle support, and
comprehensive host tests and documentation.
2026-09-09 10:15:23 +02:00
Commander1024 60d9c54bb4 Send binary WebSocket frames in one write 2026-09-08 23:52:00 +02:00
Commander1024 042499e4d6 Add broker and web throughput diagnostics 2026-09-08 22:40:58 +02:00
Commander1024 36d41be422 Document 8D.12/8D.13 Functional Sign-Off 2026-09-08 21:40:18 +02:00
Commander1024 4a4d615c59 Unify Settings Layouts and Add Coverage 2026-09-08 21:22:52 +02:00
Commander1024 989821b7c4 Add Typed Admin Network Settings 2026-09-08 20:57:27 +02:00
Commander1024 d9ac1319aa Document legacy credential cleanup sign-off
Record certificate continuity, existing-user validation, and full-mix
target telemetry while preserving the documented evidence limits.
2026-09-08 19:21:09 +02:00
Commander1024 ac80863d80 Remove Legacy Credential Bootstrap Paths
Decouple user provisioning from HTTPS identity storage while retaining
compatible v1 user records and migrating TLS material to the
credential-free
v2 format. Add focused security regression coverage and update operator
documentation.
2026-09-08 19:09:26 +02:00
Commander1024 82f21d6116 Add Bounded Ordinary HTTPS Idle Cleanup 2026-09-08 18:33:33 +02:00
Commander1024 f6263042ff Add Bounded Web Admission Diagnostics 2026-09-08 18:04:46 +02:00
Commander1024 42f6423d4e Implement SSH authorized key management 2026-09-08 16:37:47 +02:00
Commander1024 22a7c7b0a5 Record 8D.8–8D.10 target sign-off
Document user-supplied telemetry, functional testing, and acceptance of
the implemented Serial and account settings scope.
2026-09-08 15:19:49 +02:00
Commander1024 23c190bcf0 Load the PlatformIO extra script 2026-09-08 15:17:00 +02:00
Commander1024 94433ef975 Add typed account and password settings
- Add admin account list, create, role, delete, and password workflows
- Execute identity-checked mutations through the existing dispatcher
- Bound queued credential lifetime and wipe transient secrets
- Add explicit password generation with saved-value acknowledgement
- Handle self-revocation and uncertain outcomes without automatic
  retries
- Register optional account routes without disrupting terminal
  transports
- Expand host regressions and document contracts and pending target
  checks

Validated host suites and pio run; hardware validation remains pending.
2026-09-08 09:27:02 +02:00
Commander1024 42548f6334 Add typed serial settings operations
Route bounded admin mutations through the existing administration
dispatcher,
covering apply, lifecycle, persistence, authorization, and result
tracking.
Add the browser controls, automatic result refresh, regression coverage,
and
phase documentation.
2026-09-08 00:25:31 +02:00
Commander1024 5a2aa0d4d8 Add admin serial settings view 2026-09-07 20:12:33 +02:00
Commander1024 c73674cda2 Document M2 sign-off and update project status 2026-09-07 19:29:56 +02:00
Commander1024 93d8d1e5ca Mark 8D.7 Implemented Scope Validated 2026-09-07 19:04:49 +02:00
Commander1024 fe1e2d98b4 Enable bounded browser account administration for Phase 8D.7
Allow other-account add/password and forced delete/role commands through
shared dispatcher and handler policy. Keep self-target,
generated-secret,
key, bootstrap, and recovery workflows blocked.

Revalidate currentness after password prompts and before database API
admission. Document that admitted mutations may finish after disconnect,
while subsequent stale operations must reject.

Add policy, transaction-failure, cleanup, and targeted-revocation
regressions. Record completed review, passing host tests and firmware
build, with target validation and M2 acceptance still pending.
2026-09-07 10:03:45 +02:00
Commander1024 326119812f Extend browser admin lifecycle actions
Support browser reboot and HTTPS stop through deferred control, plus
exact
`web certificate rotate --force` handoff to the dispatcher. Add typed
request
validation and focused boundary and lifecycle coverage.
2026-09-07 09:36:38 +02:00
Commander1024 17520b15b7 Configure clangd for ESP-IDF development
Enable PlatformIO compilation database generation and configure
toolchain discovery for Clang-based editors.
2026-09-07 09:35:37 +02:00
Commander1024 0b86fd9c70 Close 8D.6 With Validation Sign-Off 2026-09-06 21:30:26 +02:00
Commander1024 f15491f233 Place admin controls before terminal selector 2026-09-06 20:37:39 +02:00
Commander1024 71f588360a Fix admin ticket validation format mismatch 2026-09-06 20:32:00 +02:00
Commander1024 e6db5428eb Add browser Serial/Admin terminal switching
Keep the serial connection and lease intact while providing a separate,
bounded admin terminal with explicit open and close controls. Fence
retained
terminal state across sessions and add fit-readiness retries with
regression
coverage.
2026-09-06 19:46:38 +02:00
Commander1024 aeb2043396 feat: add bounded admin WebSocket backend (Phase 8D.5)
- Require current admin cookie sessions, Origin checks and single-use
  tickets
- Reuse the shared console with session-aware authorization and slot
  allocation
- Add HTTPD-owned I/O, bounded buffering and revocation cleanup
- Prevent LRU eviction of serial clients and stale admin socket closure
- Reject unsupported web-shell mutations before side effects
- Add host regressions, a smoke client and resource accounting

Validated by user sign-off after a 15-minute full-client soak at 230400
baud, with a few broker drops under heavy output. Browser UI remains
for Phase 8D.6; numeric memory reserves remain open.
2026-09-06 14:41:41 +02:00
Commander1024 e5dce12ed4 Close Phase 8D.4 validation
The user confirmed successful empty-Enter and soak testing, closing
8D.4 while leaving numeric reserve gates open and advancing the next
planned work to 8D.5.
2026-09-06 11:23:58 +02:00
Commander1024 117c694cd4 Add SSH Console Ownership Boundary (Phase 8D.4)
Implement transport-qualified session identity and immutable owner
adapters
for SSH console lifecycle and output-drain operations. Add focused host
tests
covering admission, stale identities, deferred actions, completion
races,
prompts, backpressure, and slot reuse. Update Phase 8D documentation and
current-state tracking.
2026-09-06 09:07:11 +02:00
Commander1024 f9ee6eec9c Record Phase 8D.3 M1 sign-off 2026-09-06 08:38:41 +02:00
Commander1024 5a609fa40b Replace Web Basic Auth With Cookie Sessions
Add bounded login challenges, CSRF/origin enforcement, logout, and
session-bound WebSocket admission. Isolate private HTTPD access behind a
version-guarded adapter and add focused host coverage. Also let empty
admin
SSH input reach the normal console handler.
2026-09-05 23:55:05 +02:00
Commander1024 4435a7fddd Add Standalone Login Page Renderer
Add a hash-bound, no-store login document with focused C and Node host
tests. Keep rendering inert until the 8D.3 authentication cutover.
2026-09-05 18:45:12 +02:00
Commander1024 00f226dc59 Add allocation-free web auth parsers
Implement strict origin, cookie, and login JSON parsing with
fail-closed validation and output wiping. Add focused host contract
tests
and document the preparatory 8D.3 parser split.
2026-09-05 18:22:15 +02:00
Commander1024 a62a655ac1 Bind Serial Transports To Web Sessions 2026-09-05 18:01:39 +02:00
Commander1024 93eef0e676 Added docs. 2026-09-05 17:16:17 +02:00
Commander1024 27c54c0a92 Phase 8D.1 implemented and validated. 2026-09-05 17:15:22 +02:00
Commander1024 d4991658b1 Phase 8D validation completed 2026-09-05 16:11:23 +02:00
Commander1024 d8999cd4a9 Updated memory baselines 2026-09-05 15:20:09 +02:00
Commander1024 02fdeee345 Record Phase 8D baseline and browser contract 2026-09-05 13:08:17 +02:00
Commander1024 af89dd1bd9 Define staged Phase 8D delivery plan 2026-09-05 12:46:27 +02:00
159 changed files with 25377 additions and 1424 deletions
+10
View File
@@ -0,0 +1,10 @@
CompileFlags:
Add:
- -isystem
- /home/mscholz/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include
Remove:
- -mlongcalls
- -mdisable-hardware-atomics
- -fstrict-volatile-bitfields
- -fno-tree-switch-conversion
- -fno-shrink-wrap
+17
View File
@@ -0,0 +1,17 @@
// Folder-specific settings
//
// For a full list of overridable settings, and general information on folder-specific settings,
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
{
"lsp": {
"clangd": {
"binary": {
"path": "/usr/bin/clangd",
"arguments": [
"--background-index",
"--query-driver=/home/mscholz/.platformio/packages/**/bin/*"
]
}
}
}
}
+22 -5
View File
@@ -14,13 +14,24 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
## Development status
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D plans integrated browser login/logout, an admin-shell terminal mode, typed settings, and contextual quick administration while preserving any browser-held serial writer lease across terminal-mode changes. Configurable STA-only mDNS naming as `sak-<suffix>.local` is implemented with independent NVS persistence; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
Hardware characterization, serial/USB/Wi-Fi/HTTPS/SSH and local display/control are implemented and hardware-validated. **Phase 8 role-based users and administration is complete:** 8A8C were target-hardware validated and the user explicitly signed off tested firmware at **8D.22 (2026-09-13)**. See the [roadmap](docs/roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance evidence](docs/web_administration_acceptance.md). Very low internal/DMA lifetime minima remain a nonblocking headroom follow-up, not an approved reserve. Phase 9 hardening begins only on a separate request.
### Browser administration
Cookie login/logout supports both roles. Administrators also have an explicitly opened Admin shell sharing the canonical dispatcher, and typed **Serial, Accounts/password/SSH authorized keys, Network, Display, Broker, SSH, and HTTPS/Reboot** settings. Serial/Wi-Fi/client/writer quick controls reuse the same settings controllers. Navigation preserves terminal sessions and serial writer ownership; explicit transfer is confirmed and generation-safe. Browser-shell permissions remain narrower than typed Settings or UART0/admin SSH.
Network uses UTF-8 text/reversible hex SSIDs, explicit password Keep/Replace/disabled-STA Clear (never AP clear), RAM edits and explicit Save. Saved secrets are never prefilled/exported. Next profile follows enabled priority order, not the editor's selected index. Network `accepted` means owner admission, not online/DNS completion; disruption may precede acknowledgement.
HTTPS and SSH identity settings display public fingerprints and confirm both service and identity generations before rotation. HTTPS rotation commits before stop/restart and invalidates web logins; SSH stops before commit/restart and can disconnect clients even when persistence fails. A committed identity is never rolled back on restart failure. A lost response or timeout is not cancellation: inspect state rather than automatically replaying. Verify changed trust on UART0 with `web certificate info` or `ssh host-key info` before renewing browser trust/known_hosts; HTTPS restart requires fresh login. Browser identity reset/recovery/export is excluded; canonical CLI recovery remains available.
Keep UART0 ready for administrative recovery and native USB for network-independent UART1 access. USB is not an admin console, and whole-device reboot interrupts every transport. [Web administration contracts](docs/web_administration.md) cover API bounds, ownership, permissions, uncertainty and recovery. Dedicated typed network diagnostics and the unimplemented browser-session/USB control expansion were removed from scope; existing shell diagnostics and SSH settings remain.
## Documentation
- [Hardware wiring](docs/wiring.md): hardware profile, GPIO assignments, connector guidance, and safety notes.
- [Electrical tests](docs/electrical_tests.md): OLED/buttons, MAX3243, UART loopback, and session-broker verification procedures.
- [Role-based user database and UART0 administration](docs/user_administration_tests.md): user migration and administration, HTTPS/SSH authentication, session revocation, and the planned integrated web-administration acceptance matrix.
- [Role-based user database and UART0 administration](docs/user_administration_tests.md): user provisioning and administration, HTTPS/SSH authentication, session revocation, and reusable integrated web-administration regression procedures (not execution evidence).
- [Web administration](docs/web_administration.md): current bounded API/owner and recovery contracts; [acceptance evidence](docs/web_administration_acceptance.md) records sign-off and telemetry limits.
- [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands.
## Flash partition layout
@@ -29,7 +40,7 @@ The N16R8 target has 16 MiB flash and 8 MiB octal PSRAM. PlatformIO uses the cus
| Partition | Offset | Size | Purpose |
|---|---:|---:|---|
| `nvs` | `0x009000` | 512 KiB | Serial, Wi-Fi, mDNS hostname, local-display, role-based user, legacy recovery credential, HTTPS identity, and SSH host-key data |
| `nvs` | `0x009000` | 512 KiB | Serial, Wi-Fi, mDNS hostname, local-display, role-based user, HTTPS identity, and SSH host-key data |
| `otadata` | `0x089000` | 8 KiB | Active OTA-slot selection metadata |
| `phy_init` | `0x08B000` | 4 KiB | Optional PHY initialization data |
| `nvs_key` | `0x08C000` | 4 KiB | Reserved for future encrypted-NVS keys |
@@ -73,12 +84,18 @@ The firmware provides an interactive UART0 console at `serial-tool>`. Run `help`
The console supports session history, line editing, cursor movement, and hierarchical Tab completion. After an unattended boot, attach an ANSI-capable terminal and press Enter once to enable enhanced editing; this avoids blocking while no terminal is attached.
Serial, Wi-Fi, and mDNS hostname edits remain in RAM until explicitly saved with `serial save`, `wifi save`, or `mdns save`. Authenticated admin SSH sessions expose the shared operational administration registry, including interactive secrets, recovery-material management, network diagnostics, and deferred reboot/SSH lifecycle commands. Initial administrator bootstrap and explicit recovery of an unavailable user database remain UART0-only. An administrator also cannot generate a replacement password for its own account over SSH, preventing the one-time value from being lost when that mutation revokes the session. `web credentials show` exposes only the legacy migration/recovery credential, not an active Phase 8B network login.
Serial, Wi-Fi, and mDNS hostname edits remain in RAM until explicitly saved with `serial save`, `wifi save`, or `mdns save`. Authenticated admin SSH sessions expose the shared operational administration registry, including interactive secrets, TLS/SSH identity management, network diagnostics, and deferred reboot/SSH lifecycle commands. Create the first administrator on UART0 with `user add <username> admin` (optionally `--generate`). Explicit recovery of an unavailable user database remains UART0-only and rebuilds it empty; it refuses a healthy database. An administrator also cannot generate a replacement password for its own account over SSH, preventing the one-time value from being lost when that mutation revokes the session. Legacy web credential commands and `user bootstrap` are removed.
## Security notes
The HTTPS interface uses a device-specific self-signed certificate and role-aware HTTP Basic authentication over TLS; there is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the legacy recovery password, HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete.
The HTTPS interface uses a device-specific self-signed certificate and a same-origin login page with bounded server-side cookie sessions; HTTP Basic is no longer accepted. Open `/` or `/login`, sign in with a user-database password, and use **Sign out** before switching accounts. Four sessions have a one-hour absolute lifetime, including active serial connections; logout closes only that session's serial access. Login is globally limited to five credential verifications per 60 seconds, with explicit capacity/backoff errors. Direct-IP and mDNS access use separate host-only Secure/HttpOnly/SameSite=Strict cookies. Non-browser clients also require cookies, strict Origin and CSRF for mutations rather than Basic credentials. There is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete.
## License
This project is licensed under the [GNU General Public License version 3 only](LICENSE) (`GPL-3.0-only`). Third-party components remain subject to their respective licenses. The integration baseline uses Espressif registry components `espressif/mdns` `1.12.0`, `wolfssl/wolfssl` `5.8.2~1`, and `wolfssl/wolfssh` `1.4.20`; review upstream security releases before production use.
### Legacy credential removal
Missing user storage is persisted as an empty database; no shared credential is imported or synchronized. Existing valid v1 user records retain their accounts, roles, IDs, verifiers and keys without a schema change. HTTPS `web_sec/material` upgrades valid 1,392-byte v1 storage to 1,340-byte TLS-only v2, retaining exact certificate/key DER, fingerprint and generation, and committing before publication. Invalid records or migration failures fail closed rather than triggering fallback replacement. `web certificate rotate --force` remains available; `web reset --force` replaces TLS identity only, not users.
**Downgrade warning:** older v1-only firmware cannot read v2 HTTPS material. Logical NVS replacement is not a secure flash wipe; historical plaintext credentials can remain in flash. This cleanup requires no factory/partition erase. See [implementation and evidence limits](docs/legacy_credential_removal.md); final integration build evidence is pending.
+48 -10
View File
@@ -23,6 +23,10 @@ SSH role=admin ------> shared administration dispatcher <------ UART0
(does not join the broker)
```
## Typed Network settings
`web_network_settings` admits bounded current-admin operations to the existing ID-only dispatcher; `wifi_manager` remains radio/event/reannouncement owner and `mdns_service` owns independent hostname persistence. Zero-wait secret-free projections never copy saved PSKs onto HTTPD. Conditional compare/merge preserves omitted secrets, queue admission precedes Wi-Fi publication, and edits require explicit Save. Wi-Fi Load is stored-only; mDNS may load deterministic defaults. SSIDs remain byte-reversible; AP clear is denied. One login-bound slot and one-second timer bound queued secret retention to 30 seconds plus scheduling latency, not cancellation after owner admission. `accepted` is not online/DNS completion. See [Network contracts](../web_administration.md#network).
## Startup and initialization
`app_main()` in `src/main.c` is the composition root. The implemented order matters:
@@ -32,10 +36,10 @@ SSH role=admin ------> shared administration dispatcher <------ UART0
3. Attempt optional OLED initialization and a bounded boot animation. Display failure is nonfatal; a working display can delay later recovery services by about five seconds.
4. Initialize button diagnostics and load local-UI and serial configurations, falling back to RAM defaults on load failure.
5. Initialize the serial service, session broker, and permanent USB transport task. UART1 is not started automatically here.
6. Load/generate HTTPS material, then initialize the user database using the legacy web credential for first migration when available. User-database failure makes network authentication fail closed.
6. Load/generate HTTPS material, then initialize the independent user database, committing an empty database when storage is missing. User-database failure makes network authentication fail closed.
7. Initialize the HTTPS runtime, SSH host-key material, and permanent SSH owner task.
8. Load Wi-Fi configuration and the independent mDNS hostname configuration, persist generated first-boot Wi-Fi defaults when appropriate, initialize the nonfatal mDNS configuration service and Wi-Fi manager, and start Wi-Fi when configured for boot. The Wi-Fi manager owns subsequent mDNS announcement transitions.
9. Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. Both gates also require HTTPS security readiness, and SSH additionally requires its own security/runtime readiness. The HTTPS-security gate on SSH is an implemented dependency even though SSH has a separate host key.
9. Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. HTTPS additionally requires its own security/runtime readiness; SSH independently requires its own security/runtime readiness, not HTTPS identity readiness. This reflects `main.c` after accepted legacy-credential cleanup.
10. Start the local status/control task if button initialization succeeded.
11. Construct ESP-IDF's UART REPL to initialize `esp_console`, but do not start the stock REPL task. Register command groups, install completion, and start the custom UART frontend that feeds the shared dispatcher.
@@ -74,6 +78,10 @@ UART RX -> serial-service RX stream -> broker task
The broker drains serial input even with no clients. A full client output stream drops only that client's copy and updates drop counters; it does not block UART reception or other clients.
Binary serial WebSocket output uses the IDF-5.5.0-pinned `web_httpd_ws_send_binary` adapter: one owner-only session-override send of a bounded header+payload copy (516 bytes of local scratch, 512-byte payload). Existing generation validation and one outstanding work item per slot remain required. Non-full sends immediately replace that session's send override with a reject-only guard, mark it closing and shut down the socket before deferred cleanup; this prevents automatic SDK control replies from reentering TLS after incomplete output. HTTPD retains TLS destruction ownership. Text/control and admin output retain the SDK sender. One send call does not imply one TLS record, packet or peer receipt; the user signed off drop-free230400-baud full-client-mix operation at160MHz. Runtime stack-margin validation remains a separate follow-up; evidence and acceptance scope are in `current-state.md`.
Active-client counter snapshots expose ID/type/pending/HWM/UART/queued/read/dropped. HWM is maintained independently of web tracing and counter clear seeds current occupancy. Read means transport handoff, not peer receipt. Disconnect removes the client row while global totals retain traffic and unread-output discards. Independent default-disabled web performance capture uses two fixed slot records and nonwrapping epoch/generation fences; toggles fence in-flight samples, disable freezes aggregates, clear preserves enable state. Binary-only timestamps bound reservation-entry to callback-entry before locking, synchronous send calls and completion to broker-read return; idle and intervening work prevent scheduler-only/backlog-at-completion claims. No new instrumentation allocations or scheduling/buffer changes. Exact fields, overhead and capture contracts: [throughput diagnostics](../web_throughput_diagnostics.md).
### Clients to RS-232
```text
@@ -96,20 +104,28 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li
### HTTPS, WebSocket, and web serial
`web_server` runs HTTPS only on port 443 using the device-specific self-signed P-256 certificate from `web_security`. Current routes provide the UI, static assets, status, ticket issuance, and serial WebSocket upgrade.
`web_server` owns HTTPS on port 443 with a persisted self-signed P-256 identity. `web_serial_transport` mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 39. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails.
HTTP Basic authentication uses `user_database`. Before administrator bootstrap, the migrated role-`user` account is synchronized from the legacy credential, so that username/password can authenticate through the database; after bootstrap, the legacy blob is independent recovery material and is no longer consulted for authentication or synchronized into role-based accounts. Both `user` and `admin` roles currently receive the same web status/terminal experience; web administration is not implemented.
Cookie login/logout replaces Basic/cache. Digest-only records carry copied principals, CSRF state, absolute expiry and nonreused originating-session IDs. Strict same-origin/CSRF mutations and session/principal checks gate admission; logout invalidates its session before transport cleanup, account mutations invalidate only the affected account, and ongoing currentness is authoritative. Authentication initialization failure gates HTTPS; failed start/accepted stop wipes records. RNG/SHA/database calls run outside short spinlocks with post-call epoch/identity revalidation. [Authentication contract](../web_administration.md#authentication-and-admission).
The boot-local Basic-authentication cache has four RAM entries and a five-minute sliding lifetime. It stores a keyed digest of the complete `Authorization` header rather than the raw header, and every hit revalidates principal currentness. Its current lack of locking relies on the single-HTTPD-owner execution model.
`web_httpd_adapter` is the sole private IDF 5.5.0 boundary for duplicate headers, admission-before-101, consumed-scratch wiping, staged optional URI registration, combined binary sends and owner-only idle sweeps. Re-audit its version guard on SDK upgrades. HTTPD debug logging must not expose headers/tickets. `web_diagnostics` independently observes public post-TLS callbacks using six metadata records and a default-disabled 32-event ring; it cannot see preaccept/in-progress/failed TLS. [Admission diagnostics](../web_admission_diagnostics.md).
A WebSocket connection requires a one-time, principal-bound ticket with a maximum 30-second lifetime. Only four tickets can be outstanding; minting another evicts the live entry with the earliest expiry. Ticket issuance and upgrade also validate a supplied `Origin` against `https://<Host>`; absence of `Origin` is accepted for non-browser clients. Tickets are stored as digests, consumed before currentness validation, and are never persisted. An admitted session starts the serial service if necessary, creates a broker client, and opportunistically requests writer ownership. The web transport has two fixed session slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the web transport task mediates broker work through bounded scheduling. The browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect; after a user-paused disconnect it changes to Connect, which resumes connection attempts.
`web_httpd_idle` uses one one-second timer, six rows and at most one queued owner probe. Current-owner shutdown follows 15 seconds of observed ordinary idle, exempting actual WebSockets/async/pending input. Owner delays prevent hard timeout guarantees; accepted-but-lost work stays reserved until successful destruction, failed stop retains ownership. [Idle lifecycle contract](../https_idle_cleanup.md).
Web serial initialization is failure-isolated from the base HTTPS service: if the transport cannot initialize, `web_server_init()` can still succeed and serve authenticated non-WebSocket routes.
`web_ui`/`web_login_ui` own authored documents/scripts and hash-bound CSP loaders; authentication documents/app are no-store. Checked-in generated xterm/logo assets are compiled, not regenerated by ordinary builds. Browser Serial/Admin/Settings navigation changes view/input only, preserving serial client/lease and hidden output draining. Session-identity changes require a clean document; pagehide/restore revalidates before exposing buffers. One shared quick-settings host/controller preserves drafts, stale selections and pending uncertainty. [Terminal and console contract](../web_administration.md#terminal-and-console-ownership).
`web_ui.c` contains authored index/application strings and response policy. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets.
### Browser administration and HTTPS lifecycle
`web_admin_transport`/`web_admin_tickets` add one optional admin socket, two session-bound tickets and admission to the same two remote-console slots shared with SSH, never a broker client. HTTPD owns a 1,552-byte PSRAM-only payload and IO; a 20 ms timer queues at most one poll. Current-owner shutdown avoids queued reusable socket pointers. Detach fences submissions and only successful HTTPD stop retires old work. Console/owner checks enforce currentness before sensitive work; unsupported parsed shell commands reject before effects. [Browser-shell policy](../web_administration.md#browser-shell-policy).
`web_lifecycle_settings` uses one original-login slot and send-return → nonreused-ID HTTPD callback → existing dispatcher for self-cutting HTTPS/reboot actions. Two-second ACK and 30-second dequeue bounds precede admission, not receipt/completion. Accepted-but-lost callbacks retain one reservation through failed stop; only callback/successful destruction retires it. Conditional stop/restart reserve saturated lifecycle generation; restart retains ownership through stop/start. Conditional reboot invokes canonical `esp_restart()` outside locks, never HTTPD self-stop or console-cleanup waits.
`web_server_replace_identity` reserves service before identity and retains both through commit → reserved stop/start. Direct security and canonical CLI/browser-shell paths share task-bound nonreused identity reservations. Crypto/NVS run outside short security/service locks; commit precedes publication/wipe. Precommit failure leaves identity/HTTPD/logins unchanged; postcommit lifecycle failure never rolls back identity and can leave served/stored fingerprints different. Failed stop skips start and retains canonical recovery. Public service/security projections are separate observations, not authorization. [HTTPS ownership, generation and recovery contract](../web_administration.md#https-and-reboot).
### SSH
Typed SSH settings use the existing ID dispatcher and original-login result slot, never HTTPD wolfSSH calls or owner waits. Conditional lifecycle/session controls compare a saturated service generation and exact nonreused session ID under canonical locks. `ssh_transport_replace_identity` reserves service then identity before stop, retaining the command mutex across stop → commit → conditional restart. Failed stop skips mutation/start; failed persistence may follow disconnection; committed identity is never rolled back after restart failure. Only the SSH owner frees context after all slots retire, and start rejects orphan handles. Direct security/CLI/deferred SSH callers share task-bound identity reservations; crypto/NVS run outside security locks. HTTPS remains available, so no self-cutting HTTP ACK gate is needed. [SSH contracts](../web_administration.md#ssh).
`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.
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`.
@@ -132,9 +148,15 @@ Revocation has two layers:
1. after a database mutation commits, the command layer makes best-effort targeted WebSocket/SSH revocation calls; notification failure does not roll back the mutation;
2. transports periodically and at sensitive boundaries recheck principal currentness, providing authoritative fail-safe closure if notification fails.
The final administrator cannot be deleted or demoted. UART0 is trusted for initial administrator bootstrap and explicit unavailable-database recovery. Authenticated admin SSH can run the operational registry but is denied those two recovery operations; other secret-bearing commands are remotely available unless their handlers deny them.
The final administrator cannot be deleted or demoted. UART0 establishes the first administrator through normal `user add <username> admin` and owns explicit unavailable-database recovery to empty. Recovery refuses a healthy database. No bootstrap API or command remains. Authenticated admin SSH can run the operational registry but is denied recovery; other secret-bearing commands are remotely available unless their handlers deny them.
NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials, legacy recovery credentials, and TLS/SSH private keys remain recoverable under physical flash extraction.
NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials and TLS/SSH private keys remain recoverable under physical flash extraction.
## Typed Accounts and Serial settings
`web_account_settings` owns one login-bound slot for create/password/role/delete/key operations; HTTPD reads compact zero-wait metadata and queues only IDs. The database compares target username/account-ID/auth-generation inside its mutation lock and shares canonical invariant/commit logic. Successful commands target-revoke, including self; result loss is uncertain. A one-second timer wipes queued non-executing credentials after 30 seconds plus scheduling latency; admitted work wipes locals on return. Generated password delivery is a separate no-retained-retrieval POST, not mutation; key listing is fingerprint-only with stable sparse indices. [Accounts contracts](../web_administration.md#accounts-and-authorized-keys).
`web_serial_settings` queues bounded typed operations to the same dispatcher, retaining one original-login result and a 30-second dequeue check. Apply/Defaults are RAM-only; Save persists device working state. Reconfiguration can discard serial-service pending bytes while broker clients/lease/output remain. Snapshot reads are zero-wait and consistent; `/api/status` reports unavailable running state as null. Settings navigation preserves both terminals; bounded completion checks and manual uncertainty recovery never replay mutations. [Typed API/lifetime](../web_administration.md#typed-settings-api-and-operation-lifetime).
## Console architecture
@@ -151,10 +173,14 @@ admin SSH line editor ----/ |
The dispatcher is the sole caller of `esp_console_run()`, serializing UART0 and all admin SSH commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes.
The transport-neutral boundary retains `admin_ssh_console_open_owned()` and adds available-slot admission for runtime SSH/browser owners: copied transport-qualified slot/session/generation identity plus a firmware-lifetime immutable owner adapter. The existing two console slots are shared, not multiplied per frontend; active/executing slots cannot be replaced. Owners serialize per-session input, consume output and enforce transport liveness; completion scratch is claimed nonblockingly across owners. The existing control task calls drain/lifecycle adapters outside console locks. SSH uses generation-checked published snapshots, principal copies and its assigned console index, never wolfSSH from the control task. `SELF_CLOSE` is owner-relative; legacy SSH actions remain SSH-specific and unsupported owner actions are rejected. Dispatcher-side owner `is_current` checks run outside console locks, with full identity recheck after validation. Commands revalidate immediately before the runner; prompts revalidate before publication and after waits (250 ms polling plus check/scheduling latency), rejecting revoked submitted input and stale wakes. SSH preserves close intent through external-close consumption. Consumed output is wiped. These checks do not cancel arbitrary executing handlers or replace owner-side input/output and lifecycle validation.
For SSH, standard output/error is redirected to the invoking session's bounded output ring. `console_input` routes visible or hidden prompts to UART0 or the active SSH session. `exit` and Ctrl+D on an empty admin SSH line use bounded deferred self-disconnect after their acknowledgement drains; role-`user` SSH remains a binary-transparent serial stream. Session tokens include slot and generation so late queued work cannot attach to a reused SSH slot. Only the SSH owner task moves ring output through wolfSSH.
Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
Browser stop/reboot uses this same owner-adapter control path. Exact forced certificate rotation instead uses the typed queue union and immutable `dispatcher_actions` mask to hand off after drain/200 ms to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending input is discarded through execution and an executing slot remains reserved across self-detach. Canonical shared service/identity replacement preserves commit/stop/start failure semantics. Parsed other-account interactive add/password and forced role/delete are allowed, but browser self/generated/key/recovery and restricted network/SSH shell actions are not; typed Settings has separate permissions. Post-prompt currentness is operation admission, not an atomic session-liveness/NVS guarantee. [Browser policy and recovery](../web_administration.md#browser-shell-policy).
## Wi-Fi and persistence
`wifi_config` owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy `off`, `fallback`, or `always`. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used.
@@ -173,6 +199,12 @@ Persistent namespaces/blobs include:
Configuration modules generally choose RAM defaults without erasing incompatible storage. Security-material modules fail closed on malformed existing material and require explicit reset. OTA slots, coredump space, an NVS-key partition, and storage are reserved in `partitions.csv`; OTA, NVS encryption, coredump handling, and filesystem mounting are not implemented.
## Typed Display and Broker settings
`web_display_settings` queues IDs; `local_status_ui` owns a nonwrapping configuration generation and zero-wait writer reservation shared with CLI/legacy Apply. NVS runs outside critical sections. Save stabilizes RAM, Load preserves fallback behavior, Reset commits before RAM publication. Buttons/diagnostic holds change independent activity state, not config generation. Settings need an available UI task, not an attached OLED. [Display contract](../web_administration.md#serial-and-display).
`web_broker_settings` exposes compact zero-wait rows and confirmed writer assignment through one login-isolated slot. The broker atomically snapshots clients/writer/lease version and compares selected target/version inside the force-writer lock before effects. Three-bit slot/29-bit client generations retire rather than wrap; a separate saturated 32-bit lease generation advances before advisory event delivery and survives counter clear. Saturation blocks conditional assignment, not ordinary request/release/disconnect/recovery force. Contextual refresh never silently rebases explicit selections or clears sticky stale/absence latches; deliberate reselection is required. [Broker/context contracts](../web_administration.md#broker-and-contextual-controls).
## Local UI and hardware boundaries
`board_pins.h` centralizes project-assigned RS-232, diagnostic, RGB LED, and local-UI hardware resources; UART0 GPIOs remain local to `main.c`, and native USB uses platform wiring. `local_display` solely owns I2C0, the SSD1315-compatible OLED, its static framebuffer, and display mutex. Display frames belong to the initiating task. Dirty-page commits and I2C transactions are bounded.
@@ -193,3 +225,9 @@ Hardware diagnostics are synchronous console commands. RS-232 tests own the phys
- Avoid holding service/database/broker locks across I2C, network sends, or other potentially long operations unless the existing contract explicitly requires it. Preserve the existing broker-before-serial lock order.
- Serial RX/TX stream payloads, broker per-client payloads, the transactional user-database candidate, and selected cryptographic allocations prefer PSRAM with internal fallback. The live user database, FreeRTOS control structures, UART driver buffers, and task stacks remain internal where deterministic/cache-disable access matters.
- The build disables wolfSSL ESP32 AES/SHA acceleration, and the HTTPS path uses software AES for PSRAM-backed records. This preserves the validated workaround for uncoordinated mbedTLS/wolfSSL hardware-crypto locks and a prior mbedTLS external-RAM DMA watchdog stall.
## Legacy credential removal storage boundary
`user_database_init(load_result)` has no credential input. Missing storage is persisted empty; `user_database_recover_empty()` is the unavailable-only destructive recovery API. Valid v1 user bytes load without rewriting or account changes. The private `v1_admin_marker` retains its byte position and is derived from administrator count during mutations; it is not a public bootstrap state, new role or schema change. No user migration/bootstrap/synchronization API remains.
`web_security` owns TLS only. A private reader validates 1,392-byte v1 `web_sec/material`, copies exact key/certificate DER, fingerprint and generation into 1,340-byte v2, commits, then publishes. Temporary v1 credential-bearing input is wiped; no public legacy credential type/getter/rotation remains. Malformed/unknown records and read/validation/commit failures fail closed, with no fallback regeneration or overwrite of rejected records. Missing material may be generated; explicit reset replaces TLS only. Downgrade to v1-only firmware is incompatible. Logical NVS replacement is not secure flash erasure. Contracts/evidence: [legacy compatibility](../legacy_credential_removal.md).
+44 -14
View File
@@ -37,12 +37,13 @@ This is a semantic map, not a complete file inventory. Start here, then read the
**Responsibility:** mediate all transport access to the serial service; provide one writer lease and multiple isolated observers.
- Files: `src/session_broker.{h,c}`, `src/session_console.{h,c}`
- Interfaces: connect/disconnect, request/release/force writer, nonblocking read/write/event APIs, snapshots and counters
- Interfaces: connect/disconnect, request/release/force writer, nonblocking read/write/event APIs, snapshots and counters; `session_broker_get_management_snapshot()` and `session_broker_assign_writer_current()` atomically project/compare target and lease generation. Exhausted 29-bit client generations retire; saturated lease generation preserves ordinary recovery. Tests: `tests/session_broker_diagnostics/run.py`.
- Called by: USB, web serial, role-`user` SSH, console tests, local UI snapshots/actions
- Dependencies: `serial_service`
- Data path: `transport -> broker -> serial service -> UART1`; reverse data is fanned out per client.
- Ownership: client IDs are slot/generation-safe; events are advisory and can drop, so use snapshots as authority.
- Lifecycle: one permanent task and eight preallocated client slots; slow output drops only for the affected client.
- Diagnostics: `broker counters` adds active-client ID/type/pending/HWM/UART/queued/read/dropped rows; clear seeds HWM from pending, disconnect removes rows but retains global discard accounting. `tests/session_broker_diagnostics/run.py`; capture before disconnect, never use consuming `broker read` as a probe. Semantics/recipe: `docs/web_throughput_diagnostics.md`.
## Native USB CDC
@@ -61,24 +62,54 @@ This is a semantic map, not a complete file inventory. Start here, then read the
**Responsibility:** serve authenticated HTTPS UI/API, issue WebSocket tickets, and adapt browser serial sessions to broker clients.
- Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}`
- Security files: `src/web_security.{h,c}`
- Ordinary HTTPS idle cleanup: `src/web_httpd_idle.{c,h}`, owner sweep in `web_httpd_adapter.{c,h}`, lifecycle/TLS composition in `web_server.c`; `tests/web_httpd_idle/run.py`. Independent of diagnostics/optional transports: 15-second observed idle, one-second timer/one queued probe, six rows, actual WS/async/pending exemptions, safe current-owner shutdown and stop/restart fencing. No LRU/socket/timeout/stack increase. SDK queue/owner-delay and regression contract: `docs/https_idle_cleanup.md`.
- Independent throughput diagnostics: `web_serial_transport.{c,h}` owns two fixed per-slot binary-TX aggregates and epoch fences; `web_console.c` exposes default-disabled `web performance enable|disable|show|clear`. Queue-entry/callback-entry, synchronous-send and completion/drain-return estimates, not peer receipt or scheduler-only latency. `tests/web_serial_performance/run.py`; resource/evidence limits and UART0 paired capture: `docs/web_throughput_diagnostics.md`.
- Storage compatibility: `user_database` persists missing storage empty and preserves valid v1 user bytes; private derived `v1_admin_marker`, no public bootstrap/migration/sync APIs. `web_security` privately migrates v1 1392-byte material to TLS-only v2 1340-byte material, exact identity/generation retained, commit before publish, fail closed without fallback overwrite. Credential commands removed; user generated passwords and TLS rotation remain. Contracts, downgrade and evidence limits: `docs/legacy_credential_removal.md`.
- Security files: `src/web_security.{h,c}`, `src/web_cookie_auth.{h,c}`, `src/web_session_store.{h,c}`, `src/web_auth_parse.{h,c}`. Private IDF boundary: `src/web_httpd_adapter.{h,c}`.
- HTTP policy/UI: `web_cookie_auth` + `web_auth_parse` enforce bounded cookie/Origin/CSRF/admin admission; `web_login_ui.{c,h}` serves login, `web_ui.c` owns session-fenced Serial/Admin/Settings and shared quick controllers. Tests: `tests/web_cookie_auth/run.py` (domain variants), `tests/web_auth_parse/run.py`, `tests/web_login_ui/run.py`, `tests/web_ui_session/run.py`.
- Admission diagnostics: `web_diagnostics.{c,h}`, `tests/web_diagnostics/run.py`; six post-TLS records/32-event opt-in ring, no HTTPD off-owner inspection. [Contract](../web_admission_diagnostics.md).
- Identity/lifecycle: `web_server_replace_identity()` + `web_security` reserve service before identity; commit before reserved stop/start, no rollback after commit. `web_lifecycle_settings.{c,h}` owns original-login ID/ACK handoff. Tests: `tests/web_security/run.py`, `tests/web_admin_transport/server_lifecycle.py`.
- Asset files: authored/generated boundary in `src/web_assets_data.{h,c}`, `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py`
- Interfaces: web init/start/stop/snapshots; HTTP handlers; ticket mint/consume; attach/detach; targeted session revocation
- Called by: startup, ESP-IDF HTTPS server, user administration revocation, console/local UI
- Dependencies: user database, secure random, broker, successful Wi-Fi manager initialization at boot, mbedTLS/HTTPS server; actual network reachability is an operational prerequisite, not an initializer invariant
- Flow: `browser -> HTTPS Basic auth -> ticket -> WebSocket -> web transport -> broker`
- Flow: `browser -> HTTPS login/cookie session -> CSRF-protected ticket -> cookie/Origin/ticket admission -> WebSocket -> web transport -> broker`
- Ownership: HTTPD owns socket send/close work; transport task owns broker mediation; two fixed WebSocket slots and four outstanding tickets.
- Security constraints: Basic-auth cache hits still revalidate principal currentness; the browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect until Connect is selected. Changes to the authored inline loader must update its hard-coded CSP hash in the same change.
- Security constraints: Basic/cache removed; four absolute one-hour cookie sessions revalidate principal currentness. Four pre-login challenges (120 s), five credential attempts/60 s globally, no live session/challenge/ticket eviction. Origin/CSRF required for mutations; Origin/cookie/ticket before upgrade. Disconnect pauses reconnect but retains login; Sign out invalidates its session. Authored loader changes must update their hard-coded CSP hashes atomically.
- Session-store boundary: admitted HTTPS start initializes records; auth-init failure gates HTTPS. Failed start/accepted stop disables and wipes state. Tickets/slots require nonzero non-reused session IDs; session/account/global revocation invalidates store records before socket cleanup. RNG/SHA/database calls run outside short portMUX sections; ID/expiry/epoch checks reject stale work. Run `python3 tests/web_session_store/run.py` and its `--serial` integration mode.
- Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
### Browser admin backend
- Files: `src/web_admin_transport.{c,h}`, `src/web_admin_tickets.{c,h}`, protected registration/lifecycle in `web_server.c`, revocation through `web_serial_transport_revoke_*`, diagnostics in `web_console.c`.
- Routes: CSRF-protected admin-only `POST /api/admin/ws-ticket`; ordinary `GET /ws/admin` with cookie/Origin/ticket/shared-console admission before explicit 101. Admin UI entry is explicit; no admin broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled; current overall capacity is 39 URI handlers.
- Currentness/policy: `admin_ssh_console_open_available()` shares two slots with runtime SSH; transport-qualified tokens and owner adapters revalidate outside console locks before commands/prompts. Parsed browser policy remains narrower than typed Settings; [shell contract](../web_administration.md#browser-shell-policy). Tests: `tests/admin_console_boundary/{run,accounts,lifecycle}.py`, `tests/admin_ssh_policy/run.py`, `tests/web_admin_transport/run.py --tickets`, `tests/web_cookie_auth/run.py --admin`.
- Ownership: 20 ms ESP timer queues at most one HTTPD poll, no new task; HTTPD owns 1,552 B PSRAM-only payload and IO. Closure uses HTTPD-owned `shutdown`, not IDF's reusable-pointer queued close. Detach fences submitters; only successful HTTPD stop retires queued state before restart. Session/principal currentness and generation checks protect all sensitive boundaries.
## Typed settings source and regression map
HTTPD reads zero-wait projections and queues only IDs to the existing dispatcher. One original-login slot per domain; canonical owners compare/reserve at execution. [API/lifetime and failure contracts](../web_administration.md#typed-settings-api-and-operation-lifetime).
| Domain | API / canonical owner | Focused source tests |
|---|---|---|
| Serial | `web_serial_settings.{c,h}` / `serial_service` | `tests/web_cookie_auth/run.py --serial-settings`, `tests/admin_console_boundary/run.py` |
| Accounts / keys | `web_account_settings.{c,h}` / `user_database` | `tests/web_cookie_auth/run.py --accounts`, `tests/admin_console_boundary/accounts.py` |
| Network | `web_network_settings.{c,h}` / `wifi_manager`, `mdns_service` | `tests/web_cookie_auth/run.py --network`, `tests/web_network_settings/run.py` |
| Display | `web_display_settings.{c,h}` / `local_status_ui` | `tests/web_cookie_auth/run.py --display` |
| Broker | `web_broker_settings.{c,h}` / `session_broker` | `tests/web_cookie_auth/run.py --broker`, `tests/session_broker_diagnostics/run.py` |
| SSH | `web_ssh_settings.{c,h}` / `ssh_transport`, `ssh_security` | `tests/web_cookie_auth/run.py --ssh`, `tests/ssh_management/run.py`, `tests/ssh_management/security.py` |
| HTTPS / reboot | `web_lifecycle_settings.{c,h}` / `web_server`, `web_security` | `tests/web_cookie_auth/run.py --lifecycle`, `tests/web_admin_transport/server_lifecycle.py` |
Shared UI regression: `tests/web_ui_session/run.py` and its domain `.cjs` fixtures / `layout.py`. These are navigation pointers, not claims of test execution.
## SSH
**Responsibility:** authenticate SSH, route users to serial and administrators to the command dispatcher, and own wolfSSH lifecycle.
- Files: `src/ssh_transport.{h,c}`, `src/ssh_security.{h,c}`, `src/ssh_console.{h,c}`
- Interfaces: init/start/stop, session snapshots/disconnect/revocation, host-key replacement, counters
- Interfaces: init/start/stop, session snapshots/disconnect/revocation, host-key replacement, counters; `ssh_transport_get_management_snapshot()` / `ssh_transport_manage_current()` fence lifecycle and exact session admission. `ssh_transport_replace_identity()` reserves service before task-bound security identity across stop/commit/restart, retains context until all slots retire and rejects orphan starts. Tests: `tests/ssh_management/run.py`, `tests/ssh_management/security.py`, `tests/ssh_management/runtime.py`.
- Called by: startup, network clients, user revocation, console/local UI
- Dependencies: user database, broker, admin SSH console, secure random, wolfSSH/wolfSSL; current boot start gate also depends on `web_security` readiness
- Dependencies: user database, broker, admin SSH console, secure random, wolfSSH/wolfSSL; boot start gate requires Wi-Fi and SSH security/runtime readiness, independently of HTTPS identity readiness (verified in `main.c` after accepted legacy cleanup).
- Flow: role `user` -> broker; role `admin` -> `admin_ssh_console`
- Ownership: after caller-side library initialization, one task pinned to core 1 owns runtime wolfSSH contexts/sessions; two fixed generation-tagged slots.
- Security constraint: an interactive shell request is required; exec and subsystems are rejected, and no project file-transfer or forwarding route exists. PTY is not explicitly required.
@@ -88,22 +119,22 @@ This is a semantic map, not a complete file inventory. Start here, then read the
**Responsibility:** persist bounded accounts, verify passwords/SSH keys, issue secret-free principals, and enforce account invariants.
- Files: `src/user_database.{h,c}`, `src/user_console.{h,c}`; `src/admin_command_gate.{h,c}` is currently a narrow recursive wrapper used only by the `user` command handler, not the global command serializer
- Interfaces: init/migration/recovery, authenticate, principal-currentness, account/password/role/key mutations, snapshots
- Interfaces: credential-independent init/empty recovery, authenticate, principal-currentness, account/password/role/key mutations, snapshots
- Called by: web and SSH authentication/currentness checks and console administration
- Dependencies: NVS, secure random, mbedTLS cryptography; after a committed command-layer mutation, best-effort web/SSH revocation calls supplement authoritative transport currentness checks
- Ownership: database mutex protects the internal live record and PSRAM-preferred transactional candidate; password authentication runs PBKDF2 outside the mutex and revalidates afterward, while mutation locking must be checked per operation.
- Authorization: UART0 exclusively owns initial administrator bootstrap and unavailable-database recovery; current admins may use admin SSH for other commands unless handler policy denies them. HTTPS currently treats both roles alike.
- Authorization: UART0 establishes the first administrator through normal `user add` and exclusively owns unavailable-database recovery to empty (healthy database refused); current admins may use admin SSH for other commands unless handler policy denies them. HTTPS serial/status permits both roles; administration requires `admin`.
- Constraint: final administrator cannot be deleted or demoted; transport principals must be rechecked after mutations.
## Administration console infrastructure
**Responsibility:** provide one canonical command registry and serialized execution for UART0 and admin SSH.
**Responsibility:** provide one canonical command registry and serialized execution for UART0, admin SSH and browser admin.
- Files: `src/admin_ssh_console.{h,c}`, `src/console_input.{h,c}`, `src/console_completion.{h,c}`, `src/system_console.{h,c}`, `src/network_console.{h,c}` and all `*_console.{h,c}` modules
- Entry points: `admin_ssh_console_init()`, `admin_ssh_console_start_uart_frontend()`, command registration functions
- Called by: startup, UART0 frontend, role-`admin` SSH transport
- Called by: startup, UART0 frontend, role-`admin` SSH transport, browser admin transport
- Dependencies: ESP-IDF console/linenoise, all command handlers, user-principal currentness
- Flow: `UART0/admin SSH -> bounded request queue -> one dispatcher -> esp_console_run()`
- Flow: `UART0/admin SSH/browser admin -> bounded request queue -> one dispatcher -> esp_console_run()`
- Ownership: dispatcher is sole `esp_console_run()` caller; the SSH owner exclusively performs post-initialization wolfSSH runtime calls.
- Lifecycle: remote session tokens include slot generation; fixed output/history/prompt state is wiped immediately on idle close or after an executing handler returns. Admin SSH `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort output draining.
- Constraint: one slow command or prompt serializes all administration. Admin SSH is unavailable until command registration and UART frontend creation complete; supported deferred actions wait only for a bounded application-buffer drain heuristic.
@@ -114,7 +145,6 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Files: `src/wifi_config.{h,c}`, `src/wifi_manager.{h,c}`, `src/wifi_console.{h,c}`, `src/mdns_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_console.{h,c}`, `src/network_console.{h,c}`
- Interfaces: config defaults/validate/load/save; manager init/start/stop/apply/reconnect/next-profile/snapshot
- Called by: startup, console, local UI, ESP event callbacks
- Dependencies: secure random for default AP password, NVS, ESP-NETIF/Wi-Fi/events, Espressif mDNS, lwIP diagnostics
- Lifecycle: permanent manager task and bounded queue; callbacks enqueue compact events only.
- Constraint: application NVS is authoritative (`WIFI_STORAGE_RAM`); working edits are not persisted until save. Start/stop, including local controls, intentionally update the RAM `enabled_at_boot` field. Working-config copies contain PSKs and must be tightly scoped and wiped; routine status/local UI must use secret-free snapshots.
@@ -124,12 +154,12 @@ This is a semantic map, not a complete file inventory. Start here, then read the
**Responsibility:** own OLED I2C/framebuffer operations and present status plus constrained button actions.
- Files: `src/local_display.{h,c}`, `src/local_status_ui.{h,c}`, `src/local_boot_animation.{h,c}`, `src/local_ui_config.{h,c}`, `src/local_ui_console.{h,c}`
- Interfaces: display init/frame/draw/commit/snapshot; UI start/activity/config; versioned NVS settings
- Interfaces: display init/frame/draw/commit/snapshot; UI start/activity/config; generation-checked settings projection/update and explicit persistence reservation; versioned NVS settings
- Called by: startup, local UI task, diagnostics, display console
- Dependencies: copied snapshots/public APIs from serial, broker, USB, Wi-Fi, web, SSH
- Ownership: `local_display` solely owns I2C0 and framebuffer mutex; a frame belongs to its initiating task.
- Lifecycle: the low-priority task is firmware-lifetime only if button GPIO initialization succeeds; it still runs with an absent panel so a press can reprobe after successful I2C bus setup. Failed bus creation is not recoverable by that reprobe, and `display` configuration commands depend on the UI task.
- Constraint: collect service snapshots before I2C; local UI never joins broker or handles secrets.
- Constraint: collect service snapshots before I2C; local UI never joins broker or handles secrets. All configuration writers honor the UI owner's zero-wait reservation; NVS runs outside timing critical sections. Reset commits defaults before RAM publication, including CLI; buttons/diagnostic holds update activity, not configuration generation.
## Hardware and diagnostics
+19 -54
View File
@@ -1,65 +1,30 @@
# Current project state
This file is working memory. Update it during active work and before handoff; do not treat it as a permanent design record.
Working memory, not an implementation timeline. Source is authoritative; begin with [code map](code-map.md), then [architecture](architecture.md) and [decisions](design-decisions.md).
## Development state
## Accepted state — 2026-09-13
Based on checked-in source plus `README.md` and `docs/roadmap.md`:
- **8D.22 explicitly signed off by the user:** “Yep, I tested the firmware thats a 8d.22 signoff.” The retained Phase 8D scope is complete; earlier per-slice pending target/review/integration gates are superseded. Roadmap already records 8A/B/C as complete and target-hardware validated, so **Phase 8 is complete**. Acceptance does not manufacture individual unreported test passes.
- [Roadmap](../roadmap.md#phase-8--role-based-users-and-administrative-access--complete) holds the completed-phase gist; [web administration](../web_administration.md) holds current API/owner/recovery contracts; [acceptance evidence](../web_administration_acceptance.md) holds the latest report and limits. The old plan/baseline/per-slice histories are consolidated, not archived as another timeline. Test READMEs now link directly to current contracts and evidence; obsolete forwarding notes were removed too.
- Final prior production build **PASS: 100,556 B linked RAM / 1,828,573 B flash, CPU 160 MHz**. No build/test/device execution is implied by this documentation update. The prior combined binary WebSocket-send fix was separately user-accepted at **160 MHz / 230400 baud with full mix including browser admin**; preserve combined send and bounded failed-send isolation.
- Latest loaded capture: two serial WS, USB, two SSH roles with SSH serial writer; browser admin used then closed, not active in the capture. Internal/DMA/PSRAM free **31,508 / 23,752 / 8,136,624 B**, minima **2,052 / 460 / 8,065,972 B**, largest **18,432 / 18,432 / 7,995,392 B**; SSH minimum-free stack **15,028 B**. Full boot/loaded table is in acceptance evidence. Web send/queue/protocol and SSH IO errors zero; one SSH handshake failure/session revocation. Missing latest broker/serial counters prohibit an exact zero-drop inference.
- Hardware characterization, serial service, session broker, USB CDC, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and local display/control are implemented and documented as target-hardware validated.
- Phase 8A role-based user storage/UART0 administration and Phase 8B role-aware HTTPS/SSH authentication and targeted revocation are documented as target-hardware validated.
- Phase 8C admin SSH is implemented in source, uses the shared `esp_console` registry, and has passed target-hardware validation.
- Phase 8D integrated web administration is planned, not implemented. Its ordered scope combines browser login/logout sessions, a shared admin-shell terminal route, typed settings, and contextual Serial/Wi-Fi/client/writer controls; changing terminal modes must preserve the browser serial broker client and any writer lease.
- Security/production hardening, OTA, BLE evaluation, advanced networking, and optional filesystem features remain future roadmap work.
- Reserved OTA, coredump, NVS-key, and storage partitions do not imply those runtime features are implemented.
## Follow-ups, not acceptance blockers
## Recent memory audit
- Extremely low internal/DMA lifetime minima warrant correlated transient-headroom investigation; overlapping capability pools and conservative/non-simultaneous region minima do not prove allocation failure. Numeric reserves, HTTPD/dispatcher stack margins, peak correlation and detailed soak/fault evidence remain unapproved/unreported. Do not reopen functional sign-off or invent a reserve threshold.
- SDK TLS `-0x004C` is generic NET_RECV_FAILED, not OOM. Two boot auth failures plausibly involve stale cookies, but causation is unconfirmed. Prior intermittent web admission issues and accepted idle cleanup do not justify claiming every admission failure fixed.
- Real DNS/reannouncement, NVS power-loss, browser geometry/accessibility, individual fault cases and exact duration claims require explicit evidence if investigated; retained [regression procedures](../user_administration_tests.md) are not execution records.
- **Next: Phase 9 security/production hardening only if requested.** No source, configuration, test implementation, build, upload, erase, branch or commit is authorized by documentation consolidation.
- Fixed failed-initialization ownership leaks for wolfSSH, partial HTTPS startup, and TinyUSB teardown. Failed teardown now retains ownership and blocks unsafe duplicate initialization.
- Serial-service RX/TX stream payloads (16 KiB and 8 KiB effective capacity) now prefer PSRAM with internal fallback; FreeRTOS controls and UART driver buffers remain internal.
- The 5,360-byte transactional user-database candidate now prefers PSRAM with internal fallback while the live database remains internal. Candidate contents are wiped after each transaction and wiped/freed on initialization or recovery failure.
- UART and admin-SSH completion formatter buffers were reduced from 2 KiB to 1 KiB each; current worst-case output is 890 bytes and overflow remains fail-closed.
- Linked RAM fell from 99,508 to 92,188 bytes (7,320 bytes). PSRAM placement of serial payloads additionally removes about 24 KiB of normal internal-heap pressure on the target.
- `pio run` passes. A preliminary target run reports significantly more free memory and stable, improved operation after these changes. This is useful evidence but not completion of Phase 8C validation.
- The reviewed mDNS-enabled build uses 94,532 bytes of linked static RAM, 2,344 bytes above the earlier 92,188-byte baseline, and 1,599,765 bytes of flash. Minimizing the managed component saved 112 bytes of linked RAM and about 5.9 KiB flash versus the first mDNS build. Its 4 KiB task stack remains internal, while checked-in settings move general mDNS allocations to PSRAM and disable unused browse, component CLI, AP/ETH, and multiple-instance features. Runtime heap impact still requires target measurement.
- Remaining targeted checks include stored/migrated/recovered user-database mutations, USB enumeration, HTTPS start/stop failure recovery where injectable, SSH initialization/login, completion display, and sustained multi-transport serial traffic while checking `memory` telemetry.
## Scope and safety to retain
## Clearly incomplete or transitional areas
- 8D.15 dedicated typed network diagnostics was removed; shell diagnostics remain subject to frontend policy. Unimplemented 8D.19 ordinary browser-session/native-USB controls were removed; SSH settings remain. No implicit full browser-shell parity or browser identity reset/recovery/export.
- One UART1 broker writer, isolated observers and binary transparency. UART0 is administrative recovery; native USB is network-independent UART1, not administration or uninterrupted reboot.
- Typed operations carry original-login IDs to the existing dispatcher. Owner-reserved generations fence stale/ABA changes; later revocation/timeout does not cancel admitted work. HTTPS commits before stop/restart; SSH stops before commit/restart; committed identity never rolls back on lifecycle failure. Lost ACK/result means uncertainty, never automatic replay.
- Preserve private IDF HTTPD version guards, at-most-one owner-work reservations through failed destruction, retained SSH context until all slots retire, bounded queues/buffers and secret-free metadata. Canonical recovery survives conditional-token exhaustion.
- Phase 8C hardware validation passed, including route separation, shared command serialization, history/completion, prompts, output backpressure, revocation during queued work, deferred SSH lifecycle/reboot actions, and full concurrent transport operation. At 460800 baud with SSH and WebSocket clients in parallel, substantial packet drops and slow display controls were observed under load, without memory exhaustion; no baud-rate reduction is planned.
- Current HTTPS has no web-based user administration and gives both roles the same status/terminal routes.
- Browser authentication still uses HTTP Basic; Phase 8D plans integrated login/logout sessions before exposing administrative browser routes.
- NVS encryption, secure boot/flash encryption review, authentication rate limiting, production certificate/provisioning policy, and OTA are not implemented.
## Documentation handoff
## Known inconsistencies
Initial Git status was clean. This task changes root `README.md`, `docs/` and five test-directory READMEs; executable source/tests/config/generated assets remain untouched. Independent documentation review checked acceptance scope, owner contracts and local links. It restored explicit pointer-backed HTTPD response-header lifetime and same-version SDK-patch audit warnings, updated test README links, and removed obsolete forwarding notes without reopening sign-off.
These observations should be checked when touching the relevant area; they are not automatically bugs requiring unrelated cleanup.
- Some source comments still call shared commands UART0-only or call the current local status/control task read-only.
- `USER_DATABASE_LOAD_EMPTY` is only an initialization/failure sentinel at the checked-in revision: every successful `user_database_init()` path returns `STORED` or `MIGRATED_LEGACY`, so `main.c`'s successful "new empty" log branch is unreachable.
- SSH startup is currently gated on successful `web_security` initialization even though SSH uses separate host-key material. **Needs verification:** whether this coupling is intentional recovery policy or an accidental startup dependency.
## Items to verify in future work
- Confirm task-local Newlib standard-stream behavior if ESP-IDF/Newlib configuration changes; admin SSH command output relies on dispatcher-task stream redirection.
- If HTTPD concurrency configuration changes, add locking around the boot-local Basic-authentication cache.
## Active Task
- **Objective:** Announce a configurable `sak-<suffix>.local` hostname through mDNS when Wi-Fi STA has an IPv4 address, without changing the Wi-Fi NVS blob schema.
- **Relevant files:** `src/mdns_config.{c,h}`, `src/mdns_service.{c,h}`, `src/mdns_console.{c,h}`, `src/wifi_manager.{c,h}`, `src/main.c`, `src/CMakeLists.txt`, `src/idf_component.yml`, `dependencies.lock`, completion and command documentation.
- **Findings:** `wifi_manager` already serializes all meaningful STA transitions through its permanent task; callbacks only enqueue events. This is the appropriate lifecycle owner for mDNS, while a separate configuration module preserves the existing `wifi_app/config` wire format.
- **Decision:** Persist a fixed v1 record under `mdns_cfg/config`, separate from Wi-Fi configuration. Defaults derive a safe lower-case hexadecimal suffix from the STA MAC. The manager initializes mDNS at most once after validating `IP_EVENT_STA_GOT_IP`; the managed component's own handlers withdraw/restore the STA responder across connectivity changes, and online hostname changes use `mdns_hostname_set()` without teardown. Initialization failure is latched instead of retried because the resolved upstream 1.12.0 component has an unsafe partial low-memory initialization path. mDNS errors cannot fail Wi-Fi, UART0, UART1, or native USB.
- **Changes completed:** Added the `espressif/mdns` managed dependency (resolved to 1.12.0 on IDF 5.5), mDNS config/service/console modules, `mdns status|suffix|save|load|defaults|reset`, completion, CMake integration, and command/architecture documentation. Minimized the component to STA-only responder use, moved general allocations to PSRAM, retained the internal task stack, and removed reconnect-time free/reinit churn. Final `pio run` passes at 94,532 bytes linked RAM and 1,599,765 bytes flash.
- **Remaining work:** Target-hardware verification: associate a station and resolve the default `sak-<mac>.local`; change/save/load a suffix and confirm live reannouncement plus reboot persistence; stop Wi-Fi or remove the STA lease and confirm the record withdraws. Verify serial, native USB, and UART0 remain available if mDNS initialization fails.
- **Risks / things to remember:** Hostnames are STA-only and are intentionally not announced by fallback AP mode. NVS changes to `mdns_cfg/config` are independent of the unchanged `wifi_app/config` blob. mDNS remains allocated after first successful initialization (including its internal 4 KiB task stack) to avoid fragmentation and unsafe repeated initialization; measure free/minimum/largest internal heap and mDNS stack margin during reconnect stress.
### Handoff template
- **Objective:**
- **Relevant files:**
- **Findings:**
- **Decisions made:**
- **Changes completed:**
- **Remaining work:**
- **Risks / things to remember:**
Validation completed: independent Python local-link/anchor audit PASS across 29 authored Markdown files (122 local links, 53 Markdown fragments); 213 authored source/test/document files checked with zero obsolete Phase 8D filename references. Earlier path audit resolved127 expanded navigation references. GPT logs, vendored/generated trees and remote URL fetching excluded. Twenty obsolete phase documents removed, with no forwarding stubs/archive dump. Git diff/scope checks confirm documentation-only changes. No firmware tests/build/device commands run.
+47 -17
View File
@@ -2,6 +2,18 @@
Only constraints supported by implementation or current project documentation belong here. When original rationale is unknown, the entry describes the observable constraint without inventing intent.
## Display configuration has an owner reservation separate from button activity
**Decision:** `local_status_ui` owns a nonwrapping configuration generation and a zero-wait reservation shared by typed Display and CLI/legacy Apply. NVS runs outside timing critical sections; Save stabilizes selected bytes and Reset commits defaults before RAM publication. Load retains canonical fallback. Buttons/diagnostic holds update activity, not configuration generation.
**Consequence:** Compare before mutation, never overwrite intervening CLI edits, and do not make physical panel presence a configuration prerequisite. A successful config API is not proof of display IO. [Owner and persistence contract](../web_administration.md#serial-and-display).
## Typed Network edits preserve manager ownership and current secret bytes
**Decision:** HTTPD uses zero-wait secret-free projections and an ID-only dispatcher. Wi-Fi compare/merge/whole-candidate validation and queue-before-publication preserve omitted secrets and reject stale changes; its manager alone owns radio/reannouncement. mDNS generation/persistence is independent. Save stabilizes selected RAM; Wi-Fi Load is stored-only, not fallback-secret generation.
**Consequence:** Keep SSIDs reversible bytes, omitted/Replace/disabled-STA Clear distinct, AP clear denied, and Next profile separate from editor selection. mDNS may report RAM applied but reannouncement not queued, without rollback. A one-second queued-secret timer is not hard cancellation; `accepted` is not online/DNS. Recovery and no automatic replay are correctness requirements. [Network contract](../web_administration.md#network).
## One broker mediates all production serial transports
**Decision:** USB CDC, WebSocket, and role-`user` SSH access UART1 through `session_broker`; transports do not independently own the serial service.
@@ -20,6 +32,8 @@ Only constraints supported by implementation or current project documentation be
**Consequence for future changes:** Do not replace fan-out with a blocking shared queue. Any added transport must tolerate partial/no-progress reads and expose drop/backpressure counters.
**Throughput observation and controlled experiments:** The initial diagnostic baseline used CPU160MHz; a CPU240MHz-only experiment reduced but did not eliminate browser queue overflow. Combining binary WebSocket header/payload into one bounded session-override send eliminated reported drops, and the user signed off230400-baud full-client-mix operation after returning to160MHz. Retain the combined send, not the frequency increase; evidence and limits are in `current-state.md`. Preserve scheduling/priorities and 4096/512-byte broker/web buffers while gathering per-client HWM/drop attribution and independent opt-in web binary-TX timing. Fixed-slot epoch/generation-fenced aggregates avoid stale attribution; no new runtime allocations. Clear preserves queued data and seeds broker HWM; disconnected rows disappear while global discard counts remain. Callback timestamps precede the transport lock; synchronous send return is not peer receipt. Completion-to-read intervals include broker/mutex/control work and possible idle, even when the first read is nonempty; never label them pure scheduling latency or proof of backlog at completion. Compare enabled/disabled target captures before drawing overhead conclusions. Contracts and reproduction: [throughput diagnostics](../web_throughput_diagnostics.md).
**Relevant files:** `src/session_broker.c`, `src/session_broker.h`, `docs/roadmap.md`
## Physical UART ownership and logical writer ownership remain separate
@@ -34,21 +48,23 @@ Only constraints supported by implementation or current project documentation be
## Resource IDs are generation-safe
**Decision:** Broker clients, SSH/WebSocket slots, queued admin work, and user principals carry generations or random stable IDs to reject stale references and slot reuse.
**Decision:** Broker/SSH/WebSocket slots, originating web sessions, queued admin operations and account principals carry distinct generation/identity fences. Browser cookie-session identity is not interchangeable with account identity. Invalidate session records before socket cleanup and retain authoritative currentness checks even when notifications fail.
**Rationale/evidence:** Broker IDs encode slot generation; transports track slot generations; admin tokens include session/slot generation; user principal currentness includes account ID and authentication generation.
**Consequence:** Never turn selected transport IDs into arbitrary broker IDs/fds, rebase a stale confirmation or wrap a published token. Exhausted broker/SSH slots retire; operation/reservation IDs do not reuse; saturated service/lease generations fence ABA without disabling canonical recovery. Reboot invalidates old logins. Validate/reserve at the owner immediately before effects, not snapshot-check/unlock/unconditional mutation. [Broker](../web_administration.md#broker-and-contextual-controls) and [service](../web_administration.md#service-lifecycle-and-identity-rotation) contracts.
**Consequence for future changes:** Preserve transport-slot generations and account-authentication generations as distinct concepts. Validate tokens immediately before side effects and discard late work after disconnect/reuse/revocation.
## Confirmed writer transfer compares a lease version inside the broker lock
**Relevant files:** `src/session_broker.{h,c}`, `src/ssh_transport.c`, `src/web_serial_transport.c`, `src/admin_ssh_console.c`, `src/user_database.{h,c}`
**Decision:** Compare the connected nonreused target and separate lease generation inside the force-writer lock. A client ID alone cannot fence release/reacquire ABA. Lease generation saturates, survives counter clear and advances before advisory event delivery, potentially twice for force transfer.
**Consequence:** Saturation rejects typed assignment but preserves ordinary request/release/disconnect and recovery force. Accepted serial TX is not recalled. Live contextual refresh must retain the selected versions and sticky stale/absence state until explicit reselection. [Wrap and UI contract](../web_administration.md#broker-and-contextual-controls).
## UART0 is the physical recovery authority
**Decision:** UART0 remains independent of UART1 and networking. Initial administrator bootstrap and explicit unavailable-user-database recovery are restricted to UART0.
**Decision:** UART0 remains independent of UART1 and networking. The first administrator is created with normal `user add` on UART0; explicit unavailable-user-database recovery to empty is UART0-only and refuses healthy storage. No bootstrap command/API remains.
**Rationale/evidence:** `main.c` configures UART0 separately; command policy and user handlers deny these operations remotely. README/roadmap identify UART0 as the trusted recovery console.
**Consequence for future changes:** Network failures or credential corruption must not remove UART0 recovery. Do not expose bootstrap/recovery through web or admin SSH without an explicit security redesign.
**Consequence for future changes:** Network failures or credential corruption must not remove UART0 recovery. Do not expose unauthenticated first-admin provisioning or recovery through web or admin SSH without an explicit security redesign.
**Relevant files:** `src/main.c`, `src/admin_ssh_console.c`, `src/user_console.c`, `docs/roadmap.md`
@@ -74,23 +90,37 @@ Only constraints supported by implementation or current project documentation be
## Selected self-affecting admin SSH actions use bounded deferred control
**Decision:** Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset are deferred until command state and administration/transport application buffers appear drained, with a ten-second limit and short final delay.
**Decision:** SSH self-close/reboot/lifecycle/host-key actions use existing bounded application-drain control; browser shell stop/reboot and typed certificate handoff use owner adapters, not command replay. Drain is not peer receipt. Crypto/NVS browser rotation runs on the existing dispatcher, not the small control stack; executing slots remain reserved across self-detach.
**Rationale/evidence:** `admin_ssh_console` has a separate bounded control task and pending-action state. The check is a best-effort application-buffer heuristic, not peer-delivery confirmation. User account mutations and their immediate revocation calls do not use this path.
**HTTPS typed ACK:** Successful synchronous send return precedes one nonreused-ID HTTPD callback to the existing dispatcher. No captured request/fd/reusable operation pointer or lifecycle wait on HTTPD. Lost accepted work retains one reservation until callback or successful destruction; never release speculatively on timeout. Original-login/current-admin/dequeue deadline checks precede admission. Reserved restart may deliberately invalidate that login; subsequent revocation is not cancellation. Reboot invokes canonical `esp_restart()` outside locks without self-console cleanup.
**Consequence for future changes:** Actions that would invalidate their own SSH transport should integrate with deferred control when acknowledgement preservation matters. Prevent new input while an action is pending, keep the wait bounded, and do not describe it as guaranteed delivery.
**Identity replacement:** Compare/reserve service then task-bound nonreused identity token, shared by canonical/direct callers. Crypto/NVS run outside security locks. HTTPS commits before stop/start; SSH stops before commit/restart. Failed SSH stop skips mutation/start; persistence failure can follow client disconnection. Committed material is never rolled back on restart failure. SSH owner retains context until all slots retire; start rejects orphan handles. Public fingerprint projections do not authorize mutation, and stored/served HTTPS identity can differ after failed stop.
**Relevant files:** `src/admin_ssh_console.c`, `src/system_console.c`, `src/ssh_console.c`, `src/ssh_transport.c`
**Consequence:** Explicitly communicate partial effects, trusted UART0 fingerprint verification and fresh HTTPS login after restart. Preserve CLI recovery/reset but do not add browser Reset/export as if it were ordinary rotation. Native USB is independent UART1, not administration or uninterrupted reboot. [Lifecycle/identity contract](../web_administration.md#service-lifecycle-and-identity-rotation).
## Authentication uses copied principals and fail-safe currentness checks
**Decision:** Network sessions retain secret-free copied principals. Account mutations invalidate generations/IDs; after commit, the command layer requests best-effort targeted transport revocation, while ongoing currentness checks are authoritative.
**Decision:** Network sessions retain secret-free copied principals. Database/account ID/auth generation and originating web-session identity must be current at admission, before sensitive input and during reconciliation. Best-effort target notifications supplement, never replace, these checks and cannot roll back committed mutations.
**Rationale/evidence:** `user_database` issues principals without secrets; web/SSH check currentness during admission and active sessions. Mutating console paths call transport revocation hooks.
**Consequence:** Shared remote-console slots require transport-qualified tokens and immutable owner adapters. Validate owner currentness outside console locks, then recheck identity. Owner-side HTTPD/SSH IO and generation-safe cleanup remain mandatory; session liveness checks do not cancel executing handlers. Browser-shell permissions are parsed and narrower than typed Settings. [Authentication](../web_administration.md#authentication-and-admission), [console policy](../web_administration.md#browser-shell-policy).
**Consequence for future changes:** Do not retain pointers to database records or treat login as permanently authoritative. New authenticated sessions/transports must revalidate at admission, before sensitive input, and periodically or on relevant events. Database mutation APIs alone do not perform transport notification, and notification failure must not roll back an already committed mutation.
## Typed serial mutations share the administration dispatcher
**Relevant files:** `src/user_database.{h,c}`, `src/user_console.c`, `src/web_server.c`, `src/web_serial_transport.c`, `src/ssh_transport.c`
**Decision:** Typed domains queue IDs to the existing serialized dispatcher, never CLI strings or secrets. One original-login slot per domain and a nonreused ID fence stale work; session/deadline checks precede canonical admission. Results are replaceable observations, not durable history/idempotency.
**Consequence:** No automatic mutation replay, including after navigation, timeout or logout. Ordinary deadlines limit dequeue admission, not execution. Accounts/Network queued-secret timers wipe only non-executing inputs; locals wipe after admitted work returns. Explicit RAM/NVS/reset semantics and partial-effect uncertainty must match each canonical owner. [API bounds and lifetime](../web_administration.md#typed-settings-api-and-operation-lifetime).
## Typed account selection is checked inside the database mutation lock
**Decision:** Target username/account ID/auth generation compare occurs inside the canonical mutation lock for role/delete/password/key changes. HTTPD uses compact zero-wait secret-free projections, not the blocking CLI snapshot. Successful changes notify only the target's web/SSH sessions, including self.
**Consequence:** Separate generated-value delivery from mutation and retain no retrieval history; context-bound saved acknowledgement is UX, not receipt proof. Self-revocation can deny results, so 401/disconnect cannot mean success or cancellation. Key slots are stable and sparse, fingerprint-only on output; import shares canonical validation. Final-admin invariants and UART0 provisioning/recovery remain. [Accounts contract](../web_administration.md#accounts-and-authorized-keys).
## Browser authentication has a narrow version-pinned HTTPD boundary
**Decision:** `web_httpd_adapter` alone accesses private IDF 5.5.0 parsed-header/session state. Reject duplicate/ambiguous headers; defer 101 until cookie/Origin/ticket/transport admission; wipe consumed scratch while preserving unread bytes. Stage optional Settings descriptor/name allocations before publication. Compile header/ticket debug logging out.
**Consequence:** Re-audit SDK assumptions on upgrade; never patch around Origin `null` by weakening same-origin policy. Browser authentication POST uses CORS mode with fixed same-origin URLs/credentials because no-referrer non-CORS POST can serialize Origin as null. Digest-only cookie/challenge sessions replace Basic without fallback or live-record eviction. CSP loader hashes and authored scripts change atomically. Navigation preserves terminals/lease, while session-identity changes require a clean document before showing retained buffers. [Authentication and terminal contracts](../web_administration.md#authentication-and-admission).
## Security material and configuration use bounded, versioned NVS records
@@ -98,17 +128,17 @@ Only constraints supported by implementation or current project documentation be
**Rationale/evidence:** Serial, Wi-Fi, local UI, web security, users, and SSH security each validate schema/size and own their namespace. User/security mutations build and validate candidate state before committing it; security modules avoid silently replacing an established identity. The live user database remains internal while its 5,360-byte candidate is a persistent PSRAM-preferred allocation with internal fallback and is wiped after every transaction.
**Consequence for future changes:** Add schema versions and transactional candidate validation. Do not overwrite unknown records automatically; provide explicit migration/reset behavior. Preserve the distinct persistence contracts: explicit save/load/default/reset for working configuration and per-blob commit-before-live-install for user and identity mutation. Keep candidate ownership mutex-local and wipe/free it on initialization or recovery failure. Recheck external-buffer staging in the flash/NVS implementation when upgrading from the pinned ESP-IDF 5.5 baseline. Pre-bootstrap legacy credential rotation spans `web_sec/material` and `user_db/database`, is not cross-namespace atomic, and relies on boot reconciliation after interruption.
**Consequence for future changes:** Add schema versions and transactional candidate validation. Do not overwrite unknown records automatically; provide explicit migration/reset behavior. Preserve the distinct persistence contracts: explicit save/load/default/reset for working configuration and per-blob commit-before-live-install for user and identity mutation. Keep candidate ownership mutex-local and wipe/free it on initialization or recovery failure. Recheck external-buffer staging in the flash/NVS implementation when upgrading from the pinned ESP-IDF 5.5 baseline. Legacy credential synchronization and reconciliation are removed. Missing user storage commits empty; valid user v1 bytes remain compatible, with private `v1_admin_marker` derived from admin count, not a public bootstrap contract. HTTPS v1 (1,392 bytes) migrates through a private validated reader to TLS-only v2 (1,340 bytes), preserving exact DER/fingerprint/generation and committing before publication. Failures fail closed without fallback regeneration or overwriting rejected records. See [legacy compatibility](../legacy_credential_removal.md).
**Relevant files:** `src/serial_config.c`, `src/wifi_config.c`, `src/mdns_config.c`, `src/mdns_service.c`, `src/local_ui_config.c`, `src/web_security.c`, `src/user_database.c`, `src/ssh_security.c`
## NVS is persistence, not a physical security boundary
**Decision:** The current firmware stores Wi-Fi credentials, recovery credentials, and TLS/SSH private keys in unencrypted application NVS. The reserved NVS-key partition does not enable encryption.
**Decision:** The current firmware stores Wi-Fi credentials and TLS/SSH private keys in unencrypted application NVS. The reserved NVS-key partition does not enable encryption.
**Rationale/evidence:** `partitions.csv`, README security notes, and current code show no NVS-encryption setup. Original rationale for deferring encryption is outside the implementation; the observable limitation is explicit.
**Consequence for future changes:** Do not claim resistance to flash extraction. Avoid increasing stored secret exposure. Enabling encryption requires migration/recovery planning, not just changing the partition table.
**Consequence for future changes:** Do not claim resistance to flash extraction. Logical NVS replacement can leave old plaintext credentials in flash and is not secure erasure; no factory erase is required by this cleanup. Older v1-only firmware cannot read v2 HTTPS material. Avoid increasing stored secret exposure. Enabling encryption requires migration/recovery planning, not just changing the partition table.
**Relevant files:** `partitions.csv`, `README.md`, `src/web_security.c`, `src/ssh_security.c`, `src/wifi_config.c`
+26 -18
View File
@@ -1,6 +1,10 @@
# Command reference
UART0 and authenticated `admin` SSH sessions use the same registered command implementations through one serialized dispatcher. Admin SSH exposes the full operational registry, including interactive prompts, recovery-secret display, network diagnostics, reboot, and HTTPS/SSH material mutation. Initial administrator bootstrap and explicit recovery of an unavailable user database remain physically bound to UART0. Admin SSH also rejects generating a replacement password for its own account so the one-time value cannot be lost when the session is revoked. Run `help` for root commands and `<group> help` for a group summary. Configuration changes are RAM-only unless explicitly saved.
UART0 and authenticated `admin` SSH sessions use the same registered command implementations through one serialized dispatcher. Admin SSH exposes the full operational registry, including interactive prompts, network diagnostics, reboot, and HTTPS/SSH material mutation. Create the first administrator through normal `user add <username> admin` on physical UART0; explicit recovery of an unavailable database is UART0-only. Admin SSH also rejects generating a replacement password for its own account so the one-time value cannot be lost when the session is revoked. Run `help` for root commands and `<group> help` for a group summary. Configuration changes are RAM-only unless explicitly saved.
Browser admin uses the same dispatcher with a [narrower parsed frontend policy](web_administration.md#browser-shell-policy), independent of typed Settings permissions. It supports bounded deferred `reboot`, `web stop`, exact `web certificate rotate --force` and owner-relative `exit`. Drain (up to ten seconds plus 200 ms) is best-effort application-buffer acknowledgement, not peer receipt or an execution deadline; pending input is discarded. Certificate work runs on the existing dispatcher through the shared service-before-identity reservation, commits before stop/restart and never rolls back a committed identity after lifecycle failure. Failed stop retains ownership and skips start. Verify changed trust through UART0 `web certificate info`, recover with UART0/admin SSH `web stop` / `web start`, then sign in freshly. HTTPS-only actions leave SSH/native USB/UART0 independent; reboot affects every transport and loses unsaved RAM.
Browser `web` allows only status/stop/exact forced certificate rotation; `wifi`/`mdns` allow status only. Browser `user` allows status/list/show and interactive add/password plus forced role/delete for **other accounts**, not self/generated/key/recovery commands. Restricted SSH stop/disconnect/reset/host-key mutation remains unavailable in the browser shell. Typed Accounts/Network/SSH settings separately provide their documented bounded workflows; this is not shell parity. See [web administration](web_administration.md) for lifecycle/API ownership and uncertainty.
## System
@@ -8,19 +12,17 @@ UART0 and authenticated `admin` SSH sessions use the same registered command imp
|---|---|
| `memory` | Show free memory, minimum free memory, and largest blocks for internal RAM, DMA-capable RAM, and PSRAM. |
| `reboot` | Drain console output briefly and restart the ESP32. |
| `exit` | Close the current administrative SSH session after its acknowledgement drains; unavailable on UART0. Ctrl+D on an empty admin SSH command line does the same. |
| `exit` | Close the current administrative SSH or browser session after its acknowledgement drains; unavailable on UART0. Browser `exit` leaves serial connected. Ctrl+D on an empty administrative command line does the same. |
## Role-based users
| Command | Description |
|---|---|
| `user status` / `user list` | Show database generation, capacity, administrator/bootstrap state, and all secret-free account summaries. |
| `user status` / `user list` | Show database generation, capacity, administrator count, and all secret-free account summaries. |
| `user show <username>` | Show one account's role, ID, authentication generation, and SSH-key fingerprints. |
| `user bootstrap` | Set and confirm the `admin` password without echo, then promote the migrated account to `admin`. |
| `user bootstrap --generate` | Bootstrap `admin` with a generated 24-character password displayed once. |
| `user add <username> <user|admin>` | Create an account using a bounded no-echo password and confirmation prompt. |
| `user add <username> <user|admin> --generate` | Create an account with a generated password displayed once. |
| `user delete <username> --force` | Delete an account; the pre-bootstrap migrated `admin` and final administrator are protected. |
| `user delete <username> --force` | Delete an account; the final administrator is protected. |
| `user role <username> <user|admin> --force` | Change a role; the final administrator cannot be demoted. |
| `user password <username>` | Set and confirm a new password without echo. |
| `user password <username> --generate` | Replace a password with a generated value displayed once. |
@@ -28,13 +30,13 @@ UART0 and authenticated `admin` SSH sessions use the same registered command imp
| `user key add <username> <type> <base64>` | Import a key non-interactively; intended for authenticated admin SSH and also accepted on UART0. |
| `user key delete <username> <0..2> --force` | Delete one key by the index shown by `user show`. |
| `user key clear <username> --force` | Delete all public keys for an account. |
| `user recover --force` | When normal user-database initialization failed, explicitly replace its blob from the current legacy network credential. |
| `user recover --force` | When normal user-database initialization failed, explicitly replace only its blob with an empty database; UART0-only, refuses a healthy database. |
Usernames must match `[a-z][a-z0-9_-]{0,15}`. Passwords contain 1264 printable ASCII characters. The fixed database supports eight users and three SSH keys per user; initial key types are `ssh-ed25519` and `ecdsa-sha2-nistp256`. A key may be assigned to multiple accounts but cannot be duplicated within one account. Password verifiers, salts, raw key blobs, and passwords are absent from ordinary status output. `Ctrl-C` cancels a password or key prompt, and generated passwords are shown once.
On the first Phase 8A boot, the old shared `admin` credential is imported as a role-`user` account, not silently granted administrator rights. Run `user bootstrap` from physical UART0 to establish the administrator. Phase 8B now authenticates HTTPS and SSH passwords through this database and enables stored SSH public keys. Before bootstrap, `web credentials rotate --force` and `web reset --force` synchronize the migrated verifier; after bootstrap, that legacy credential is recovery-only and does not authenticate or alter role-based users.
Missing `user_db/database` storage is committed empty. On UART0 run `user add <username> admin`, optionally with `--generate`, to create the first administrator. There is no bootstrap command, imported shared credential, or synchronization with HTTPS material. Existing valid v1 user databases load unchanged, including previously migrated role-`user` accounts; no account is silently promoted.
`user recover --force` is a destructive physical recovery operation and succeeds only while the database is unavailable. It replaces the user blob with one role-`user` account derived from the current legacy credential; run `user bootstrap` afterward. It does not erase unrelated NVS data. Successful password, role, key, bootstrap, and delete operations invalidate only that username's outstanding WebSocket tickets and active WebSocket/SSH sessions; unrelated users remain connected.
`user recover --force` is destructive and succeeds only while the database is unavailable. It rebuilds only the user blob empty, importing no credentials; then create an administrator with normal `user add` on UART0. It refuses a healthy initialized database, including a healthy empty one, and does not erase unrelated NVS data. Successful password, role, key and delete operations invalidate only that username's outstanding tickets and active web/SSH sessions; unrelated users remain connected.
## Local display
@@ -44,10 +46,12 @@ On the first Phase 8A boot, the old shared `admin` credential is imported as a r
| `display set dim-seconds <0..86400>` | Set the RAM inactivity delay before contrast drops to `1`; `0` disables dimming. |
| `display set off-seconds <0..86400>` | Set the RAM inactivity delay before the OLED switches off; `0` disables automatic off. |
| `display save` / `display load` | Save the working aging settings to NVS or load them. |
| `display defaults` / `display reset` | Apply 300/600-second defaults in RAM, or apply and persist them. |
| `display defaults` / `display reset` | Apply 300/600-second defaults in RAM, or save defaults first and then apply them. |
When both transitions are enabled, `off-seconds` must be greater than `dim-seconds`. Applying settings counts as local UI activity. At normal boot, an initialized OLED shows a bounded five-second identity animation before the status UI begins; it scrolls the device name in yellow and draws the compact upright-terminal logo in blue. A missing OLED remains nonfatal; after reconnecting it safely, one new button press requests a bounded reprobe and is consumed without navigating.
Display settings require an available local UI task, not an attached panel. CLI and browser **Settings → Display** share the public configuration owner; concurrent mutations can report busy, and browser operations reject an intervening configuration edit rather than overwrite it. Save persists working RAM, not browser drafts. Load selects defaults when saved storage is absent/incompatible without rewriting NVS. Reset storage failure leaves RAM unchanged (commit-before-publication, no RAM rollback). Browser timeout/navigation does not cancel already-admitted work; Check Result and Refresh before retrying. [Display settings contract](web_administration.md#serial-and-display).
## Serial service
| Command | Description |
@@ -66,7 +70,7 @@ Defaults are 115200 baud, 8 data bits, no parity, one stop bit, no flow control,
| Command | Description |
|---|---|
| `broker status` / `broker clients` | Show broker state or connected clients. |
| `broker counters` / `broker clear-counters` | Show or clear broker counters. |
| `broker counters` / `broker clear-counters` | Show global totals plus active-client ID/type/pending/HWM/UART/queued/read/dropped; clear counters and seed HWM from current pending bytes without draining output. |
| `broker connect <name>` / `broker disconnect <client-id>` | Create or remove a console test client. |
| `broker request-writer <client-id>` / `broker release-writer <client-id>` | Request or relinquish the single writer lease. |
| `broker force-writer <client-id|none>` | Administratively assign or clear the writer lease. |
@@ -74,7 +78,7 @@ Defaults are 115200 baud, 8 data bits, no parity, one stop bit, no flow control,
| `broker read <client-id> [maximum-bytes]` | Read queued serial output for a client. |
| `broker events <client-id>` | Show ownership and connection events for a client. |
Each client has a generation-safe ID. There can be one writer and multiple observers; a slow observer loses only its own queued output.
Each client has a generation-safe ID. There can be one writer and multiple observers; a slow observer loses only its own queued output. HWM is bounded by 4,096 bytes; read counts transport handoff, not peer receipt. Disconnected client rows disappear, while global totals retain traffic and unread-output discards until cleared. Capture counters before disconnect. `broker read` consumes queued data and must not be used as a throughput diagnostic probe. See [Web throughput diagnostics](web_throughput_diagnostics.md).
## Native USB CDC-ACM
@@ -126,12 +130,14 @@ When the Wi-Fi station receives an IPv4 address, the Wi-Fi manager announces `sa
| `web` / `web help` | Show web-service command usage. |
| `web status` | Show HTTPS and WebSocket state. |
| `web start` / `web stop` | Start or stop HTTPS service. |
| `web counters` / `web clear-counters` | Show or clear web counters. |
| `web credentials show` | Display the legacy migration/recovery credential on UART0 or authenticated admin SSH; it is not a role-based network login. |
| `web credentials rotate --force` | Replace the legacy recovery credential and synchronize the migrated pre-bootstrap account only. |
| `web counters` / `web clear-counters` | Show or clear ordinary HTTPS/serial WebSocket counters, independently of performance capture. |
| `web diagnostics enable\|disable\|show\|clear` | Independent opt-in admission tracing and post-TLS occupancy; not serial throughput timing. |
| `web performance enable\|disable\|show\|clear` | Default-disabled per-slot binary TX timing/count aggregates. Disable freezes; enable resumes; clear preserves enabled state. Each control operation fences in-flight samples with a new epoch. All actions print a snapshot. |
| `web certificate info` | Display certificate identity and fingerprint. |
| `web certificate rotate --force` | Replace the HTTPS certificate and private key. |
| `web reset --force` | Explicitly replace missing, incompatible, or damaged legacy credentials and web material. |
| `web certificate rotate --force` | Replace the HTTPS certificate and private key. Browser admin defers commit and HTTPS restart; both browser routes close and new certificate trust/relogin is required. UART0/admin SSH behavior is unchanged. |
| `web reset --force` | Explicitly replace HTTPS certificate/private key only, including missing, incompatible or damaged material; never changes users. |
Use UART0 for quiet throughput captures (authenticated admin SSH also exposes these commands). Performance output includes current-epoch pending age and count/sum/estimated-average/max timings for queue entry to callback entry, synchronous send, and completion to drain return. These are not peer acknowledgements or scheduler-only measurements; nonempty intervals may include idle. No secrets or serial payloads are printed. See [Web throughput diagnostics](web_throughput_diagnostics.md) for exact semantics, saturation limits and the reset/burst/drain/disable/capture-before-disconnect recipe.
HTTPS listens on port 443 only. Authenticate with any current user-database username/password; both `user` and `admin` roles receive the existing status and browser-terminal interface. The device serves vendored xterm.js without Internet access. Browser sessions use one-time account-bound tickets, binary WebSocket frames, and the broker's one-writer rule. The combined **Connect**/**Disconnect** control closes the current WebSocket and pauses automatic reconnect when active; after a user-paused disconnect, it changes to **Connect** to resume connection attempts. Account mutations revoke only that account's tickets and sessions.
@@ -155,7 +161,7 @@ UART0 and admin SSH submit to one bounded queue, and one dispatcher task is the
UART0 and admin SSH use shared whole-line Tab completion. A unique/common prefix expands inline; a Tab that cannot extend an ambiguous prefix prints the matching candidates and redraws the unchanged input line instead of cycling candidates. Admin SSH additionally supports four-entry per-session command history with Up/Down, inline cursor editing with Left/Right, Home/End (including Pos1/Ende terminal sequences), Backspace/Delete, Ctrl-C, and visible or no-echo interactive prompts. Its history is RAM-only, private to the session, and wiped on disconnect. Ping callbacks enqueue bounded typed results so all formatting remains on the dispatcher task.
`exit`, `reboot`, `ssh stop`, session disconnect, and SSH host-key reset/rotation use bounded deferred control. The firmware waits on a best-effort basis for the administration output ring and transport TX buffer to drain before acting; this is not confirmation that the peer received the acknowledgement. The shell stops accepting another command while such an action is pending. SSH host-key replacement or service stop closes all SSH sessions; reconnect and verify the new fingerprint where applicable. Web recovery credentials/certificates, Wi-Fi secrets, and interactive user passwords/keys are available to authenticated administrators and must therefore be treated as remotely accessible administrative material. `user bootstrap` and `user recover --force` remain UART0-only. A connected administrator also cannot generate its own replacement password remotely, preventing the one-time password from being lost during self-revocation. SSH does not provide `exec`, SFTP, SCP, forwarding, or subsystems.
`exit`, `reboot`, `ssh stop`, session disconnect, and SSH host-key reset/rotation use bounded deferred control. The firmware waits on a best-effort basis for the administration output ring and transport TX buffer to drain before acting; this is not confirmation that the peer received the acknowledgement. The shell stops accepting another command while such an action is pending. SSH host-key replacement or service stop closes all SSH sessions; reconnect and verify the new fingerprint where applicable. TLS certificate management, Wi-Fi secrets, and interactive user passwords/keys are available to authenticated administrators and must therefore be treated as remotely accessible administrative material. `user recover --force` remains UART0-only; `user bootstrap` and all `web credentials` commands are removed. A connected administrator also cannot generate its own replacement password remotely, preventing the one-time password from being lost during self-revocation. SSH does not provide `exec`, SFTP, SCP, forwarding, or subsystems.
## Hardware diagnostics
@@ -182,3 +188,5 @@ UART0 and admin SSH use shared whole-line Tab completion. A unique/common prefix
| `debug buttons test [seconds]` | Run the bounded button event test for 130 seconds; the default is 10 seconds. |
Follow the exact wiring in [Electrical tests](electrical_tests.md) before invoking diagnostics. The OLED must be powered from 3.3 V because module I²C pull-ups may connect to `VCC`; verify that all external pull-ups also terminate at 3.3 V. Display diagnostics probe the standard SSD1315-compatible 7-bit `0x3c`/`0x3d` addresses. The currently tested module acknowledges at `0x3c`, whose 8-bit write/read forms are `0x78`/`0x79`; an explicit `scan --force` is available only for the dedicated local-UI bus. Diagnostics initially run at 100 kHz and treat an absent display as nonfatal. RS-232 diagnostics that require UART1 refuse to use it until `serial stop` releases it. The RGB LED shows test state: blue idle, yellow/orange running, green passed, red failed.
HTTPS storage migration preserves the exact TLS identity and commits TLS-only v2 before publication. Older v1-only firmware cannot read v2. Logical NVS replacement is not secure flash erasure; no factory erase is required. See [legacy removal](legacy_credential_removal.md).
+65
View File
@@ -0,0 +1,65 @@
# Bounded ordinary HTTPS idle retention
Current owner/SDK contract. The user accepted idle cleanup as working; [overall acceptance](web_administration_acceptance.md) records current status and evidence limits. No individual unreported soak, fault or reserve result is inferred.
## Evidence and scope
User-authorized response to the admission capture: post-TLS occupancy **6/6, ordinary4 / serial2 / admin0**, ordinary connection ages **5074 seconds**; admin ticket returned in **14 ms**, no subsequent TLS/upgrade observed, ticket unconsumed. These ages measure connection lifetime, not time since the last request. They support investigating retained ordinary sockets, not claiming every captured socket was idle. Installed HTTPD excludes its listening fd from `select` while full with LRU disabled. New connections can therefore wait before TLS or upgrade even when ticket issuance was fast.
This policy releases **expired idle ordinary HTTPS connections**, not arbitrary ordinary requests to make room. The policy uses the existing six sockets, LRU-disabled operation, one-second receive/send and five-second TLS-handshake timeouts without expanding task stacks or queue capacities. Current overall handler capacity is 39. No new task. UART0 recovery, independent USB access, serial broker ownership, tickets, authentication and both WebSocket transports are unchanged.
## Usage and timeout semantics
- Automatically enabled for every successfully started HTTPS server, including when diagnostic tracing is disabled and either optional transport is unavailable. No new console command, setting or credential migration.
- **15 seconds of observed ordinary idle retention**, checked by a **one-second ESP timer** that requests work on HTTPD. The three existing five-second browser status-poll intervals leave room for normal keepalive reuse instead of a TLS handshake per request.
- A new post-TLS connection that has sent no request gets the same full idle window. Time spent establishing TLS does not consume it.
- The first owner sweep observing a new connection or a changed successful-request completion marker starts a fresh window. The marker covers every ordinary route, including login, assets, status, typed settings, tickets and keepalive errors that return successfully—not just diagnostic wrappers. Failed requests are deleted by the normal SDK path instead.
- HTTPD pending bytes, TLS pending bytes or a readable TCP fd reset the observation window. Negative TLS pending results and `select` errors conservatively reset it too. No bytes are read or discarded by the probe.
- At the threshold, after current SDK classification and zero-time readiness checks, HTTPD calls `shutdown(current_fd, SHUT_RDWR)`. A successful shutdown is latched; a failed call retries on the next probe. The SDK's subsequent read/delete path owns socket close, TLS destruction, diagnostic close notification and freeing the slot. The probe never calls `close`, overrides TLS cleanup, or queues a session-close pointer.
- **WebSockets (serial and admin), closing WebSockets and async requests are exempt.** The check uses actual SDK flags, not diagnostic metadata. Successful explicit 101/classification and request cleanup finish before the owner can sweep, so there is no ordinary-idle interval during upgrade admission.
- Under an available owner and timely successful work delivery, expect 15 seconds plus the initial observation delay (nominally up to one second), the next probe delay (nominally up to one second), and SDK read/cleanup latency. This is a conservative sampled idle policy, **not a strict wall-clock timeout or admission SLA**.
### Deliberate limitations
1. Parsing an incomplete request, synchronous response sends, leftover-body purge and TLS handshakes serialize on HTTPD. The probe cannot interrupt them. Existing per-read/per-send timeouts remain; a peer trickling input can extend overall processing beyond one timeout. This change does not provide a slowloris deadline or solve TLS/memory/global-socket pressure.
2. Four continuously active ordinary connections plus two WebSockets can still fill all six slots. They are not evicted. Likewise, an admission attempt immediately after fresh saturation can hit the browser's existing 15-second timeout before the conservative window expires; the user may still need to retry. Older idle retained sockets are eligible on the next delivered probe.
3. As with any HTTP keepalive timeout, bytes arriving **after** the last readiness check can race a shutdown. Already executing HTTPD requests/responses are protected; future client intent cannot be predicted. A client may need a new connection. No new application-level mutation retry/replay is added. Response completion here means synchronous HTTPD completion, not proof of peer receipt/TCP acknowledgement.
4. `httpd_queue_work` with `CONFIG_HTTPD_QUEUE_WORK_BLOCKING` disabled uses loopback UDP. A reported queue error releases the reservation and retries next tick. **A successful send is not an execution acknowledgement**: an accepted-but-lost control message leaves one reservation pending, disables further probes, and requires successful HTTPS stop/restart to restore probing. There is no speculative reservation timeout: it could accumulate delayed callbacks and violate the one-probe bound. This is explicitly regression-tested, not hidden behind a hard retention guarantee. HTTPD blockage or queue loss also cannot be repaired by raising sockets/LRU/timeouts here.
5. A failed stop leaves cleanup detached and ownership retained for a later stop retry; it does not restart probes on a partially stopped server. Timer allocation/start failure gates HTTPS start with its error rather than silently starting without the policy. UART0/USB recovery is unaffected.
## Exact installed SDK audit
All SDK references below are under `~/.platformio/packages/framework-espidf/components/`; installed framework is ESP-IDF **5.5.0** (`3.50500.0`). Production private access remains solely in `src/web_httpd_adapter.c`, with the existing compile-time version guard extended to require idle-lifecycle re-audit.
- `esp_http_server/src/httpd_main.c`: `httpd_server` selects the listener only with capacity or LRU enabled. Control work runs **before** current data sessions, then accept. `httpd_process_session` skips async sessions and synchronously runs `httpd_sess_process`; errors delete the session before any subsequent owner work. Accept invokes TLS synchronously through `open_fn`.
- `esp_http_server/src/httpd_sess.c`: `httpd_sess_process` calls `httpd_req_new`, then `httpd_req_delete`, and only after both return success assigns `session->lru_counter = ++hd->lru_counter`. This happens even with LRU disabled. New sessions zero the slot, including the marker; the global counter resets when all sessions are deleted. Application calls to `httpd_sess_update_lru_counter` are confined to verified serial-WebSocket send work (`web_serial_transport.c`), which the sweep exempts. This marker is not a timestamp and not an fd-generation token.
- `esp_http_server/src/httpd_parse.c`: `httpd_req_new` synchronously parses and invokes the URI handler. `httpd_req_delete` drains any remaining body; cleanup clears `hd_req_aux.sd` and request pointers. A return from a diagnostic handler wrapper or a response-send call is earlier than this boundary. The sweep requires HTTPD's thread identity and no current `hd_req_aux.sd`, and skips `for_async_req`. Current ordinary handlers do not use async requests, out-of-owner sends, or unfinished chunked responses. Re-audit that contract if introduced.
- `esp_http_server/include/esp_http_server.h` has no global synchronous post-request-cleanup hook. Its event notifications are not such a hook: `esp_http_server_dispatch_event` posts to the event loop. `HTTP_SERVER_EVENT_SENT_DATA` is emitted by `httpd_resp_send` and per `httpd_resp_send_chunk`, **before** handler return/body purge/cleanup. Send/receive overrides belong to HTTPS and do not expose a safe completion hook. URI matching and error handlers likewise cannot supply an all-route post-cleanup boundary.
- `esp_https_server/src/https_server.c`: successful `httpd_ssl_open` stores the transport context and installs TLS send/recv/pending functions before synchronous `HTTPD_SSL_USER_CB_SESS_CREATE`. The application callback invalidates any old row for that fd even if the TLS pointer, socket-slot address and counter value were reused. If fd lookup unexpectedly fails, all observations reset conservatively. The close callback remains the diagnostic observer; SDK destruction remains intact. `httpd_ssl_pending` calls `esp_tls_get_bytes_avail` without consuming data; errors can post an existing SDK error event.
- `httpd_sess_trigger_close` resolves fd to a raw reusable `sock_db *`, then queues `httpd_sess_close`. Its zero-counter/LRU guard does not prove the same connection still occupies that slot. **Not used by this policy.** Direct owner shutdown retains the slot until SDK read cleanup and has no deferred fd/pointer argument that could later close a replacement.
- `httpd_queue_work` uses `cs_send_to_ctrl_sock` / `sendto` in `esp_http_server/src/util/ctrl_sock.c`. The actual generated config leaves `CONFIG_HTTPD_QUEUE_WORK_BLOCKING` undefined/off, selecting the nonblocking queue mode. The idle initializer explicitly rejects builds with that blocking option on. Successful `httpd_stop` waits for `THREAD_STOPPED`, frees HTTPD and ends possible old callback execution; failed stop is not a retirement boundary.
## Ownership and bounded storage
`src/web_httpd_idle.{c,h}` owns one persistent ESP timer, six static observation rows, lifecycle gate/generation and queued/submitting flags. It uses no request data, secret, dynamic per-connection allocation, payload buffer, new task or additional socket. Timer callback performs only short metadata locking and at most one queue submission; all private session access, readiness and shutdown run on HTTPD.
`src/web_server.c` serializes lifecycle. Prepare initializes rows **before** SSL startup; the TLS callback and sweep thereafter share the same owner. Attach publishes a nonzero, nonwrapping `uintptr_t` server generation, passed by value as opaque work argument—not a mutable shared descriptor or raw fd. At most one queued/executing probe is reserved; a separate submitting flag remains set until `httpd_queue_work` returns even if work already finished. That closes the callback-before-submit-return race.
Detach first prevents submissions, then waits at most one second for any submitting call to return. Fence timeout forbids SSL destruction, retaining the handle for retry. An already executing sweep may finish safely while stop waits for HTTPD. Only successful SDK stop retires a discarded queued reservation; restart gets a new generation even if the server handle is reused. A stale generation cannot sweep or clear a newer reservation. No counter wrap or generic off-owner session-list query is accepted.
`src/web_httpd_adapter.{c,h}` defines the six-row bound and 15-second policy and performs the version-pinned owner sweep. `src/CMakeLists.txt` adds only the new module. Diagnostics remains unchanged internally; server composes idle identity reset followed by existing diagnostic publication.
## Regression and resource limits
`tests/web_httpd_idle/run.py` exercises production lifecycle/sweep paths, installed SDK request cleanup, host socketpair/readiness/shutdown/fd reuse and deterministic TLS/timer/queue doubles. `tests/web_admin_transport/server_lifecycle.py` checks server composition. Historical host validation passed; these references do not claim a new run or real target scheduler/TLS timing.
Bounded storage: six observation rows (144 bytes), one persistent timer (installed non-profiled layout 32 bytes before allocator overhead), lifecycle/generation flags and one reserved probe. SDK control UDP/mailbox allocation is transient and separate. No per-connection payload/task/stack/socket growth. Actual HTTPD/timer margins and runtime overhead/reserve floors require target measurement, not host sizes. See [latest firmware resources](web_administration_acceptance.md#latest-firmware-evidence).
## Regression procedure — not execution evidence
1. Start with diagnostic capture disabled: establish two serial WebSockets and ordinary HTTPS fetches. Verify idle ordinary sockets disappear after the observation window while both serial clients/lease remain unchanged. Enable capture only as needed to compare close/open occupancy; ages remain connection ages, not idle timestamps.
2. Reproduce the original ordinary4/serial2 full-slot case; wait beyond the idle window, then issue/open admin. Record client ticket/TLS/upgrade timings and occupancy without recording tickets/cookies. Verify no repeat reload loop is needed for already-old idle saturation. Separately test fresh saturation and acknowledge the existing 15-second browser timeout limit.
3. Leave status polling active for several minutes: no five-second TLS reconnect churn; both WebSockets and binary serial data/broker isolation survive. Repeat Settings/account-key operations, large assets, login/logout and two browser contexts. Compare with polling paused to distinguish genuinely idle slots.
4. Slow incomplete headers/bodies, pipelined requests, slow response readers and slow/failed TLS handshakes: no probe-driven close of an executing response or admitted WebSocket. Record owner delays; do not infer an overall request deadline from the unchanged one-second receive timeout.
5. Repeated close/reopen/fd reuse, full-mix stop/start and certificate rotation via supported UART0/SSH/browser lifecycle paths. Confirm UART0 and USB remain available, queued work never affects replacement connections, failed-stop retries retain ownership, and no start allocates a second server.
6. Capture settled/loaded/post-cleanup internal/DMA/PSRAM values and HTTPD/ESP-timer minimum-free stack. Soak at the accepted full client mix. Investigate control queue loss separately if probing appears stuck; successful stop/start is the safe recovery, not an eviction/capacity increase.
+29
View File
@@ -0,0 +1,29 @@
# Legacy credential removal and storage compatibility
Current storage/recovery contract. The user accepted cleanup on 2026-09-08, confirming the certificate fingerprint was unchanged and preexisting test users remained usable. [Overall acceptance and evidence limits](web_administration_acceptance.md) supersede the old per-slice handoff; no unreported provisioning, recovery, power-loss or all-key tests are implied.
## User database
- `user_database_init(load_result)` takes no legacy credential. Missing `user_db/database` storage is committed as an empty v1 database; no account/password is imported.
- Create the first administrator on physical UART0 with `user add <username> admin`, optionally `--generate`. Entered passwords use hidden confirmation; generated passwords are shown once. There is no reserved bootstrap account or public bootstrap state.
- `user recover --force` is UART0-only, calls `user_database_recover_empty()`, and destructively rebuilds only an unavailable database empty. It refuses a healthy initialized database, including an empty one. Follow with normal `user add`; unrelated configuration and TLS/SSH identities are untouched.
- Valid existing v1 database bytes load without rewriting or changing accounts, roles, IDs, authentication generations, verifiers or authorized keys. Previously migrated role-`user` accounts are not automatically promoted. The private `v1_admin_marker` preserves the old wire byte and is derived from administrator count during mutations; it is not a new role, public bootstrap field or schema change.
- No public bootstrap, legacy user migration or synchronization API remains. Final-admin protection, conditional mutations, copied principal currentness and target-only revocation remain the canonical account contracts.
## HTTPS identity storage
`web_security` owns only TLS material. At the unchanged `web_sec/material` key, a private byte-oriented reader validates the shipped **1,392-byte v1** layout and builds **1,340-byte TLS-only v2**. It retains the **exact private-key DER, certificate DER, SHA-256 fingerprint and material generation**: this migration is not certificate rotation.
The v2 candidate is validated and committed before live publication. Temporary credential-bearing v1 input is wiped. No public legacy credential reader/type, display, rotation or authentication path remains. Unknown/malformed material, read failures, invalid cryptographic identity and migration write/commit failures fail closed; they do not trigger fallback regeneration or overwrite of rejected material. Truly missing material may be generated and saved normally.
`web credentials show`, `web credentials rotate --force` and `user bootstrap` (including its generated form) are removed. `web certificate info` and `web certificate rotate --force` remain subject to existing frontend policy. `web reset --force` explicitly replaces TLS certificate/private key only; it does not reset passwords, import accounts or synchronize verifiers. TLS replacement/restart can close browser sessions and requires renewed certificate trust/login, without changing user credentials or revoking unrelated SSH sessions.
## Compatibility and physical-security limits
**Older v1-only firmware cannot read v2 HTTPS storage.** Do not assume a downgrade preserves usable HTTPS or restores removed credentials. Existing user database v1 compatibility is separate from this HTTPS downgrade incompatibility.
Replacing an NVS blob logically removes credential fields from the current record; it is **not secure flash wiping**. Append-oriented historical copies may retain plaintext legacy credentials, and current Wi-Fi secrets/TLS/SSH private keys remain unencrypted. PBKDF2 verifiers remain subject to offline guessing. No factory/partition erase is required or authorized by this cleanup.
## Regression references
Use `tests/web_security/run.py`, `tests/admin_console_boundary/accounts.py`, `tests/admin_ssh_policy/run.py` and [current legacy-removal procedures](user_administration_tests.md#current-legacy-removal-regression-procedure). Prior host tests used NVS fault doubles and a generated identity wrapped in the historical TLS layout, not an independently captured old-device fixture; they do not prove flash durability or power-loss behavior. Procedures are not claims of execution.
+18 -22
View File
@@ -169,47 +169,43 @@ Implemented and target-hardware validated:
- Missing, disconnected, or unresponsive display hardware remains nonfatal and can recover through one bounded reprobe.
- Concurrent serial, UART0, USB, WebSocket, SSH, and injected display/button fault behavior validated. See [Electrical tests](electrical_tests.md) and [Hardware wiring](wiring.md).
## Current and planned phases
### Phase 8 — Role-based users and administrative access — Complete
Phase 8A through 8C are complete and target-hardware validated; integrated web administration in 8D remains planned. Later work remains planned or under evaluation. Optional features must not weaken the completed serial and recovery paths.
Implemented and accepted: a bounded, persistent user system replaces the single shared network credential. Both roles can use the authenticated web serial/status interface. Over SSH, `user` routes to the broker-backed serial stream while `admin` routes exclusively to the administration shell and receives no broker client or writer lease. UART0 remains the physical recovery and bootstrap authority.
### Phase 8 — Role-based users and administrative access
Replace the single shared network credential with a bounded, persistent user system. Both roles can use the authenticated web serial/status interface. Over SSH, `user` routes to the broker-backed serial stream while `admin` routes exclusively to the administration shell and receives no broker client or writer lease. UART0 remains the physical recovery and bootstrap authority.
Implementation sequence:
Completed implementation (8A8C target-hardware validated; 8D explicitly accepted at 8D.22 on 2026-09-13):
1. **Phase 8A — User database and UART0 administration — Complete**
- Versioned NVS database for up to eight `user`/`admin` accounts, random account IDs and authentication generations, final-admin protection, and three Ed25519/P-256 keys per account.
- Salted PBKDF2-HMAC-SHA256 password verifiers, bounded no-echo entry, one-time generated passwords, and secret-free account/key status.
- Transactional migration of the legacy credential to an unprivileged account, explicit UART0 bootstrap, and UART0-only recovery that replaces only the unavailable user blob.
- Historical migration/bootstrap supported the initial cutover. Current firmware instead commits missing storage empty, provisions the first admin with normal UART0 `user add`, and provides UART0-only unavailable-database recovery; legacy credential/bootstrap commands are removed.
- Migration, bootstrap, CRUD, persistence, and command basics target-hardware validated; the full fault-injection matrix remains regression coverage. NVS remains unencrypted and offline guessing remains possible.
2. **Phase 8B — Role-aware HTTPS and SSH authentication — Complete**
- HTTPS Basic plus SSH password/public-key authentication use the common database and copied secret-free principals; unavailable user storage fails closed.
- HTTPS and SSH authenticate through the common database and copied secret-free principals; unavailable user storage fails closed. The initial HTTPS Basic path was replaced by cookie authentication in 8D.
- One-time principal-bound WebSocket tickets and ongoing principal-currentness checks prevent stale admission or input.
- Account mutations request targeted WebSocket/SSH revocation; authentication generations provide fail-safe invalidation while unrelated accounts remain connected.
- Legacy credentials remain migration/recovery-only after bootstrap. Password/key login, ticket behavior, targeted revocation, recovery, and concurrent transport operation are target-hardware validated.
- Password/key login, ticket behavior, targeted revocation, recovery, and concurrent transport operation are target-hardware validated. Legacy credentials were subsequently removed without changing established TLS identity or valid users.
3. **Phase 8C — SSH administrative shell — Complete**
- Authenticated `admin` SSH shell sessions route to a bounded administration worker and never create a broker client or acquire a serial writer lease. Normal `user` sessions retain the existing broker-backed serial stream.
- UART0 and admin SSH now submit complete lines to one fixed-length request queue. A single dispatcher task is the sole caller of ESP-IDF's non-reentrant `esp_console_run()` and therefore executes the same registered command handlers for both entry routes. The former separately implemented reduced SSH command dispatcher has been removed.
- The worker uses fixed per-session command/input and output buffers. Queue records contain copied secret-free principals and generation-tagged session tokens; late work is discarded after disconnect, slot reuse, role change, password/key mutation, or deletion. Task-local standard streams route canonical handler output into the applicable bounded SSH ring, and only the SSH owner task calls wolfSSH APIs.
- Transport-neutral bounded prompts now support interactive user passwords/keys and Wi-Fi secrets over admin SSH without exposing hidden input or allowing another command while a prompt is active. Ping callbacks enqueue typed bounded events and the dispatcher alone formats their output. Four-entry per-session history and whole-line Tab completion are RAM-only and wiped on disconnect.
- Authenticated administrators receive the operational registry, including recovery-secret display, HTTPS material rotation/reset, reboot, ping, and SSH lifecycle/session/host-key mutation. Self-terminating reboot and SSH actions are deferred until acknowledgement output drains, block further shell input, and execute through existing synchronous owner APIs from a separate bounded control task. Initial `user bootstrap` and explicit `user recover --force` remain physical-UART0 operations; admin SSH also rejects generating a replacement password for its own account.
- Authenticated administrators receive the operational registry, including HTTPS material rotation/reset, reboot, ping, and SSH lifecycle/session/host-key mutation. Self-terminating reboot and SSH actions are deferred until acknowledgement output drains, block further shell input, and execute through existing synchronous owner APIs from a separate bounded control task. First-admin provisioning and explicit `user recover --force` remain physical-UART0 operations (`user bootstrap` was later removed); admin SSH also rejects generating a replacement password for its own account.
- `ssh sessions` and `ssh counters` identify broker versus admin-console routes, worker command state, queued admin output, admission failures, and input backpressure. `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort application-buffer draining. Admin sessions are checked for a current `admin` principal before command execution and during the active-session reconciliation.
- Keep SFTP, SCP, `exec`, forwarding, subsystems, and unauthenticated shells disabled.
- Target-hardware validation passed for route separation, history/Tab editing, interactive visible/hidden prompts, output/backpressure, generated and entered user/password/key management including the longest ECDSA P-256 import, ping event routing, deferred reboot/SSH lifecycle drain behavior, bootstrap/recovery rejection, targeted self/other-user revocation during queued work, UART0/SSH administration serialization, and concurrent USB/WebSocket/user-SSH/admin-SSH operation. Stress at 460800 baud with SSH and WebSocket clients caused substantial expected packet drops and slower display controls, but did not exhaust memory or require lowering the supported baud-rate range.
4. **Phase 8D — Integrated web administration — Planned**
- Begin with integrated authentication: replace browser-facing HTTP Basic authentication with a same-origin HTTPS login page, explicit logout, and bounded opaque server-side sessions. Store only a digest of each random session token with a copied secret-free principal, expiry, CSRF state, and authentication-generation binding. Send the raw token only in a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain`, and an explicit lifetime; never retain passwords, Basic headers, raw tokens, verifiers, or SSH-key blobs in snapshots or logs.
- Make logout invalidate the current server-side session, expire its cookie, close that session's serial and administrative WebSockets, and redirect to login. Password/role/key mutation, deletion, recreation, and explicit revocation invalidate the affected account's web sessions and tickets without disturbing unrelated accounts. Require the CSRF token plus strict same-origin checks for every state-changing request, including logout, and rate-limit login attempts with bounded secret-free accounting.
- Add an admin-only **Serial terminal**/**Admin shell** selector. The administrative route uses a short-lived, single-use, admin-principal-bound ticket and a bounded WebSocket frontend for the same serialized command registry used by UART0 and admin SSH; it is not a generic HTTP command-execution endpoint. Normal users retain the existing serial interface and cannot mint, upgrade, or invoke administrative routes. Remote policy still rejects physical-only `user bootstrap` and `user recover --force`.
- Switching the visible terminal between Serial and Admin changes only the displayed terminal route. It must not disconnect the browser's serial broker client, release its writer lease, or silently stop serial observation. Keep the writer/observer badge plus Request control/Release control visible in both modes so an administrator knows the retained state and cannot unintentionally lose the lease to another client. Explicit Disconnect, logout, revocation, session expiry, or an explicit release/transfer operation still performs normal broker cleanup.
- Add an admin-only Settings area backed by typed, bounded subsystem APIs rather than generated CLI strings. Cover user/password/role/SSH-key management, serial configuration and persistence, Wi-Fi profiles/AP policy/secrets, service and session controls, display settings, network diagnostics, and carefully separated security/danger-zone operations. The admin shell provides full remote operational parity and the settings pages provide guided high-frequency workflows; unusual electrical/debug operations may remain shell-only.
- Add contextual admin quick settings to the existing status cards. Hover, keyboard focus, or click on **Serial** opens a popover with current framing/lifecycle state, safe common edits, apply/save semantics, and a link to full Serial settings. The **Wi-Fi** card similarly exposes connection/profile controls and a link to full Network settings without revealing saved secrets by default. Touch and keyboard users must receive the same functionality as pointer hover, with Escape/outside-click dismissal and no action triggered merely by opening a popover.
- Hover, focus, or click on **Broker clients** opens a live, secret-free client list with IDs, transport, writer/observer state, and bounded queue/drop information. Activating **Active writer** opens an admin-only transfer dialog listing current eligible clients; transfer is an explicit confirmed generation-safe administrative assignment, never a side effect of opening or hovering. Stale/disconnected targets fail visibly without changing the current lease. Normal users may retain ordinary aggregate status but receive neither client-management details nor mutation controls.
- Preserve strict CSP, no-referrer/frame-denial policy, no-store responses for login/session/admin material, secret-safe JSON encoding, one-time generated-password display, and bounded request/response bodies. Keep UART0 recovery, native USB UART1 access, and existing SSH behavior available if web sessions or administration cannot initialize.
- Add secret-free counters/snapshots for session capacity, expiry, login failure/backoff, logout, invalidation, CSRF/origin rejection, admin-console admission/backpressure, typed-API failure, and writer-transfer races. Compatibility HTTP Basic, if retained temporarily during migration, must be explicit, separately constrained, and unable to bypass logout or revocation.
4. **Phase 8D — Integrated web administration — Complete**
- User explicitly signed off tested firmware at **8D.22 on 2026-09-13**, superseding earlier per-slice pending acceptance/review gates. Cookie login/logout replaces Basic; bounded digest-only sessions, same-origin/CSRF checks, principal currentness and targeted revocation protect serial and admin routes.
- Admin-only Serial/Admin selection and Settings preserve connected terminal observation and serial writer ownership. The browser shell shares the serialized UART0/admin-SSH dispatcher, with bounded output, explicit admission and retained frontend restrictions—not unrestricted shell parity.
- Typed Serial, Accounts/password/authorized-key, Network/Wi-Fi/mDNS, Display, Broker and SSH settings; confirmed HTTPS stop/restart/reboot and HTTPS/SSH identity rotation. Canonical owners compare reserved service/identity/target generations, preserve commit/failure semantics and expose no stored secrets. Admitted work is not cancelled by later logout/timeout; uncertain mutations are never automatically replayed.
- Accessible Serial/Wi-Fi/client/writer contextual controls reuse existing controllers. Writer transfer is explicit and generation-safe; one writer, isolated observers, binary transparency, UART0 administrative recovery and network-independent native USB UART1 remain invariants.
- Scope removed by user: **8D.15** dedicated typed network diagnostics (shell diagnostics retained) and the unimplemented **8D.19** ordinary browser-session/native-USB controls (SSH controls retained). Browser identity reset/recovery/export is excluded; canonical recovery remains available.
- Prior final build PASS: **100,556 B linked RAM / 1,828,573 B flash, CPU 160 MHz**. Earlier combined binary WebSocket-send fix was explicitly accepted at **160 MHz / 230400 baud with full client mix including browser admin**. Latest overall sign-off does not invent individual fault/soak/duration results or a latest exact zero-drop comparison.
- Latest loaded internal/DMA minima **2,052 / 460 B** remain a conservative transient-headroom follow-up, not a blocker reopening acceptance or an approved reserve. See [acceptance and telemetry](web_administration_acceptance.md), [current contracts](web_administration.md), [regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) and [legacy compatibility](legacy_credential_removal.md).
Completion requires login/logout and account switching without browser credential-cache dependence; stale-session, CSRF, origin, capacity, expiry, and revocation tests; hidden and server-rejected normal-user administration; shared admin-console serialization and backpressure; terminal switching that demonstrably preserves the browser broker client and writer lease; accessible Serial/Wi-Fi/client/writer popovers; generation-safe explicit writer transfer; typed settings and secret-handling tests; concurrent USB/WebSocket/user-SSH/admin-SSH/browser-admin operation; and continued UART0 recovery.
## Current and planned phases
Phase 8 is complete for its accepted scope. Phase 9 is next **only on a separate user request**; later work remains planned or under evaluation. Optional features must not weaken completed serial and recovery paths. General release gates below remain guidance for future work, not claims that every listed fault, soak or reserve measurement was individually performed for 8D.
### Phase 9 — Security and production hardening
+49 -5
View File
@@ -1,6 +1,42 @@
# User administration and authentication tests
This document retains phase-specific regression procedures. The Phase 8A and 8B sections describe the behavior of those historical implementation baselines; they are not the current end-to-end acceptance behavior. In current Phase 8C firmware, HTTPS and SSH authenticate through the user database, role-`user` SSH sessions receive the broker-backed serial stream, and role-`admin` SSH sessions receive the administration shell. Use the Phase 8C section for current routing and shared-console validation. Never include generated or entered passwords in test logs.
Reusable procedures, **not execution evidence**. [Phase 8 is accepted](web_administration_acceptance.md); overall 8D.22 user sign-off does not assert that every checklist item below ran. Historical 8A/B sections describe their original baselines only; current cookie authentication, removed bootstrap/credentials and UART0-only recovery override them. Use current contracts and the integrated regression section for present behavior. Never log generated/entered passwords, cookies/tickets or private/verifier material.
## Current Broker management regression procedure — 8D.16
The [Broker contract](web_administration.md#broker-and-contextual-controls) defines current API, generations, wrap safety and bounds. Retain these checks for future regressions; acceptance is recorded separately.
- Verify admin-only detailed client rows against non-consuming UART0 snapshots/counters; direct normal-user management GET/POST denied. Observe zero/one/full client mix without reading UART payloads as a probe.
- Opening, selecting, cancelling, refreshing and navigating must leave writer and both terminals unchanged. Explicit full-page Refresh clears selection; contextual refresh retains selected target/version and sticky stale/absence latches until deliberate reselection. Native confirmation must identify exact target and snapshot writer.
- Assign among USB/SSH/two browser serial clients with both admins connected. Disconnect/reuse target while confirmation is open; race writer release/reacquire, competing requests, shell force and local release. Stale confirmation must fail without changing the intervening lease; Refresh/reselect/confirm explicitly.
- Test separate-login result isolation, same-login tab replacement, pending capacity, lost ACK/result, bounded polling/manual recovery, logout/expiry/revocation and HTTPS stop/restart. Never infer cancellation from connection loss or replay automatically.
- At signed-off 160 MHz/230400 baud, check full-mix binary traffic/drop isolation, UART0/USB recovery and optional-route failure isolation. Capture exact revision/mix and internal/DMA/PSRAM plus HTTPD/dispatcher margins; host tests/build do not approve reserves or target throughput for an unmeasured workload.
## Current Network settings regression procedure — 8D.12/8D.13
The [Network contract](web_administration.md#network) defines the current byte codec, owner/persistence semantics and uncertainty. Retain these regression procedures without inferring individual execution from overall sign-off.
- Verify admin-only Settings/Network and direct-route normal-user denial, current cookie/principal, body/query/framing/Origin/CSRF checks, unavailable/contended snapshots and generation races against CLI/local controls.
- Round-trip UTF-8 and arbitrary SSID bytes through text/hex, including NUL/BOM/non-UTF-8 and 32-byte boundaries. No saved PSK or length may appear in responses, status/logs/completion/local display. Keep omits credentials; Replace never accepts blank; disabled-STA Clear (including disable+clear) works; enabled-STA/AP clear is denied even when AP policy is off. Check transient-input expiry and context/session clearing.
- Distinguish RAM Apply, explicit Save, stored-only Wi-Fi Load and reboot persistence. Missing/invalid/failing Wi-Fi storage must not install generated defaults or new AP secrets. Exercise stale generations, queue failure/drop accounting and NVS failures without secret logging. No Wi-Fi reset/default/export action exists.
- Prepare UART0 and USB before confirming disruptive actions. Test Start/Stop (including RAM boot policy), Reconnect/Next, AP policies and stopped no-ops. The selected profile is an edit target, not explicit connection selection; Next uses canonical priority/wrap. Cancel confirmations and exercise lost ACK/401/disconnect, manual Check Result/Refresh, another-tab result replacement and no automatic replay. `accepted` is not online; delivery before disconnection is not guaranteed. Reconnect via STA/AP; UART0 administers recovery, USB preserves independent UART1 access.
- Test mDNS generation/Set/Save/Load/Defaults, live/offline reannouncement, next STA IP, init/live failure isolation and `applied_not_queued`. Verify actual client DNS and changed-hostname browser trust/login, not merely `announced`. Confirm no unintentional Wi-Fi secret reset.
- With USB/two web serial/SSH serial and both admin routes, verify hidden output draining and writer/observer preservation through Settings navigation. Separate actual network-disruption losses from serial/broker regressions. Exercise optional Network route/timer failure and stop/restart without taking down unrelated routes. Browser-shell restrictions remain unchanged.
- Capture boot/full-mix internal/DMA/PSRAM free/minimum/largest blocks and memory floors during TLS/Network operations; timer heap/slot costs, repeated-operation cleanup/soak and **HTTPD/dispatcher stack margins** remain required. Record exact revision/client mix and nonsecret counters, including broker and manager queue drops. Host tests do not establish target reserve or hard scheduling/cancellation guarantees.
## Current legacy-removal regression procedure
The legacy bootstrap/credential/reconciliation instructions in the phase baselines below are **historical only**, superseded by [legacy credential removal](legacy_credential_removal.md) and the current [command reference](command_reference.md). Basic authentication is also historical; current HTTPS uses cookie login. The current overrides apply to later Phase 8C/browser procedures too: no `user bootstrap` or `web credentials` command remains, first-admin creation uses normal UART0 `user add`, and recovery rebuilds empty. Never treat the checklist below as evidence of execution.
1. On a disposable controlled NVS image with only `user_db/database` missing, boot and confirm an empty database is persisted with zero accounts/admins and no imported credential. Reboot and confirm it remains empty. Keep physical UART0 attached; do not factory-erase the device for this test.
2. Run `user add maint admin` on UART0, check hidden password confirmation and cancellation, then confirm account/password persistence after reboot. Separately exercise `user add operator user --generate` and secure one-time display. Final-administrator delete/demotion must still fail. Existing SSH own-password generation restrictions and typed browser generated-password support remain unchanged.
3. Load a valid existing v1 user image and verify accounts, roles, IDs, auth generations, verifiers and keys are unchanged, including a formerly migrated role-`user` account. No bootstrap status should appear and no account should be silently promoted. No web material change may synchronize a verifier.
4. On a disposable malformed user image, confirm authentication fails closed without automatically overwriting storage. On UART0 run `user recover --force`, confirm empty storage, then `user add maint admin`. Recovery must refuse healthy databases, including healthy empty storage, and be unavailable through SSH/browser. Verify serial/Wi-Fi configuration and TLS/SSH identities remain intact. Inject read/write/commit failures where available and check failure isolation and complete committed records, not partial live mutations.
5. Upgrade valid 1,392-byte v1 `web_sec/material`; verify persisted 1,340-byte TLS-only v2 and exact certificate/key DER, fingerprint and generation retention in a controlled fixture without logging private data. Reboot and confirm identity continuity. Malformed/unknown/cryptographically invalid records and migration read/write/commit failures must fail closed without fallback replacement. Do not interpret a failed commit as proof that no flash write occurred.
6. Confirm help/completion/status expose no legacy credential/bootstrap operation or secret. Removed commands must reject without mutation. Exercise retained user generation and `web certificate rotate --force` through supported frontends. `web reset --force` must change TLS only, require new certificate trust/login after HTTPS restart, leave user credentials/generations unchanged, and not revoke unrelated SSH sessions.
7. Recheck UART0/native USB availability and broker one-writer/isolated-observer behavior with network authentication unavailable. Record only nonsecret counters and telemetry. Older v1-only firmware cannot read HTTPS v2; logical NVS replacement is not secure flash erasure and no factory erase is required.
Acceptance and prior evidence limits are recorded in [legacy compatibility](legacy_credential_removal.md) and [overall acceptance](web_administration_acceptance.md); the preceding procedure is not an execution log.
## Historical Phase 8A baseline — role-based database and UART0 administration
@@ -166,9 +202,9 @@ While an administrative command is queued or running, use UART0 to change that a
Finally, issue commands concurrently from UART0 and admin SSH, including `user list`, long `help` output, and one UART0 interactive password or key prompt while an SSH command waits. Confirm the single dispatcher serializes all `esp_console_run()` calls, UART0 retains its line editing/history/completion, prompt input is consumed only from UART0, outputs are not mixed between transports, and there is no stack overflow, corrupted argument parsing, database damage, or broker disruption.
## Planned Phase 8D integrated web administration
## Integrated web administration regression procedure
These are acceptance requirements for the planned implementation, not tests that have passed yet.
Current retained-scope checks for future changes. [8D.22 sign-off](web_administration_acceptance.md) closes the phase; these are **not additional acceptance conditions or claims of individual execution**. Record exact revision, workload/client mix, duration, counters and heap/stack evidence for any new run. Do not treat host doubles as target timing/power-loss proof or reserve approval. Prepare UART0 recovery/native USB before disruptive tests and do not erase persisted data without explicit approval.
### 1. Integrated login and authorization
@@ -178,7 +214,7 @@ Authenticate as both roles through the same-origin login page, explicitly log ou
As an administrator, connect the browser serial terminal, acquire the writer lease, send and observe serial data, then switch repeatedly between **Serial terminal** and **Admin shell**. The visible terminal contents and input route must change, but `broker clients`, the displayed browser client ID, and active writer ID must remain unchanged. Request control/Release control and writer/observer state must stay visible in both modes. While Admin shell is selected, have a normal user request the writer lease and confirm the retained browser lease prevents unintended takeover. Only explicit Release control, confirmed writer transfer, Disconnect, logout, revocation, expiry, or connection failure may release it.
Verify the browser admin shell executes the canonical registry through the single dispatcher, preserves bounded history/completion/prompts and backpressure, and does not itself become a second broker client. Physical-only bootstrap/recovery commands remain rejected. Closing only the admin-console route must leave the browser serial client and its lease intact.
Verify the browser admin shell executes the canonical registry through the single dispatcher, preserves bounded history/completion/prompts and backpressure, and does not itself become a second broker client. First-admin provisioning remains UART0-only, remote recovery is rejected, and the removed bootstrap command is unavailable everywhere. Closing only the admin-console route must leave the browser serial client and its lease intact.
### 3. Quick settings and client popovers
@@ -188,8 +224,16 @@ Open **Broker clients** and confirm its secret-free list matches authoritative b
### 4. Typed settings and destructive operations
Exercise user/password/role/key management, serial settings and persistence, Wi-Fi profiles/AP policy/secrets, service/session controls, display settings, and network diagnostics through typed bounded APIs. Compare resulting subsystem state with the equivalent canonical CLI behavior without routing API requests through command strings. Generated passwords appear once in no-store responses; destructive or self-terminating HTTPS/SSH/reboot/security actions require explicit confirmation and explain the expected connection loss.
Exercise user/password/role/key management, serial settings and persistence, Wi-Fi profiles/AP policy/secrets, retained SSH service/session controls, display settings, HTTPS lifecycle/reboot and HTTPS/SSH rotation through typed bounded APIs. Dedicated typed network diagnostics and ordinary browser-session/native-USB management are excluded; exercise existing shell diagnostics only through permitted frontends. Compare resulting subsystem state with the equivalent canonical CLI behavior without routing API requests through command strings. Generated passwords appear once in no-store responses; destructive or self-terminating HTTPS/SSH/reboot/security actions require explicit confirmation and explain the expected connection loss.
### 5. Concurrency and failure isolation
Run USB, browser serial, browser admin shell, user SSH, admin SSH, UART0, and active UART1 traffic concurrently. Alternate explicit writer transfers while issuing administrative commands and opening/closing popovers. Verify one writer, isolated observers, bounded memory/queues, principal revocation, no mixed admin output, no hidden lease loss during terminal switching, and continued UART0/native-USB recovery if web-session or admin-console initialization fails.
### 6. Service identity, lost acknowledgement and recovery
Confirm public fingerprints and both service/identity versions before HTTPS/SSH rotation. Race canonical CLI changes with open confirmations; stale operations must reject before mutation. For HTTPS, exercise send-return/ID-callback admission, accepted-but-lost work and failed destruction without accumulating callbacks; restart must retain owner reservation while deliberately invalidating logins. For SSH, failed stop must skip mutation/start and retained context must survive until every slot retires. Inspect precommit/postcommit partial effects without assuming an error means no change; never replay automatically. Verify new trust via UART0, fresh HTTPS login after restart, stopped-rotate versus CLI-reset behavior, and continued unrelated-service recovery. Browser identity Reset/recovery/export is not part of the workflow.
### 7. Display, API bounds and optional failures
Exercise Display generation conflicts, explicit RAM/Save/Load/Defaults/Reset, absent panel with available UI task and concurrent button activity. Reset storage failure must leave RAM unchanged. Across all typed domains check body/receive/schema limits, optional registration failures, one-slot/login isolation, queue expiry versus admitted execution, saturation and stale IDs. Secret timers cancel/wipe only non-executing work; logout/deadline cannot recall admitted commits. Compare Serial service discards with preserved broker clients/lease/output, rather than claiming uninterrupted data through explicit reconfiguration.
+128
View File
@@ -0,0 +1,128 @@
# Web administration contracts
Current, accepted firmware behavior. Phase status and executed-evidence limits belong in the [roadmap](roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance record](web_administration_acceptance.md), not in implementation timelines. [Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) describe checks, not results. Source is authoritative; start with the [code map](agent/code-map.md).
## Authentication and admission
- HTTPS-only, same-origin login replaces Basic authentication entirely. Four digest-only opaque sessions have absolute one-hour expiry; four pre-login challenges last 120 seconds. Login is globally bounded to five credential attempts per 60 seconds. Live sessions/challenges/tickets are not evicted to admit another client.
- Raw tokens travel only in the host-only `__Host-` cookie with Secure, HttpOnly, SameSite=Strict, Path=/ and explicit lifetime; no Domain. Session records retain copied secret-free principals, CSRF state and nonzero, nonreused originating-session IDs. Passwords, raw cookies/tickets, verifiers and private keys never enter routine snapshots/logs/completion/display.
- Mutation admission requires current cookie/principal, strict Origin and CSRF validation; administration additionally requires current `admin`. Normal users retain serial/status but cannot invoke administration directly. Authentication POST fetches use CORS mode with fixed same-origin URLs and same-origin credentials: do not accept Origin `null` to compensate for browser no-referrer behavior.
- Session-store initialization is part of admitted HTTPS start; authentication failure gates HTTPS. Failed start/accepted stop disables and wipes session state. Logout invalidates only the originating session before socket cleanup; account mutation invalidates that account's sessions/tickets, including deletion/recreation, without revoking unrelated accounts. Currentness checks remain authoritative if best-effort notifications fail.
- Four serial tickets and two admin tickets are digest-only, single-use, 30-second, session/principal-bound records. Cookie/Origin/ticket/currentness and transport admission precede explicit WebSocket 101. Store RNG/SHA/database calls run outside short spinlocks; IDs/expiry/epochs fence stale publication without nested store/transport locks.
- HTTPD remains bounded to six sockets, two serial WebSockets, one admin WebSocket and 39 method/path handlers; LRU eviction is disabled. Sessions, sockets, tickets and the two shared remote-console slots are separate capacity limits. Optional settings/admin failures preserve unrelated routes where their initialization contract permits; UART0 and native USB remain independent of web readiness.
`web_httpd_adapter` alone accesses private IDF 5.5.0 HTTPD state. It rejects duplicate/ambiguous headers, postpones 101 until admission, and wipes consumed header scratch while preserving right-aligned unread bytes. Optional Settings registration stages descriptor/name allocations before publishing either, avoiding the pinned public registration failure path. Re-audit these private boundaries on SDK upgrades and same-version SDK patches: the version guard does not detect patches that retain the same version number. HTTPD response headers are pointer-backed, not copied; both `Set-Cookie` value buffers must remain valid and distinct through response send. Do not reuse or wipe those buffers before sending completes. Do not enable header/ticket debug logging. Auth documents, scripts and sensitive responses are no-store with CSP/no-referrer/frame-denial protections; authored loader changes require matching CSP hashes. Generated assets are not a normal documentation/build output.
## Terminal and console ownership
Switching Serial/Admin/Settings never creates a second serial client, requests/releases the writer lease or reconnects serial. Hidden connected terminals keep draining; only selected terminal input is sent. Both browser terminals have separate 5,000-line scrollback and 64 KiB callback-accounted pending-output bounds with visible browser-drop counts. Admin input is bounded to 4 KiB admission and 512-byte frames. Admin open/reopen is explicit; admin close is isolated. Disconnect pauses serial reconnect but retains login; logout/expiry/revocation closes affected sockets normally.
The document binds terminal state to its first validated username/role/session-stable CSRF tuple. A different identity requires a clean document before showing retained output. Pagehide fences sockets/work and hides scrollback until same-session revalidation; admin is not automatically reopened. Fit readiness caches only success and uses at most three generation-fenced animation-frame retries per external request.
`admin_ssh_console` is the sole `esp_console_run()` caller for UART0, admin SSH and browser admin. Two fixed remote-console slots are shared across SSH/browser, not two per transport. Queue records carry copied principals and transport-qualified generation tokens. Owner currentness is checked outside console locks before commands and during prompts (250 ms polling plus validation/scheduling latency), then identity is rechecked. This is operation admission, not cancellation or rollback of arbitrary executing handlers. Consumed output and retired prompt/history state are wiped.
HTTPD alone owns browser-admin socket IO and its 1,552-byte PSRAM-only payload. A 20 ms timer queues at most one owner poll, not a new task. Close uses current-owner shutdown, not queued reusable `sock_db *` pointers. Detach fences submitters; queued state retires only after successful HTTPD destruction. Failed destruction retains ownership and prevents unsafe restart/reuse.
### Browser-shell policy
Typed Settings permissions do not expand shell permissions. Parsed canonical arguments, not raw prefixes or completion suggestions, control admission:
- Browser `web` permits only `web status`, `web stop`, and exact `web certificate rotate --force`; certificate info/reset, diagnostics/performance and other web forms are denied.
- Browser `wifi`/`mdns` permit only status. Network mutation belongs to typed Settings or UART0/admin SSH.
- Browser `user` permits status/list/show and interactive add/password plus forced role/delete for **other accounts only**. Self changes, generated passwords, key commands and recovery are denied there; typed Accounts supports the separately bounded self/generated/key workflows.
- Browser SSH stop/disconnect/reset and host-key mutation are denied; typed SSH Settings has its own safe owner path. Do not claim full browser-shell parity.
- Browser `reboot` and owner-relative `exit` are supported. First-admin provisioning uses normal `user add` on UART0; unavailable-database recovery is UART0-only. The legacy `user bootstrap` and web credential commands no longer exist.
Self-affecting shell actions use the existing bounded drain/control path (up to ten seconds plus a short delay), not guaranteed peer delivery. Browser certificate rotation hands a typed action after drain/200 ms to the existing 12 KiB dispatcher, never crypto/NVS on the 4 KiB control stack. Pending input is discarded through execution, and an executing slot remains reserved across self-detach. UART0/admin SSH retain canonical recovery actions.
## Typed settings API and operation lifetime
All routes below are under `/api/settings/`. Each domain has bodyless GET snapshot plus GET/POST operation unless noted. Requests reject queries, malformed/oversized/duplicate/unknown fields and inappropriate bodies; mutation routes require bounded JSON with current admin/Origin/CSRF. GET allows absent Origin but rejects mismatch. Snapshot contention/unavailability is not an empty successful projection. HTTPD performs bounded admission/encoding only; canonical mutations run on the existing four-entry administration dispatcher using **IDs only**, not command strings, credentials, request pointers or socket handles.
| Domain and source | Snapshot / operation suffix | Request / snapshot / result buffer bounds (bytes) |
|---|---|---|
| `web_serial_settings`, serial service | `serial` / `serial-operation` | 256 / 256 / 96 |
| `web_account_settings`, user database | `accounts` / `account-operation` | 768 / 1024 (accounts), 512 (keys) / 96 |
| `web_network_settings`, Wi-Fi + mDNS | `network` / `network-operation` | 768 / 2048 / 128 |
| `web_display_settings`, local status UI | `display` / `display-operation` | 256 / 128 / 96 |
| `web_broker_settings`, session broker | `broker` / `broker-operation` | 256 / 2048 / 96 |
| `web_ssh_settings`, SSH owner/security | `ssh` / `ssh-operation` | 256 / 768 / 96 |
| `web_lifecycle_settings`, HTTPS owner/security | `lifecycle` / `lifecycle-operation` | 256 / 320 / 96 |
Mutation bodies use at most four receive attempts. Each domain retains one original-login-bound pending/result slot, nonreused operation IDs and an executing reservation. Results are replaceable, login-isolated observations, not durable history or idempotency keys. Another tab can replace a completed result. Dispatcher currentness and the 30-second dequeue deadline precede canonical owner admission. **Admitted work may finish after logout, revocation, timeout or navigation.** A failed response, 401 or disappearance of a result proves neither success nor cancellation; inspect current state before any deliberate retry.
Accounts and Network each use a one-second timer to cancel/wipe queued, non-executing secrets after 30 seconds plus scheduling latency. Shared input wipes on dequeue/rejection; executing locals wipe on return. This is not a hard wall-clock erasure/execution guarantee. Other domain deadlines are dequeue checks, not new cancellation timers. UI fences late/session-changed responses, bounds request/result checking to 15 seconds and never automatically replays/restores mutations. Serial/Accounts/Network/Display/Broker use bounded automatic checks where implemented (at most ten one-second GETs); SSH and HTTPS/Reboot require manual Check Result/Refresh.
### Serial and Display
Serial Apply/Defaults change RAM; Save persists working device state, not browser drafts. Load follows canonical defaults/fallback behavior. Reset uses canonical apply/persist/best-effort rollback. Reconfiguration/stop discards serial-service RX/TX and task-local pending data but preserves broker clients, lease and already-fanned output. USB with DTR may restart a stopped service. `/api/status` uses a zero-wait consistent serial snapshot (`running:null` if unavailable).
Display accepts dim/off timeouts 086400 seconds and an expected nonzero configuration generation. All writers, including CLI/legacy Apply, share a zero-wait owner reservation; NVS runs outside critical sections. Save stabilizes selected RAM; Load retains canonical fallback without rewriting NVS; Reset commits defaults **before** RAM publication, leaving RAM unchanged on failure. Buttons/diagnostic holds update activity, not configuration generation. Configuration needs an available UI task, not a physically present panel, and never takes over I2C.
### Accounts and authorized keys
The database owns eight accounts, final-admin protection and conditional target username/account-ID/auth-generation checks inside its mutation lock. Create/password/role/delete and key mutations share canonical commit/invariant logic. HTTPD uses zero-wait secret-free projections, not blocking CLI snapshots. Successful mutations request target-only web/SSH revocation; self mutation can revoke access before result retrieval.
Separate bodyless POST `accounts/generate-password` returns one 24-character value without mutation or retained retrieval. The browser uses a 60-second context-bound saved acknowledgement before separate submission; this is UX, not delivery proof, and JavaScript strings cannot be securely wiped. POST `accounts/keys` returns only slot/type/SHA-256 fingerprints for the selected identity. Key add/delete/clear share the operation slot; public-key import is at most 384 decoded text bytes in the 768-byte request and uses canonical Ed25519/P-256 validation. Three stable key indices may be sparse; never interpret response-array position as an index. No stored key blob, verifier or password is exported.
### Network
Wi-Fi config/runtime is one zero-wait consistent projection; mDNS is a separate projection, not cross-domain atomic authorization. Four stable profiles carry enabled/priority/security/SSID/password-configured metadata. `mixed` means WPA2-or-stronger, not open. `announced` is expected STA announcement, not verified DNS.
SSID wire values are reversible **bytes**, maximum 32: printable ASCII, standard single-character JSON escapes and `\u00HH`, with no raw non-ASCII, non-byte Unicode or surrogates. NUL/non-UTF-8 round-trip. UI text is UTF-8-encoded before byte serialization; exact reversible text or literal hex preserves existing bytes and BOM, with no silent replacement/truncation.
Flat operations select one domain/target:
- `wifi-patch` + Wi-Fi generation: optional boot policy, AP policy/channel/SSID/password/clear; `profile-patch` additionally selects stable profile 03 and optional enabled/priority/security.
- `wifi-save|wifi-load` require Wi-Fi generation. `start|stop|reconnect|next-profile` use canonical connection controls, not explicit selected-profile connection.
- `mdns-set` requires its generation and suffix; `mdns-save|mdns-load|mdns-defaults` require its generation. Suffix is 155 lowercase ASCII letters/digits/hyphens, no leading/trailing hyphen, producing `sak-<suffix>`.
Omitted fields preserve current bytes under the Wi-Fi mutex. Password Keep omits, Replace is 863 printable ASCII bytes, disabled-STA Clear is explicit; replacement and clear cannot coexist. AP clear is always denied, even while off. No saved PSK or length is returned. Wi-Fi compare/merge/whole-candidate validation and required queue admission precede RAM publication. Save stabilizes selected bytes; Load reads stored configuration only and cannot generate fallback secrets. No browser Wi-Fi reset/default-secret/export route exists.
Edits require explicit Save. Disabled-profile-only edits do not restart active radio; enabled-policy/AP changes follow canonical asynchronous restart. Start/Stop change RAM enabled-at-boot; Reconnect/Next are no-ops while stopped. Next follows enabled priority order with wrap; editor selection is not connection selection. mDNS independently owns generation/persistence; the Wi-Fi manager owns radio/reannouncement. mDNS RAM change followed by queue failure is reported without rollback; its Load may choose deterministic MAC-derived defaults.
Network result fields are `id/action/state/error`: `pending`, `accepted`, `ok`, `failed`, `cancelled`, `stale`, `invalid`, `loaded_defaults`, `applied_not_queued`, or login-isolated `idle`. `accepted` means RAM/owner-queue admission, **not** association/DHCP/DNS completion. `ok` denotes explicit Save. Loss can precede response; recover via STA/AP and inspect before retrying. UART0 administers recovery; native USB provides network-independent UART1, not an admin console.
### Broker and contextual controls
Broker management atomically copies eight compact client rows, writer and lease generation under one zero-wait mutex acquisition, without reading UART payloads or consuming events. Conditional assignment compares the selected nonzero target ID and lease generation under the same force-writer lock before any effects. Stale/absent targets conflict; same-current-target assignment is a no-op only after validation. Accepted serial TX is not recalled by transfer.
IDs have three slot bits and 29 generation bits; exhausted client slots retire until reboot rather than wrap. The separate 32-bit lease version saturates at UINT32_MAX, survives counter clear and advances on grant/release/revoke before advisory event delivery. Forced transfer can advance twice; it is not a count. Saturation blocks typed assignment but leaves ordinary request/release/disconnect and canonical recovery force available. Reboot invalidates old browser sessions.
Serial/Wi-Fi/Broker quick views reuse one nonmodal settings host and existing controllers/drafts, not parallel editors. Hover/focus/click/tap never mutate. Network quick mode excludes password controls. Full-page drafts are protected; promotion preserves controller/selection, departure fences reads/timers without cancelling admitted work. Dismissal restores the prior terminal without stealing unrelated focus; explicit Escape/Close returns focus to its trigger.
Broker contextual reads use one timer five seconds after successful completion and a five-second whole-read deadline, stopping on errors/uncertainty/departure. Explicit target/lease selection is not rebased by refresh. Sticky stale/absence latches require deliberate reselection even if later snapshots match again. Confirmation captures exact versions before session revalidation; guarded aria-disabled controls preserve focus during updates.
## Service lifecycle and identity rotation
Service generations are saturated, distinct from identity and session generations, and advance on admitted canonical lifecycle attempts including failures; counter clear/reinit cannot revive stale versions. Public zero-wait metadata authorizes nothing. Conditional controls compare under the canonical owner reservation; no snapshot-check/unconditional-mutate gap is allowed. Reboot invalidates originating logins and boot-local reservation IDs.
### HTTPS and reboot
GET `lifecycle` has seven fields: `generation`, `running`, `transitioning`, `controllable`, `identity_generation`, `fingerprint`, `rotatable`. Stored HTTPS fingerprint is 64 lowercase hex SHA-256 digits. Stop/restart/reboot require exactly action + service generation; rotate also requires identity generation. Unknown/duplicate/escaped/coerced fields and zero/saturated versions reject. Unavailable identity yields generation zero/empty fingerprint/not rotatable without removing ordinary service controls.
Self-cutting HTTP actions use **successful synchronous send return → one nonreused-ID HTTPD callback → existing dispatcher**. Send return is not browser receipt. The callback never waits or performs lifecycle work, and captures no request/fd/reusable operation pointer. A two-second ACK admission deadline and post-validation 30-second dequeue deadline are not execution bounds. Accepted-but-lost HTTPD work retains one reservation until callback arrival or successful HTTPD destruction; failed destruction cannot release it or accumulate more callbacks. The original login/current admin is revalidated before owner admission.
`web_server_stop_current()`/`web_server_restart_current()` compare and reserve under the server mutex. Restart retains transition ownership through stop/start; failed stop skips start, failed cleanup retains handles and canonical recovery, and reinit preserves the error. `web_server_reboot_current()` reserves the HTTPS generation then uses canonical `esp_restart()` outside locks, not an HTTPD stop wait or self-console cleanup.
`web_server_replace_identity(service_generation, identity_generation, reset, &committed)` reserves **service before identity**, before any crypto/storage. Both nonzero generations mean conditional healthy-running rotation; both zero retain CLI semantics; conditional Reset is invalid. Canonical CLI/browser-shell rotation and direct security rotate/reset share nonreused task-owner-bound identity reservation tokens. Only the owner can replace once/release; exhaustion fails closed until reboot. Crypto/NVS run outside service/security mutexes and spinlocks; commit precedes live publication and old-key wipe. Reservation remains held while reserved start copies the committed pair.
HTTPS ordering is **commit → stop → restart**. Precommit generation/RNG/storage failure leaves identity, HTTPD and logins unchanged (service version may advance after admission). Postcommit lifecycle failure never rolls back identity; failed stop can leave old served and new stored fingerprints different. Running replacement restarts HTTPS; canonical stopped rotation stays stopped, while CLI TLS-only reset can recover unavailable identity and starts a stopped service. Browser has no Reset/recovery/export action.
Save drafts; rotation/restart invalidates all web logins and closes both browser routes. Inspect `web certificate info` through trusted UART0, verify fingerprint before renewing trust, then reload/sign in freshly. Accepting a warning alone is not trusted verification. Use canonical UART0/admin SSH `web stop` / `web start` for retained-server recovery. Network/SSH/USB are not stopped by HTTPS-only operations; whole-device reboot interrupts all transports and loses unsaved RAM.
### SSH
GET `ssh` supplies service/session state plus identity generation, fixed P-256 algorithm, unpadded OpenSSH `SHA256:` base64 fingerprint and rotatable flag. Service actions use exactly `action`, `generation`, `target`; rotate adds `identity_generation` and requires target zero. Start/stop and exact-session disconnect use published state, saturated service generation and the command mutex; exhausted SSH session slots retire rather than wrap. Disconnect success is an owner close request, not completed teardown. HTTPD never calls wolfSSH or waits for the SSH task.
`ssh_transport_replace_identity()` compares/reserves **service then identity before stop or storage**, holding the existing command mutex across **stop → generate/commit/publish → conditional restart**. Canonical UART0/deferred admin-SSH rotation/reset and direct security mutations share the task-bound nonreused identity reservation. Crypto/NVS run outside security locks/spinlocks. Both nonzero versions select conditional rotation (also while stopped); both zero preserve canonical semantics, with no conditional Reset.
Failed stop/timeout skips mutation and never starts again; pending owner work is not cancelled. Persistence failure after successful stop may already have disconnected clients and attempts old-identity restart if previously running. Committed replacement remains committed if restart fails. Stopped rotation stays stopped; canonical reset can recover unavailable material/start stopped SSH. Only the SSH owner frees runtime context after all slots retire, before clearing cleanup admission; start rejects orphan handles. wolfSSH copies DER into its context; caller/candidate/superseded private bytes are wiped.
SSH changes leave invoking HTTPS available, so they use the ordinary ID-dispatcher/result path, not HTTPS's self-cutting ACK gate. UI confirms both versions and all-SSH/session scope, retains stale selection and manual 15-second/no-replay flow. A failed result may represent partial effects. Verify new trust through UART0 `ssh host-key info` before updating known_hosts; no HTTPS relogin is inherently required. Browser Reset/recovery/key export and user authorized-key changes are separate, excluded from host rotation.
## Diagnostics, recovery and scope boundaries
See [admission diagnostics](web_admission_diagnostics.md), [ordinary HTTPS idle cleanup](https_idle_cleanup.md), [throughput diagnostics](web_throughput_diagnostics.md) and [legacy storage compatibility](legacy_credential_removal.md). Broker read means transport handoff, not peer receipt; capture non-consuming counters before disconnect. TLS `-0x004C` is generic NET_RECV_FAILED, not evidence of OOM. Resource minima and counter observations require attribution, not inferred causes.
Phase 8D.15's dedicated typed network-diagnostics UI/API was removed: diagnostics remain shell-based, subject to frontend policy. The unimplemented 8D.19 ordinary browser-session/native-USB control expansion was removed; existing SSH controls remain. No full shell parity, browser identity recovery/reset/export, encryption, secure boot or OTA is implied by acceptance. UART0 is the administrative recovery authority; native USB is binary-transparent, network-independent UART1 access. Neither permits bypassing the broker's single writer or recalling already-admitted work.
+40
View File
@@ -0,0 +1,40 @@
# Web administration acceptance
## Phase 8D.22 sign-off — 2026-09-13
The user explicitly accepted the tested firmware: **“Yep, I tested the firmware thats a 8d.22 signoff.”** Phase 8D is complete for its retained scope. This supersedes earlier per-slice target-pending, parent-review and integration-acceptance gates; it does not require additional testing to establish the user's sign-off. Phase 8A/B/C were already recorded as complete and target-hardware validated in the roadmap, so [Phase 8 is complete](roadmap.md#phase-8--role-based-users-and-administrative-access--complete).
Accepted functionality comprises cookie authentication, isolated browser serial/admin terminals, typed Serial/Accounts/authorized-key/Network/Display/Broker/SSH/HTTPS settings, contextual controls, confirmed generation-safe writer/service/identity actions and retained UART0/native-USB recovery. [Current contracts](web_administration.md) define actual permissions, bounded failures and partial effects; acceptance is not a claim of unrestricted browser-shell parity.
Scope decisions remain effective: 8D.15 dedicated typed network diagnostics was removed (shell diagnostics retained); the unimplemented 8D.19 ordinary browser-session/native-USB control remainder was removed (SSH controls retained). Browser identity Reset/recovery/export was not added. Phase 9 security/production hardening is next only on a separate user request.
## Latest firmware evidence
The **prior final firmware build passed**, reporting **100,556 bytes linked RAM / 1,828,573 bytes flash**, at **160 MHz**. This is recorded prior build evidence, not a build run during documentation consolidation. Host regression suites and independent implementation reviews were previously reported passing; no new host/runtime test pass is asserted here.
Latest user telemetry, bytes:
| Sample / memory capability | Free | Minimum free | Largest block |
|---|---:|---:|---:|
| Boot internal 8-bit | 59,808 | 58,840 | 31,744 |
| Boot internal DMA | 52,052 | 51,084 | 31,744 |
| Boot PSRAM | 8,196,968 | 8,183,972 | 8,126,464 |
| Loaded after burst, internal 8-bit | 31,508 | 2,052 | 18,432 |
| Loaded after burst, internal DMA | 23,752 | 460 | 18,432 |
| Loaded after burst, PSRAM | 8,136,624 | 8,065,972 | 7,995,392 |
Loaded SSH minimum-free stack was **15,028 bytes**. The capture had two active SSH sessions across the serial/admin roles, two serial WebSockets and USB, with SSH holding the serial writer. Browser admin had been used and then closed; it was **not active in the captured loaded sample**. Web send/queue/protocol error counters were zero; SSH IO errors were zero, with one handshake failure and one session revocation retained without attributing a cause.
Latest broker/serial counters were not supplied, so these transport counters do **not** establish an exact latest zero-drop or byte-integrity result. No latest full-mix-with-browser-admin-active, individual fault-injection, exact duration, soak, cleanup-cycle or reserve-floor result is inferred. Generic SDK TLS `-0x004C` / NET_RECV_FAILED is not an OOM diagnosis. Two boot authentication failures could plausibly involve stale browser cookies, but that explanation is unconfirmed.
## Earlier acceptance retained without replaying the timeline
- M1 browser login/logout and M2 shared browser administration were explicitly accepted by the user; later Serial/account/Network presentation and legacy-credential cleanup also received scoped acceptance.
- The user explicitly accepted the combined binary WebSocket-send fix at **160 MHz, 230400 baud with the full client mix, including browser admin**. That prior acceptance stands independently of the latest capture's closed browser-admin socket. It does not imply an unreported latest exact counter comparison or soak duration. Keep the combined send and bounded failed-send isolation, not the earlier frequency-only experiment.
- Ordinary HTTPS idle cleanup was accepted as working; that is not a guarantee against all future admission failures or owner delays.
## Nonblocking follow-ups and evidence limits
The extremely low internal/DMA lifetime minima remain an unresolved transient-headroom follow-up, **not a blocker reopening 8D sign-off and not an approved reserve**. Capability pools overlap; summed per-region lifetime minima can be conservative/non-simultaneous and do not prove an allocation failure. Do not add internal and DMA numbers together or attribute an error to memory pressure without correlated evidence. HTTPD/dispatcher stack margins, peak correlation, allocation reserve policy and long-run cleanup/soak evidence remain distinct future measurements.
[Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) and focused test runners remain available for future changes; listing them is not evidence they all ran on hardware. Documentation-only consolidation changes no firmware, configuration, generated assets or test implementation and performs no build/upload/erase/commit. No new Phase 9 work is authorized by this acceptance.
+32
View File
@@ -0,0 +1,32 @@
# Web admission diagnostics
Current default-disabled instrumentation, not an admission fix or an allocation-failure detector. `src/web_diagnostics.{c,h}` observes public synchronous HTTPS create/close callbacks and the four serial/admin ticket/upgrade handlers. It adds no owner task, queued probe or socket-capacity change. See [ordinary idle cleanup](https_idle_cleanup.md) for the independent cleanup policy and [acceptance evidence](web_administration_acceptance.md) for user reports.
## Usage and interpretation
Use UART0 (preferred during network stalls) or authenticated admin SSH:
```text
web diagnostics clear
web diagnostics enable
web diagnostics show
```
Enable before reproducing the failed third connection. At failure, run `web diagnostics show` promptly, alongside existing `memory`/`web counters`/browser Network timing evidence; repeat the snapshot if admission remains stalled. Then `web diagnostics disable` freezes event retention (live occupancy continues updating); `web diagnostics show` prints retained history, and `web diagnostics clear` erases history/counters without changing live sockets or enable state. The setting is RAM-only and defaults off after boot. Existing browser-shell policy deliberately denies these commands, even though shared completion offers the fixed forms. There is no HTTP diagnostic endpoint.
- Six connection records are maintained from boot even with capture disabled, so enabling on an already loaded server does not mislabel existing sockets. Each successful TLS connection gets a monotonically increasing, non-wrapping, firmware-lifetime 64-bit `conn` sequence independent of fd, cookie, ticket, broker or user identity. Clear, disable and HTTPS restart do not reset it. Internal TLS object identity is used only during synchronous cleanup; no pointer is exported or dereferenced by the console.
- Snapshot output gives its boot-relative `snapshot_us`, post-TLS occupancy split ordinary/serial-WS/admin-WS and each live fd/connection sequence/open time/age. `kind=0/1/2` means ordinary/serial/admin. Ordinary includes every successful TLS socket not yet observed as upgraded, including idle keep-alives, assets/login/settings and sockets not yet used for HTTP. Classification is actual public `httpd_ws_get_fd_info()` state after upgrade-handler return, **not inferred from `ESP_OK`**. No URI string is inspected.
- The fixed 32-entry overwrite ring records successful TLS open, TLS transport-context close, and enter/result for serial/admin ticket and upgrade handlers. `t_us` is boot-relative observation time; result `dt_us` measures the underlying handler only (including its work/IO, excluding entry resource sampling); close `dt_us` is successful-TLS connection lifetime. Open/enter durations are zero. Open/result occupancy includes the connection; close occupancy excludes it. Event IDs survive clear; `overwritten` counts evicted retained records since clear. `unmatched` counts duplicate create/unmatched close; `lost` counts untrackable creates (getter failure, metadata/sequence exhaustion). Nonzero anomaly counters mean occupancy is not trustworthy as complete evidence. Counters saturate.
- `rc` is the exact handler return, **not HTTP status or ticket issuance outcome**: sending a 401/403/503 can return `ESP_OK`. Match browser HTTP status and existing rejection/issuance counters; there is no ticket-value correlation, request ID, authentication identity, header/body/query logging, or allocation-failure attribution. Upgrade success is visible in the occupancy classification. Ring overwrite or clear/toggle during an operation can leave unpaired enter/result records; do not invent a duration for a missing result.
- Each retained event samples free/largest bytes for internal 8-bit, internal DMA and PSRAM 8-bit, plus the current HTTPD task's minimum-free stack **in ESP-IDF bytes**. Capability scans run outside the diagnostic lock. Samples are sequential, not an atomic heap snapshot; overlapping internal/DMA pools must not be added. Stack watermark includes diagnostic call overhead and is not dispatcher margin. `show` does not query a live task handle: resources are historical event samples, not fresh heap values at show time. Use `memory` for current system heap.
- Console snapshots copy only local diagnostic metadata under a short portMUX, never inspect HTTPD session internals or wait for its owner. Printing occurs outside the lock. At most 32 ID-qualified rows and six live records are printed, even during churn; concurrent clear/overwrite is reported as “no longer retained”. Capture epochs reject samples crossing enable/disable/clear, and sequence checks prevent a stale upgrade result from reclassifying a reused fd. No queued diagnostic work exists, hence no outstanding probe or stale queue lifetime to retire on restart. Minimal connection bookkeeping and upgrade classification remain active while disabled; heap scans and event recording do not. Instrumentation still has CPU/static RAM cost, not zero perturbation.
## SDK audit and explicit blind spots
Audited installed PlatformIO ESP-IDF **5.5.0**, `components/esp_https_server/src/https_server.c` (`httpd_ssl_open`, `httpd_ssl_close`), `components/esp_http_server/src/httpd_sess.c` (`httpd_sess_delete`) and `httpd_main.c`. HTTPS performs synchronous TLS creation before the configured `open_fn` and `user_cb` create callback. It installs a transport-context destructor; that destructor invokes the public close callback before deleting TLS/freeing the context. HTTPD's default close closes the fd, then clears contexts, then frees its session slot. The diagnostic close therefore marks a cleanup observation, not a FIN timestamp or causal close reason. It uses the stored fd, not a getter on an already-closed socket. **Neither `open_fn` nor `close_fn` is replaced**, preserving all existing HTTPS cleanup ownership and failure behavior. Callbacks execute synchronously under the existing HTTPD lifecycle; successful stop finishes cleanup before restart, while failed/partial stop retains remaining live metadata. No asynchronous fd-only events are consumed, avoiding event-delay/fd-reuse ambiguity.
This deliberately bounded first slice does **not** measure TCP connect/accept/listen backlog, pending clients when IDF stops accepting at capacity, handshake begin/duration/failure, TLS allocation failure or aggregate lwIP socket pressure. The public configured open hook is post-TLS too; adding it would not fix these blind spots. No owner-queued client-list probe is added. Occupancy is an owner-published **successful-TLS lower bound**, not the complete HTTPD session table while a handshake is in progress. A clean **6/6** snapshot supports established-connection saturation at that instant (three WS + three ordinary is directly distinguishable); fewer than six does not exonerate admission/TLS/global socket pressure. Correlate time with browser evidence and existing secret-free TLS errors; do not call this pre-TLS tracing or claim the root cause is proven. Existing capacities, receive/send/handshake timeouts and accepted admission issue remain unchanged.
## Regression entry points and limits
`python3 tests/web_diagnostics/run.py` covers bounded metadata/ring behavior and SDK guards; `python3 tests/web_admin_transport/server_lifecycle.py` covers lifecycle composition. These are reusable host commands, not a new execution claim. Doubled TLS/scheduling and host tests cannot establish target admission latency, reserve floors or fault causes. Capture on UART0 alongside `memory`, `web counters`, browser timings and non-consuming broker/serial counters, without cookies/tickets/headers. Generic TLS NET_RECV_FAILED (`-0x004C`) and NET_CONN_RESET (`-0x0050`) are not OOM diagnoses.
+77
View File
@@ -0,0 +1,77 @@
# Web serial throughput diagnostics
## Current contract and accepted fix
Default-disabled instrumentation measures broker fan-out and binary WebSocket TX, not CPU usage, peer receipt or pure scheduler latency. The user separately accepted the combined binary header/payload-send fix at **160 MHz / 230400 baud with full mix including browser admin**. [Acceptance evidence](web_administration_acceptance.md) distinguishes that report from the latest capture and unmeasured reserves. The old frequency-only experiment is not the retained solution.
`web_httpd_ws_send_binary` is an IDF-5.5.0-pinned owner-only session-override send with a bounded 516-byte header/payload scratch copy and 512-byte payload. Generation validation and one outstanding item per slot remain. A non-full send installs a reject-only override before close/shutdown, preventing SDK automatic PONG/CLOSE from reentering TLS with different arguments after incomplete output, even if shutdown fails. No retry/replay; HTTPD owns TLS destruction. Text/control/admin retain the SDK sender. One API call does not promise one TLS record/packet or peer receipt. Keep CPU160, priorities, scheduling and 4096-byte broker/512-byte web payload bounds unless separately changing them with evidence.
## Broker accounting
`broker counters` retains global totals and adds one row per active client: generation-safe ID, type, pending bytes, output high-water mark (HWM), UART bytes considered for that client, queued bytes, read bytes and dropped bytes. `broker clients` helps map the IDs to active transports; `web performance show` supplies each web slot's broker ID.
- HWM is peak output occupancy, bounded by 4,096 bytes. `broker clear-counters` clears totals but seeds each active HWM with current pending occupancy, not zero. It does not drain queues or change ownership.
- `read` means handed to a transport, not received or rendered by a peer. Queued counts successful copies; UART counts bytes considered while the client was connected.
- Disconnected per-client rows disappear and slot/generation reuse resets them. Global counters retain disconnected traffic until cleared, including accepted-but-unread output discarded on disconnect. Thus global drops need not mean only overflow, and queued plus dropped need not equal UART fan-out after disconnect discards.
- Capture before disconnect. **Do not use `broker read` for observation:** it consumes data intended for that transport. Snapshot/counter commands do not consume serial data.
## Independent web performance capture
Use physical UART0 for the least intrusive capture. Commands are also available through the authenticated admin SSH registry. `web performance enable|disable|show|clear` is separate from admission tracing (`web diagnostics enable|disable|show|clear`) and from ordinary `web counters|clear-counters`.
Performance capture defaults disabled. Enable resumes aggregates; disable freezes aggregates; clear resets aggregates while preserving enable state. Each control operation advances a nonwrapping epoch and fences in-flight samples. Exhaustion fails closed. Slot generation and broker identity also fence reuse; this is not a disconnected-session archive.
Each active one of two fixed slots shows fd, generation, broker ID, pending/measured-pending/executing state and current-epoch `pending_age_us`. Age starts at reservation-path entry and includes an executing send. If `measured_pending=0`, zero age is unavailable, not evidence of immediate service. Disabling invalidates current-epoch pending age; capture a live `show` only if investigating a stuck queue, accepting its perturbation.
Only serial **binary TX** is measured. Text/control frames are not samples, though they can delay subsequent binary work. No payloads, passwords, keys, cookies, tickets, verifier material or other authentication secrets are retained or printed.
| Printed timing | Exact interpretation |
|---|---|
| `queue->callback-entry` | Reservation-path entry to HTTPD callback entry, timestamped before the callback takes the transport lock. Includes submission-path work, not just HTTPD queue residence; owned callbacks retired without sending can count. |
| `send-call` | Time around the actual synchronous HTTPD-owner send API call. Return is not peer acknowledgement, browser receipt or rendering. |
| `completion->first-drain-attempt-return` | Successful send-call completion to return of the first subsequent broker read attempt, including empty/error attempts. |
| `completion->next-nonempty-drain-return (includes idle)` | Completion to the next successful nonempty broker read return, possibly after empty attempts and source idle time. |
| `completion->first-attempt-nonempty-return` | Subset where the first subsequent read returns data. Excludes observed empty attempts, but does not prove backlog existed at send completion. |
Each timing reports count, sum in microseconds, integer estimated average (`sum/count`, zero for no samples) and maximum. Completion intervals are not scheduler-only: they include transport work, broker read/mutex time and intervening control-frame work, and may include idle time even in the first-attempt-nonempty subset. They do not isolate a particular task, TLS operation or network cause.
`queued_frames/bytes` counts transport reservations, including reported queue failures; `queue_errors` records those failures. `sent_frames/bytes` counts successful send-call returns. `send_errors` includes owner-context rejection, whereas send-call timings require an actual API call. `retired` records measured callbacks retired without sending. Saturating aggregates set `saturated=1`; totals and averages then cease to be reliable. Epoch fencing intentionally prevents old work contaminating a new capture, so do not demand equality across toggles or in-flight clears.
## Reproducible UART0 capture
1. Record firmware/build identity, baud/framing/flow control, source burst size, browser versions, client IDs/roles and full-mix topology. Establish all serial clients and both admin routes first. Quiet the UART source and allow queues and connection activity to settle. Do not reconnect/login during the trace.
2. Optionally stop independent admission tracing before the timed capture. Then reset in this order, while quiet:
```text
web diagnostics disable
web performance clear
web performance enable
broker clear-counters
serial clear-counters
web clear-counters
```
The first command is optional; record whether admission tracing was enabled. Resets are sequential, not an atomic cross-service snapshot.
3. Send the known burst once, stop the source and wait for transport queues to drain. Avoid repeated console/status printing during the burst. A quiet final broker pending count of zero alone does not prove browser receipt; retain peer byte counts too.
4. Stop performance capture first, then capture all counters **before disconnecting any client**:
```text
web performance disable
web performance show
broker counters
serial counters
web counters
broker clients
```
Disable already prints the performance snapshot; `show` is an explicit repeatable frozen-aggregate record. Keep connection counts and source/peer totals with the sample.
5. Only after saving evidence, change to one browser, keeping the other full-mix participants and serial settings equivalent. Quiet/settle and repeat the complete reset/burst/drain/stop/capture sequence. Do not compare a fresh capture to lifetime counters.
6. Separately compare equivalent performance-enabled and performance-disabled bursts with fresh ordinary counter resets. Keep performance disabled for the latter; do not treat retained performance aggregates as that run's measurements. Keep admission tracing state equal and record it.
At 230400 baud, 8N1, 4,096 bytes represents approximately **177.8 ms** of continuous input retention from an empty broker queue, ignoring flow-control pauses. Compare per-client drops/HWM with latency maxima against this scale, not as a hard deadline or proof of cause; occupancy, idle gaps and timings cover different boundaries. For future regressions, gather per-client attribution and controlled comparisons before changing buffers, priorities, scheduling or CPU.
## Resources and regression references
Two fixed diagnostic records plus nonwrapping epoch/timestamp/slot fences and broker HWM fields add bounded storage, no new instrumentation allocations/tasks/queues/payloads. Disabled is not zero overhead: gate branches remain and broker occupancy/HWM is always active. Enabled timing and short aggregate locks have runtime costs not established by host tests. The combined-send local scratch adds HTTPD stack use; actual reserve remains a target measurement.
`tests/session_broker_diagnostics/run.py` covers counters and generation-safe management; `tests/web_serial_performance/run.py` covers production send/drain paths, epochs, retirement, SDK wire compatibility and failed-send/control-reply isolation. `tests/admin_ssh_policy/run.py` covers SSH-allowed/browser-denied tracing commands. Prior focused/broad host validation and firmware builds passed; no tests were run during documentation consolidation. These harnesses do not prove real TLS/scheduler overhead, peer-byte integrity or long-soak behavior. Use the capture procedure above for future attribution, retaining explicit workload/duration/counter provenance.
+3
View File
@@ -0,0 +1,3 @@
Import("env")
env.Replace(COMPILATIONDB_INCLUDE_TOOLCHAIN=True)
+2
View File
@@ -13,3 +13,5 @@ board_build.partitions = partitions.csv
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
extra_scripts = pre:extra_script.py
+2
View File
@@ -1,4 +1,6 @@
# ESP32-S3-WROOM-1-N16R8 hardware configuration
# Single-variable web throughput experiment; target validation pending.
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160=y
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
+26
View File
@@ -31,9 +31,25 @@ idf_component_register(
"user_console.c"
"web_security.c"
"web_serial_transport.c"
"web_serial_settings.c"
"web_account_settings.c"
"web_network_settings.c"
"web_display_settings.c"
"web_broker_settings.c"
"web_ssh_settings.c"
"web_lifecycle_settings.c"
"web_admin_tickets.c"
"web_admin_transport.c"
"web_assets_data.c"
"web_ui.c"
"web_server.c"
"web_diagnostics.c"
"web_session_store.c"
"web_auth_parse.c"
"web_httpd_adapter.c"
"web_httpd_idle.c"
"web_cookie_auth.c"
"web_login_ui.c"
"web_console.c"
"wifi_config.c"
"wifi_manager.c"
@@ -67,6 +83,16 @@ idf_component_register(
wolfssl__wolfssl
)
# Only web_httpd_adapter.c uses this private, version-checked boundary.
target_include_directories(${COMPONENT_LIB} PRIVATE
"$ENV{IDF_PATH}/components/esp_http_server/src"
"$ENV{IDF_PATH}/components/esp_http_server/src/port/esp32")
# HTTPD debug logs include header values; URI warnings include ticket queries.
# Compile those out, independently of runtime log-level changes.
idf_component_get_property(httpd_lib esp_http_server COMPONENT_LIB)
target_compile_definitions(${httpd_lib} PRIVATE LOG_LOCAL_LEVEL=ESP_LOG_ERROR)
# Public wolfSSH headers include wolfCrypt configuration from user_settings.h.
target_compile_definitions(${COMPONENT_LIB} PRIVATE
WOLFSSL_USER_SETTINGS
+402 -97
View File
@@ -9,15 +9,20 @@
#include "console_completion.h"
#include "esp_console.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "linenoise/linenoise.h"
#include "secure_random.h"
#include "ssh_transport.h"
#include "user_database.h"
#include "web_serial_settings.h"
#include "web_account_settings.h"
#include "web_network_settings.h"
#include "web_display_settings.h"
#include "web_broker_settings.h"
#include "web_ssh_settings.h"
#include "web_lifecycle_settings.h"
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
@@ -47,6 +52,7 @@ typedef struct {
bool executing;
bool deferred_action_pending;
admin_ssh_console_token_t token;
const admin_console_owner_t *owner;
user_principal_t principal;
size_t input_length;
size_t input_cursor;
@@ -71,9 +77,24 @@ typedef struct {
uint8_t output[ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY];
} admin_session_t;
typedef struct {
admin_ssh_deferred_action_type_t action;
admin_ssh_console_token_t token;
const admin_console_owner_t *owner;
uint32_t argument;
} admin_control_request_t;
typedef enum {
ADMIN_REQUEST_SSH = 0,
ADMIN_REQUEST_UART0,
ADMIN_REQUEST_DEFERRED,
ADMIN_REQUEST_SERIAL_SETTINGS,
ADMIN_REQUEST_ACCOUNT_SETTINGS,
ADMIN_REQUEST_NETWORK_SETTINGS,
ADMIN_REQUEST_DISPLAY_SETTINGS,
ADMIN_REQUEST_BROKER_SETTINGS,
ADMIN_REQUEST_SSH_SETTINGS,
ADMIN_REQUEST_LIFECYCLE_SETTINGS,
} admin_request_origin_t;
typedef struct {
@@ -81,18 +102,23 @@ typedef struct {
admin_ssh_console_token_t token;
user_principal_t principal;
TaskHandle_t completion_task;
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
union {
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
admin_control_request_t deferred;
uint32_t serial_settings_id;
uint32_t account_settings_id;
uint32_t network_settings_id;
uint32_t display_settings_id;
uint32_t broker_settings_id;
uint32_t ssh_settings_id;
uint32_t lifecycle_settings_id;
};
} admin_request_t;
typedef struct {
admin_ssh_deferred_action_type_t action;
admin_ssh_console_token_t token;
uint32_t argument;
} admin_control_request_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_MAX_SESSIONS];
/* admin_ssh_console_feed_input() is called only by the sole SSH owner task. */
/* Claimed under s_lock, used outside it; competing TAB input is backpressured. */
static bool s_completion_busy;
static char s_completion_output[CONSOLE_COMPLETION_OUTPUT_CAPACITY];
static StaticQueue_t s_request_queue_storage;
@@ -115,13 +141,18 @@ static bool s_dispatch_remote;
static bool s_dispatch_output_previous_cr;
static admin_ssh_console_token_t s_dispatch_token;
static user_principal_t s_dispatch_principal;
static ssh_transport_snapshot_t s_control_ssh_snapshot;
bool admin_ssh_console_dispatch_is_remote(void)
{
return xTaskGetCurrentTaskHandle() == s_task && s_dispatch_remote;
}
bool admin_ssh_console_dispatch_is_web(void)
{
return admin_ssh_console_dispatch_is_remote() &&
s_dispatch_token.transport == ADMIN_CONSOLE_TRANSPORT_WEB;
}
const user_principal_t *admin_ssh_console_dispatch_principal(void)
{
return admin_ssh_console_dispatch_is_remote() ? &s_dispatch_principal : NULL;
@@ -137,6 +168,8 @@ static bool token_identity_matches(const admin_session_t *session,
const admin_ssh_console_token_t *token)
{
return token_valid(token) && session->token.session_id == token->session_id &&
session->token.transport == token->transport &&
session->token.slot_index == token->slot_index &&
session->token.slot_generation == token->slot_generation;
}
@@ -146,6 +179,37 @@ static bool token_matches(const admin_session_t *session,
return session->active && token_identity_matches(session, token);
}
static bool session_is_current(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
const admin_console_owner_t *owner = token_matches(session, token)
? session->owner : NULL;
taskEXIT_CRITICAL(&s_lock);
if (owner == NULL) {
return false;
}
bool account_current = false;
bool current = principal->role == USER_ROLE_ADMIN &&
user_database_principal_is_current(principal, &account_current) == ESP_OK &&
account_current && owner->is_current(token, principal);
/* External checks may close/reuse a slot. Never act on its replacement. */
taskENTER_CRITICAL(&s_lock);
bool matched = token_matches(session, token) && session->owner == owner;
taskEXIT_CRITICAL(&s_lock);
if (matched && !current) {
admin_ssh_console_close(token);
}
return matched && current;
}
bool admin_ssh_console_dispatch_is_current(void)
{
return xTaskGetCurrentTaskHandle() == s_task &&
(!s_dispatch_remote || session_is_current(&s_dispatch_token, &s_dispatch_principal));
}
static bool append_output_locked(admin_session_t *session,
const uint8_t *data, size_t length)
{
@@ -306,6 +370,9 @@ esp_err_t admin_ssh_console_dispatch_read_input(
*output_length = 0U;
memset(output, 0, capacity);
(void)xSemaphoreTake(s_prompt_done, 0U);
if (!session_is_current(&s_dispatch_token, &s_dispatch_principal)) {
return ESP_ERR_NOT_FOUND;
}
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[s_dispatch_token.slot_index];
@@ -328,27 +395,36 @@ esp_err_t admin_ssh_console_dispatch_read_input(
if (!published) {
return ESP_ERR_NO_MEM;
}
if (xSemaphoreTake(s_prompt_done, portMAX_DELAY) != pdTRUE) {
return ESP_FAIL;
for (;;) {
/* The semaphore is only a hint: delayed/stale wakes cannot submit input. */
(void)xSemaphoreTake(s_prompt_done, pdMS_TO_TICKS(250U));
bool current = session_is_current(&s_dispatch_token, &s_dispatch_principal);
esp_err_t result = ESP_ERR_INVALID_STATE;
taskENTER_CRITICAL(&s_lock);
session = &s_sessions[s_dispatch_token.slot_index];
if (!token_identity_matches(session, &s_dispatch_token)) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_NOT_FOUND;
}
if (current && session->active && session->prompt_state == ADMIN_PROMPT_WAITING) {
taskEXIT_CRITICAL(&s_lock);
continue;
}
if (!current || !session->active || session->prompt_state == ADMIN_PROMPT_DISCONNECTED) {
result = ESP_ERR_NOT_FOUND;
} else if (session->prompt_state == ADMIN_PROMPT_SUBMITTED) {
memcpy(output, session->prompt_input, session->prompt_length);
*output_length = session->prompt_length;
result = ESP_OK;
}
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
session->prompt_length = 0U;
session->prompt_capacity = 0U;
session->prompt_hidden = false;
session->prompt_state = ADMIN_PROMPT_NONE;
taskEXIT_CRITICAL(&s_lock);
return result;
}
esp_err_t result = ESP_ERR_INVALID_STATE;
taskENTER_CRITICAL(&s_lock);
session = &s_sessions[s_dispatch_token.slot_index];
if (session->prompt_state == ADMIN_PROMPT_SUBMITTED) {
memcpy(output, session->prompt_input, session->prompt_length);
*output_length = session->prompt_length;
result = ESP_OK;
} else if (session->prompt_state == ADMIN_PROMPT_DISCONNECTED) {
result = ESP_ERR_NOT_FOUND;
}
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
session->prompt_length = 0U;
session->prompt_capacity = 0U;
session->prompt_hidden = false;
session->prompt_state = ADMIN_PROMPT_NONE;
taskEXIT_CRITICAL(&s_lock);
return result;
}
esp_err_t admin_ssh_console_dispatch_defer(
@@ -361,6 +437,12 @@ esp_err_t admin_ssh_console_dispatch_defer(
admin_session_t *session = &s_sessions[s_dispatch_token.slot_index];
bool valid = token_matches(session, &s_dispatch_token) &&
!session->deferred_action_pending;
const admin_console_owner_t *owner = valid ? session->owner : NULL;
if (valid && ((unsigned)action > ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE ||
!(owner->supported_actions & (1U << action)))) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_NOT_SUPPORTED;
}
if (valid) {
session->deferred_action_pending = true;
}
@@ -371,6 +453,7 @@ esp_err_t admin_ssh_console_dispatch_defer(
admin_control_request_t request = {
.action = action,
.token = s_dispatch_token,
.owner = owner,
.argument = argument,
};
if (xQueueSend(s_control_queue, &request, 0U) == pdTRUE) {
@@ -432,6 +515,34 @@ static int ssh_output_write(void *cookie, const char *buffer, int length)
return length;
}
bool admin_ssh_console_web_user_command_allowed(
size_t argc, char **argv, const user_principal_t *principal)
{
if (argc == 0U || strcmp(argv[0], "user") != 0) return false;
if (argc == 1U ||
(argc == 2U && (strcmp(argv[1], "status") == 0 ||
strcmp(argv[1], "list") == 0)) ||
(argc == 3U && strcmp(argv[1], "show") == 0)) return true;
bool role_valid = argc >= 4U &&
(strcmp(argv[3], "user") == 0 || strcmp(argv[3], "admin") == 0);
bool mutation =
(argc == 4U && strcmp(argv[1], "add") == 0 && role_valid) ||
(argc == 3U && strcmp(argv[1], "password") == 0) ||
(argc == 4U && strcmp(argv[1], "delete") == 0 &&
strcmp(argv[3], "--force") == 0) ||
(argc == 5U && strcmp(argv[1], "role") == 0 && role_valid &&
strcmp(argv[4], "--force") == 0);
/* Parsed names use the database's exact, case-sensitive identity. Reject
* self even for no-op role changes; their handler still requests revocation.
* Generation/output and key workflows remain outside this bounded slice. */
return mutation && principal != NULL && principal->role == USER_ROLE_ADMIN &&
principal->username_length > 0U &&
principal->username_length <= USER_DATABASE_USERNAME_CAPACITY &&
!(strlen(argv[2]) == principal->username_length &&
memcmp(argv[2], principal->username, principal->username_length) == 0);
}
static bool remote_command_allowed(const admin_request_t *request)
{
char copy[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
@@ -439,11 +550,39 @@ static bool remote_command_allowed(const admin_request_t *request)
char *argv[ADMIN_SSH_CONSOLE_MAX_ARGUMENTS] = {0};
/* Use exactly the same quote/escape parser as esp_console_run(). */
size_t argc = esp_console_split_argv(copy, argv, ADMIN_SSH_CONSOLE_MAX_ARGUMENTS);
bool allowed = argc > 0U;
if (allowed && strcmp(argv[0], "user") == 0 && argc >= 2U &&
(strcmp(argv[1], "bootstrap") == 0 || strcmp(argv[1], "recover") == 0)) {
/* Empty input is handled quietly by esp_console_run(), not UART0 policy. */
bool allowed = true;
if (argc >= 2U && strcmp(argv[0], "user") == 0 &&
strcmp(argv[1], "recover") == 0) {
allowed = false;
}
/* Temporary browser policy until lifecycle acknowledgements/revocation are
* coordinated (8D.7). Classify parsed canonical arguments, not raw prefixes.
* User mutations remain available through UART0/SSH, subject to their policy.
*/
if (request->token.transport == ADMIN_CONSOLE_TRANSPORT_WEB && argc > 0U) {
if (strcmp(argv[0], "web") == 0) {
allowed = (argc == 2U && (strcmp(argv[1], "status") == 0 ||
strcmp(argv[1], "stop") == 0)) ||
(argc == 4U && strcmp(argv[1], "certificate") == 0 &&
strcmp(argv[2], "rotate") == 0 && strcmp(argv[3], "--force") == 0);
} else if (strcmp(argv[0], "wifi") == 0 || strcmp(argv[0], "mdns") == 0) {
allowed = argc == 2U && strcmp(argv[1], "status") == 0;
} else if (strcmp(argv[0], "user") == 0) {
allowed = admin_ssh_console_web_user_command_allowed(
argc, argv, &request->principal);
} else if (strcmp(argv[0], "reboot") == 0) {
allowed = argc == 1U;
} else if (strcmp(argv[0], "ssh") == 0 && argc >= 2U) {
/* SSH-specific deferred actions are not yet supported by WEB. */
if (strcmp(argv[1], "stop") == 0 || strcmp(argv[1], "disconnect") == 0 ||
strcmp(argv[1], "reset") == 0 ||
(strcmp(argv[1], "host-key") == 0 &&
!(argc == 3U && strcmp(argv[2], "info") == 0))) {
allowed = false;
}
}
}
secure_wipe(copy, sizeof(copy));
return allowed;
}
@@ -473,12 +612,14 @@ static int command_exit(int argc, char **argv)
}
esp_err_t error = admin_ssh_console_dispatch_defer(
ADMIN_SSH_DEFER_DISCONNECT, s_dispatch_token.session_id);
ADMIN_CONSOLE_DEFER_SELF_CLOSE, s_dispatch_token.session_id);
if (error != ESP_OK) {
printf("Could not schedule SSH session close: %s\n", esp_err_to_name(error));
printf("Could not schedule %s session close: %s\n",
s_dispatch_token.transport == 0U ? "SSH" : "remote", esp_err_to_name(error));
return 1;
}
printf("SSH session close scheduled after output drains.\n");
printf("%s session close scheduled after output drains.\n",
s_dispatch_token.transport == 0U ? "SSH" : "Remote");
return 0;
}
@@ -506,8 +647,11 @@ static void dispatch_registered_command(admin_request_t *request)
}
int command_result = 0;
esp_err_t error = esp_console_run((const char *)request->line, &command_result);
report_command_result(error, command_result);
if (request->origin == ADMIN_REQUEST_UART0 ||
session_is_current(&request->token, &request->principal)) {
esp_err_t error = esp_console_run((const char *)request->line, &command_result);
report_command_result(error, command_result);
}
fflush(stdout);
s_dispatch_remote = false;
@@ -521,6 +665,78 @@ static void dispatch_registered_command(admin_request_t *request)
}
}
static void dispatch_deferred_request(admin_request_t *request);
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_SERIAL_SETTINGS, .serial_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_ACCOUNT_SETTINGS, .account_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_NETWORK_SETTINGS, .network_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_DISPLAY_SETTINGS, .display_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_BROKER_SETTINGS, .broker_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_SSH_SETTINGS, .ssh_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id)
{
taskENTER_CRITICAL(&s_lock);
bool ready = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready || !id) return ESP_ERR_INVALID_STATE;
admin_request_t request = {.origin = ADMIN_REQUEST_LIFECYCLE_SETTINGS, .lifecycle_settings_id = id};
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
static void worker_task(void *context)
{
(void)context;
@@ -529,6 +745,25 @@ static void worker_task(void *context)
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
continue;
}
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS ||
request.origin == ADMIN_REQUEST_NETWORK_SETTINGS || request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS ||
request.origin == ADMIN_REQUEST_BROKER_SETTINGS || request.origin == ADMIN_REQUEST_SSH_SETTINGS ||
request.origin == ADMIN_REQUEST_LIFECYCLE_SETTINGS) {
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id);
else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id);
else if (request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) web_network_settings_execute(request.network_settings_id);
else if (request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) web_display_settings_execute(request.display_settings_id);
else if (request.origin == ADMIN_REQUEST_BROKER_SETTINGS) web_broker_settings_execute(request.broker_settings_id);
else if (request.origin == ADMIN_REQUEST_SSH_SETTINGS) web_ssh_settings_execute(request.ssh_settings_id);
else web_lifecycle_settings_execute(request.lifecycle_settings_id);
secure_wipe(&request, sizeof(request));
continue;
}
if (request.origin == ADMIN_REQUEST_DEFERRED) {
dispatch_deferred_request(&request);
secure_wipe(&request, sizeof(request));
continue;
}
if (request.origin == ADMIN_REQUEST_UART0) {
dispatch_registered_command(&request);
if (request.completion_task != NULL) {
@@ -538,35 +773,33 @@ static void worker_task(void *context)
continue;
}
bool current = false;
esp_err_t auth_error = user_database_principal_is_current(&request.principal, &current);
bool current = session_is_current(&request.token, &request.principal);
bool active;
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[request.token.slot_index];
active = token_matches(session, &request.token) && session->command_pending &&
active = current && token_matches(session, &request.token) && session->command_pending &&
!session->executing;
if (active) {
session->executing = true;
}
taskEXIT_CRITICAL(&s_lock);
bool authorized = active && auth_error == ESP_OK && current &&
request.principal.role == USER_ROLE_ADMIN &&
remote_command_allowed(&request);
bool authorized = active && remote_command_allowed(&request);
if (authorized) {
dispatch_registered_command(&request);
} else if (active) {
(void)worker_write(&request.token,
auth_error == ESP_OK && current
? "Command is restricted to physical UART0.\r\n"
: "Administrative authorization is no longer current; closing session.\r\n");
request.token.transport == ADMIN_CONSOLE_TRANSPORT_WEB
? "Command is unavailable from the web console; use UART0 or SSH where permitted. Recovery requires UART0.\r\n"
: "Command is restricted to physical UART0.\r\n");
}
current = session_is_current(&request.token, &request.principal);
bool prompt = false;
taskENTER_CRITICAL(&s_lock);
session = &s_sessions[request.token.slot_index];
if (token_matches(session, &request.token)) {
session->executing = false;
session->command_pending = false;
prompt = auth_error == ESP_OK && current &&
prompt = current &&
request.principal.role == USER_ROLE_ADMIN &&
!session->deferred_action_pending;
} else if (!session->active && session->executing &&
@@ -586,16 +819,18 @@ static void finish_deferred_request(const admin_control_request_t *request,
esp_err_t result, bool cancelled)
{
char message[160];
const char *transport = request->token.transport == 0U ? "SSH" : "remote";
if (cancelled) {
snprintf(message, sizeof(message),
"Deferred action cancelled before SSH output drained.\r\nadmin@serial-tool> ");
"Deferred action cancelled before %s output drained.\r\nadmin@serial-tool> ",
transport);
} else if (result == ESP_OK) {
snprintf(message, sizeof(message),
"Deferred SSH action completed.\r\nadmin@serial-tool> ");
"Deferred %s action completed.\r\nadmin@serial-tool> ", transport);
} else {
snprintf(message, sizeof(message),
"Deferred SSH action failed: %s\r\nadmin@serial-tool> ",
esp_err_to_name(result));
"Deferred %s action failed: %s\r\nadmin@serial-tool> ",
transport, esp_err_to_name(result));
}
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[request->token.slot_index];
@@ -606,6 +841,34 @@ static void finish_deferred_request(const admin_control_request_t *request,
taskEXIT_CRITICAL(&s_lock);
}
static void dispatch_deferred_request(admin_request_t *request)
{
const admin_control_request_t *action = &request->deferred;
bool current = session_is_current(&action->token, &request->principal);
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[action->token.slot_index];
bool active = current && token_matches(session, &action->token) &&
session->owner == action->owner && session->deferred_action_pending &&
!session->command_pending && !session->executing;
if (active) session->executing = true;
taskEXIT_CRITICAL(&s_lock);
/* Keep the slot reserved across lifecycle callbacks, including self-detach.
* Recheck after reservation just as the canonical runner does. */
esp_err_t result = ESP_ERR_NOT_FOUND;
if (active && session_is_current(&action->token, &request->principal)) {
result = action->owner->perform(&action->token, action->action, action->argument);
}
taskENTER_CRITICAL(&s_lock);
session = &s_sessions[action->token.slot_index];
if (active && token_identity_matches(session, &action->token)) {
if (session->active) session->executing = false;
else secure_wipe(session, sizeof(*session));
}
taskEXIT_CRITICAL(&s_lock);
finish_deferred_request(action, result, false);
}
static void control_task(void *context)
{
(void)context;
@@ -626,18 +889,8 @@ static void control_task(void *context)
if (!current) {
break;
}
bool transport_drained = false;
if (console_drained &&
ssh_transport_get_snapshot(&s_control_ssh_snapshot) == ESP_OK) {
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
const ssh_transport_session_snapshot_t *slot =
&s_control_ssh_snapshot.sessions[index];
if (slot->active && slot->session_id == request.token.session_id) {
transport_drained = !slot->tx_pending;
break;
}
}
}
bool transport_drained = console_drained &&
request.owner->drained(&request.token);
if (console_drained && transport_drained) {
drained = true;
break;
@@ -650,27 +903,29 @@ static void control_task(void *context)
continue;
}
vTaskDelay(pdMS_TO_TICKS(200U));
esp_err_t result = ESP_OK;
switch (request.action) {
case ADMIN_SSH_DEFER_REBOOT:
esp_restart();
break;
case ADMIN_SSH_DEFER_STOP:
result = ssh_transport_stop();
break;
case ADMIN_SSH_DEFER_DISCONNECT:
result = ssh_transport_disconnect(request.argument);
break;
case ADMIN_SSH_DEFER_HOST_KEY_ROTATE:
result = ssh_transport_replace_host_key(false);
break;
case ADMIN_SSH_DEFER_HOST_KEY_RESET:
result = ssh_transport_replace_host_key(true);
break;
default:
result = ESP_ERR_NOT_SUPPORTED;
break;
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[request.token.slot_index];
bool current = token_matches(session, &request.token) &&
session->owner == request.owner && session->deferred_action_pending;
admin_request_t queued = {
.origin = ADMIN_REQUEST_DEFERRED,
.deferred = request,
};
if (current) queued.principal = session->principal;
taskEXIT_CRITICAL(&s_lock);
if (current && (request.owner->dispatcher_actions & (1U << request.action))) {
/* Nonblocking handoff: a full dispatcher queue fails before mutation.
* Pending remains set until execution completes, not merely enqueue. */
if (xQueueSend(s_request_queue, &queued, 0U) != pdTRUE) {
finish_deferred_request(&request, ESP_ERR_TIMEOUT, false);
}
secure_wipe(&queued, sizeof(queued));
secure_wipe(&request, sizeof(request));
continue;
}
secure_wipe(&queued, sizeof(queued));
esp_err_t result = current ? request.owner->perform(
&request.token, request.action, request.argument) : ESP_ERR_NOT_FOUND;
finish_deferred_request(&request, result, false);
secure_wipe(&request, sizeof(request));
}
@@ -750,7 +1005,7 @@ esp_err_t admin_ssh_console_register_commands(void)
{
const esp_console_cmd_t command = {
.command = "exit",
.help = "Close the current administrative SSH session",
.help = "Close the current administrative remote session",
.hint = NULL,
.func = &command_exit,
.argtable = NULL,
@@ -782,10 +1037,14 @@ esp_err_t admin_ssh_console_start_uart_frontend(void)
return ESP_OK;
}
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
static esp_err_t open_session(admin_ssh_console_token_t *token,
const user_principal_t *principal,
const admin_console_owner_t *owner, bool available)
{
if (!token_valid(token) || principal == NULL || principal->role != USER_ROLE_ADMIN) {
if (token == NULL || token->session_id == 0U || token->slot_generation == 0U ||
(!available && !token_valid(token)) || principal == NULL || principal->role != USER_ROLE_ADMIN ||
owner == NULL || owner->is_current == NULL ||
owner->drained == NULL || owner->perform == NULL) {
return ESP_ERR_INVALID_ARG;
}
taskENTER_CRITICAL(&s_lock);
@@ -799,26 +1058,59 @@ esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
return ESP_ERR_INVALID_STATE;
}
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
if (session->executing) {
size_t index = token->slot_index;
if (available) {
for (index = 0U; index < ADMIN_SSH_CONSOLE_MAX_SESSIONS; ++index) {
if (!s_sessions[index].active && !s_sessions[index].executing) {
break;
}
}
if (index == ADMIN_SSH_CONSOLE_MAX_SESSIONS) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
}
admin_session_t *session = &s_sessions[index];
if (session->active || session->executing) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
secure_wipe(session, sizeof(*session));
session->active = true;
session->history_position = -1;
token->slot_index = (uint8_t)index;
session->token = *token;
session->owner = owner;
session->principal = *principal;
static const char banner[] =
"ESP32 Serial Swiss Army Knife administrative SSH shell\r\n";
const char *banner = token->transport == 0U
? "ESP32 Serial Swiss Army Knife administrative SSH shell\r\n"
: "ESP32 Serial Swiss Army Knife administrative remote shell\r\n";
static const char prompt[] =
"Run 'help' for supported remote administrative commands.\r\nadmin@serial-tool> ";
(void)append_output_locked(session, (const uint8_t *)banner, sizeof(banner) - 1U);
(void)append_output_locked(session, (const uint8_t *)banner, strlen(banner));
(void)append_output_locked(session, (const uint8_t *)prompt, sizeof(prompt) - 1U);
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *token,
const user_principal_t *principal,
const admin_console_owner_t *owner)
{
return open_session(token, principal, owner, true);
}
esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
const user_principal_t *principal,
const admin_console_owner_t *owner)
{
if (token == NULL) {
return ESP_ERR_INVALID_ARG;
}
admin_ssh_console_token_t copy = *token;
return open_session(&copy, principal, owner, false);
}
void admin_ssh_console_close(const admin_ssh_console_token_t *token)
{
if (!token_valid(token)) {
@@ -829,8 +1121,10 @@ void admin_ssh_console_close(const admin_ssh_console_token_t *token)
bool matched = token_matches(session, token);
bool wake_prompt = false;
if (matched) {
if (session->prompt_state == ADMIN_PROMPT_WAITING) {
if (session->prompt_state != ADMIN_PROMPT_NONE) {
session->prompt_state = ADMIN_PROMPT_DISCONNECTED;
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
session->prompt_length = 0U;
wake_prompt = true;
}
session->active = false;
@@ -854,7 +1148,8 @@ bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token)
bool shell_input = !session->command_pending && !session->deferred_action_pending;
bool prompt_input = session->command_pending && session->executing &&
session->prompt_state == ADMIN_PROMPT_WAITING;
bool accepts = token_matches(session, token) && (shell_input || prompt_input) &&
bool accepts = token_matches(session, token) && !session->deferred_action_pending &&
(shell_input || prompt_input) &&
session->output_length <= ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY -
ADMIN_SSH_CONSOLE_RESPONSE_RESERVE;
taskEXIT_CRITICAL(&s_lock);
@@ -874,7 +1169,7 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
bool submit = false;
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
if (!token_matches(session, token)) {
if (!token_matches(session, token) || session->deferred_action_pending) {
taskEXIT_CRITICAL(&s_lock);
return *consumed != 0U;
}
@@ -992,6 +1287,10 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
continue;
}
if (value == '\t') {
if (s_completion_busy) {
taskEXIT_CRITICAL(&s_lock);
return *consumed != 0U;
}
if (session->input_cursor != session->input_length) {
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
++*consumed;
@@ -1000,6 +1299,7 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
}
char current[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
memcpy(current, session->input, sizeof(current));
s_completion_busy = true;
++*consumed;
taskEXIT_CRITICAL(&s_lock);
char completed[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U] = {0};
@@ -1032,6 +1332,8 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
}
}
secure_wipe(s_completion_output, sizeof(s_completion_output));
s_completion_busy = false;
taskEXIT_CRITICAL(&s_lock);
secure_wipe(current, sizeof(current));
secure_wipe(completed, sizeof(completed));
@@ -1137,8 +1439,10 @@ esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
first = ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_start;
}
memcpy(data, session->output + session->output_start, first);
secure_wipe(session->output + session->output_start, first);
if (copied > first) {
memcpy(data + first, session->output, copied - first);
secure_wipe(session->output, copied - first);
}
session->output_start = (session->output_start + copied) %
ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY;
@@ -1166,6 +1470,7 @@ esp_err_t admin_ssh_console_get_session_snapshot(
snapshot->command_pending = session->command_pending;
snapshot->input_pending = session->input_length != 0U;
snapshot->output_pending = session->output_length != 0U;
snapshot->deferred_action_pending = session->deferred_action_pending;
snapshot->input_length = session->input_length;
snapshot->output_length = session->output_length;
taskEXIT_CRITICAL(&s_lock);
+92 -4
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Bounded, transport-neutral administrative command worker for SSH sessions. */
/* Bounded administrative dispatcher with a small remote-owner boundary. */
#pragma once
@@ -14,13 +14,26 @@
extern "C" {
#endif
/* Nonblocking typed settings admission to the canonical dispatcher. */
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id);
esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id);
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
#define ADMIN_CONSOLE_TRANSPORT_SSH 0U
#define ADMIN_CONSOLE_TRANSPORT_WEB 1U
typedef struct {
uint8_t slot_index;
uint32_t session_id;
uint32_t slot_generation;
uint8_t transport; /* Zero is SSH, including legacy designated initializers. */
} admin_ssh_console_token_t;
typedef enum {
@@ -30,13 +43,78 @@ typedef enum {
ADMIN_SSH_DEFER_DISCONNECT,
ADMIN_SSH_DEFER_HOST_KEY_ROTATE,
ADMIN_SSH_DEFER_HOST_KEY_RESET,
ADMIN_CONSOLE_DEFER_SELF_CLOSE,
ADMIN_CONSOLE_DEFER_WEB_STOP,
ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
} admin_ssh_deferred_action_type_t;
/* Small owner boundary; module/API names are retained for existing SSH callers.
* Exactly two shared console slots, not two per transport. slot_index addresses
* this pool; open_available atomically selects a free slot. Owners must not reuse an identity while
* old work can exist. transport is a firmware-assigned namespace (0 = SSH).
* An occupied or still-executing slot cannot be replaced by open_owned().
*
* The immutable adapter lives for firmware lifetime. Callbacks run on the
* control task OUTSIDE console locks for drained/perform, except perform actions
* selected by dispatcher_actions run serialized on the existing 12KiB dispatcher
* after drain/delay and queued identity/principal revalidation (no command replay).
* Zero dispatcher_actions preserves legacy control-task execution. Required is_current
* runs on the dispatcher outside console locks; it must be bounded and validate
* full transport identity, originating-session liveness and principal binding,
* without calling socket libraries or handlers. Core separately checks accounts.
* drained must be nonblocking, validate the full identity and include pending
* owner output. perform must revalidate identity and marshal lifecycle work to
* its owner, never call socket libraries here. Neither callback may call console
* handlers. supported_actions is a bitmask (1U << action); reject unsupported
* actions before side effects. Legacy STOP/DISCONNECT/key actions mean SSH;
* SELF_CLOSE means this frontend; WEB_STOP means HTTPS, not SSH.
* WEB_CERTIFICATE_ROTATE replaces the HTTPS identity and restarts HTTPS.
* These WEB actions and SELF_CLOSE ignore argument.
*
* One owner serializes feed calls per session; different owners may feed in
* parallel. Shared completion scratch is nonblocking/serialized by the core.
* The owner alone consumes output, maintains authentication/session liveness,
* and calls close on disconnect/revocation. Core copies/rechecks principals at
* admission and dispatch. Dispatch and prompts also check owner currentness;
* blocked prompts recheck every 250ms (plus check/scheduling latency). This does
* not cancel or roll back arbitrary executing handlers. Admission remains the
* owner's responsibility; is_current need not accept unpublished admission.
* Close wakes prompts; executing state is retained until the handler returns.
* Output remains bounded (5s write backpressure); deferred work waits at most
* 10s for application drain plus 200ms, NOT peer-delivery confirmation.
* Dispatcher actions then wait behind queued commands/prompts, with input gated
* until completion or cancellation; the drain bound is not an execution deadline.
* No new tasks, queues, slots, or browser endpoint are provided by this API.
*/
typedef struct {
uint32_t supported_actions;
uint32_t dispatcher_actions; /* Subset of supported_actions; immutable. */
bool (*is_current)(const admin_ssh_console_token_t *token,
const user_principal_t *principal);
bool (*drained)(const admin_ssh_console_token_t *token);
esp_err_t (*perform)(const admin_ssh_console_token_t *token,
admin_ssh_deferred_action_type_t action, uint32_t argument);
} admin_console_owner_t;
/* Selects any inactive, nonexecuting slot from the shared two-slot pool.
* Input slot_index is ignored; only slot_index changes, and only on success.
* Caller supplies transport/session_id/slot_generation and must retain the
* returned token. Full pool returns ESP_ERR_INVALID_STATE, like open_owned.
*/
esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *token,
const user_principal_t *principal,
const admin_console_owner_t *owner);
esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
const user_principal_t *principal,
const admin_console_owner_t *owner);
typedef struct {
bool active;
bool command_pending;
bool input_pending;
bool output_pending;
bool deferred_action_pending;
size_t input_length;
size_t output_length;
} admin_ssh_console_session_snapshot_t;
@@ -50,25 +128,35 @@ esp_err_t admin_ssh_console_start_uart_frontend(void);
/* Valid only while a registered command callback runs on the dispatcher task. */
bool admin_ssh_console_dispatch_is_remote(void);
bool admin_ssh_console_dispatch_is_web(void);
const user_principal_t *admin_ssh_console_dispatch_principal(void);
/* Revalidate account, originating owner/session and token before side effects.
* False outside the dispatcher; UART0 dispatch remains physically trusted. */
bool admin_ssh_console_dispatch_is_current(void);
/* Shared parsed browser account policy: dispatcher admission + handler defense. */
bool admin_ssh_console_web_user_command_allowed(
size_t argc, char **argv, const user_principal_t *principal);
esp_err_t admin_ssh_console_dispatch_read_input(
const char *prompt, uint8_t *output, size_t capacity,
bool hidden, size_t *output_length);
esp_err_t admin_ssh_console_dispatch_defer(
admin_ssh_deferred_action_type_t action, uint32_t argument);
/* The token and principal are copied; no SSH or socket objects cross this boundary. */
/* SSH compatibility entry point, implemented by the owner in ssh_transport.c.
* Token/principal are copied; no SSH or socket objects cross this boundary.
* Existing feed/close/read/snapshot APIs below also accept open_owned tokens.
*/
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal);
void admin_ssh_console_close(const admin_ssh_console_token_t *token);
/* Called only by the SSH owner task. Returns false when input must be backpressured. */
/* Called by the session owner. Returns false when input must be backpressured. */
bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token);
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
const uint8_t *data, size_t length,
size_t *consumed);
/* Called only by the SSH owner task; copies already-produced output without blocking. */
/* Called by the session owner; copies already-produced output without blocking. */
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
uint8_t *data, size_t capacity,
size_t *received);
+8 -6
View File
@@ -126,8 +126,6 @@ static const char *const s_completion_candidates[] = {
"user status",
"user list",
"user show",
"user bootstrap",
"user bootstrap --generate",
"user recover --force",
"user add",
"user delete",
@@ -184,10 +182,14 @@ static const char *const s_completion_candidates[] = {
"web stop",
"web counters",
"web clear-counters",
"web credentials",
"web credentials show",
"web credentials rotate",
"web credentials rotate --force",
"web diagnostics enable",
"web diagnostics disable",
"web diagnostics show",
"web diagnostics clear",
"web performance enable",
"web performance disable",
"web performance show",
"web performance clear",
"web certificate",
"web certificate info",
"web certificate rotate",
+61 -13
View File
@@ -203,6 +203,8 @@ static TickType_t s_diagnostic_hold_started;
static uint32_t s_external_activity_sequence;
static local_ui_config_t s_config;
static bool s_config_available;
static bool s_config_busy;
static uint32_t s_config_generation;
static const gpio_num_t s_button_gpios[LOCAL_STATUS_BUTTON_COUNT] = {
LOCAL_UI_BUTTON_PREVIOUS_GPIO,
@@ -1368,21 +1370,62 @@ esp_err_t local_status_ui_get_config(local_ui_config_t *config)
return available ? ESP_OK : ESP_ERR_INVALID_STATE;
}
esp_err_t local_status_ui_get_settings(local_ui_config_t *config, uint32_t *generation)
{
if (config == NULL || generation == NULL) return ESP_ERR_INVALID_ARG;
portENTER_CRITICAL(&s_timing_mux);
esp_err_t error = !s_config_available ? ESP_ERR_INVALID_STATE :
s_config_busy ? ESP_ERR_TIMEOUT : ESP_OK;
if (error == ESP_OK) {
*config = s_config;
*generation = s_config_generation;
}
portEXIT_CRITICAL(&s_timing_mux);
return error;
}
esp_err_t local_status_ui_update_settings(local_ui_settings_action_t action,
uint32_t expected_generation, const local_ui_config_t *config, bool *loaded_defaults)
{
if (loaded_defaults != NULL) *loaded_defaults = false;
if (action < LOCAL_UI_SETTINGS_APPLY || action > LOCAL_UI_SETTINGS_RESET ||
(action == LOCAL_UI_SETTINGS_APPLY && local_ui_config_validate(config) != ESP_OK))
return ESP_ERR_INVALID_ARG;
local_ui_config_t candidate;
portENTER_CRITICAL(&s_timing_mux);
esp_err_t error = !s_config_available ||
(expected_generation && expected_generation != s_config_generation) ||
s_config_generation == UINT32_MAX ? ESP_ERR_INVALID_STATE :
s_config_busy ? ESP_ERR_TIMEOUT : ESP_OK;
if (error == ESP_OK) {
candidate = action == LOCAL_UI_SETTINGS_APPLY ? *config : s_config;
s_config_busy = true;
}
portEXIT_CRITICAL(&s_timing_mux);
if (error != ESP_OK) return error;
bool stored = true;
if (action == LOCAL_UI_SETTINGS_LOAD) error = local_ui_config_load(&candidate, &stored);
if (action == LOCAL_UI_SETTINGS_DEFAULTS || action == LOCAL_UI_SETTINGS_RESET)
local_ui_config_defaults(&candidate);
if (action == LOCAL_UI_SETTINGS_SAVE || action == LOCAL_UI_SETTINGS_RESET)
error = local_ui_config_save(&candidate);
portENTER_CRITICAL(&s_timing_mux);
if (error == ESP_OK && action != LOCAL_UI_SETTINGS_SAVE) {
s_config = candidate;
++s_config_generation;
++s_external_activity_sequence;
}
s_config_busy = false;
portEXIT_CRITICAL(&s_timing_mux);
if (error == ESP_OK && loaded_defaults != NULL) *loaded_defaults = !stored;
return error;
}
esp_err_t local_status_ui_apply_config(const local_ui_config_t *config)
{
esp_err_t error = local_ui_config_validate(config);
if (error != ESP_OK) {
return error;
}
portENTER_CRITICAL(&s_timing_mux);
if (!s_config_available) {
portEXIT_CRITICAL(&s_timing_mux);
return ESP_ERR_INVALID_STATE;
}
s_config = *config;
++s_external_activity_sequence;
portEXIT_CRITICAL(&s_timing_mux);
return ESP_OK;
return local_status_ui_update_settings(LOCAL_UI_SETTINGS_APPLY, 0, config, NULL);
}
esp_err_t local_status_ui_start(const local_ui_config_t *config)
@@ -1397,6 +1440,11 @@ esp_err_t local_status_ui_start(const local_ui_config_t *config)
portENTER_CRITICAL(&s_timing_mux);
s_config = *config;
if (s_config_generation == UINT32_MAX) {
portEXIT_CRITICAL(&s_timing_mux);
return ESP_ERR_INVALID_STATE;
}
++s_config_generation;
s_config_available = true;
portEXIT_CRITICAL(&s_timing_mux);
+16
View File
@@ -21,6 +21,22 @@ esp_err_t local_status_ui_start(const local_ui_config_t *config);
esp_err_t local_status_ui_get_config(local_ui_config_t *config);
esp_err_t local_status_ui_apply_config(const local_ui_config_t *config);
typedef enum {
LOCAL_UI_SETTINGS_APPLY, LOCAL_UI_SETTINGS_SAVE, LOCAL_UI_SETTINGS_LOAD,
LOCAL_UI_SETTINGS_DEFAULTS, LOCAL_UI_SETTINGS_RESET
} local_ui_settings_action_t;
/* Zero-wait RAM projection. Generation is nonzero and never wraps. */
esp_err_t local_status_ui_get_settings(local_ui_config_t *config, uint32_t *generation);
/* Reserve configuration across storage IO, without holding a critical section.
* Zero expected_generation is for canonical unconditional CLI operations only.
* Nonzero stale generations return ESP_ERR_INVALID_STATE; contention returns
* ESP_ERR_TIMEOUT. Load retains the canonical default fallback. Reset commits
* defaults before publishing RAM, so a storage failure needs no RAM rollback.
* No display IO occurs here; successful RAM changes signal renderer activity. */
esp_err_t local_status_ui_update_settings(local_ui_settings_action_t action,
uint32_t expected_generation, const local_ui_config_t *config, bool *loaded_defaults);
/* Preserve a manually selected display diagnostic for a bounded interval. */
void local_status_ui_hold_for_diagnostics(void);
+9 -24
View File
@@ -87,7 +87,8 @@ static int apply_parameter(const char *parameter, const char *text)
}
local_ui_config_t config;
esp_err_t error = local_status_ui_get_config(&config);
uint32_t generation;
esp_err_t error = local_status_ui_get_settings(&config, &generation);
if (error != ESP_OK) {
printf("Could not read local UI configuration: %s\n", esp_err_to_name(error));
return 1;
@@ -102,7 +103,7 @@ static int apply_parameter(const char *parameter, const char *text)
return 1;
}
error = local_status_ui_apply_config(&config);
error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_APPLY, generation, &config, NULL);
if (error != ESP_OK) {
printf("Invalid display configuration: %s. When both timeouts are enabled, off must be later than dim.\n",
esp_err_to_name(error));
@@ -122,11 +123,7 @@ static int command_display(int argc, char **argv)
return apply_parameter(argv[2], argv[3]);
}
if (argc == 2 && strcmp(argv[1], "save") == 0) {
local_ui_config_t config;
esp_err_t error = local_status_ui_get_config(&config);
if (error == ESP_OK) {
error = local_ui_config_save(&config);
}
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_SAVE, 0, NULL, NULL);
if (error != ESP_OK) {
printf("Could not save display configuration: %s\n", esp_err_to_name(error));
return 1;
@@ -136,17 +133,15 @@ static int command_display(int argc, char **argv)
}
if (argc == 2 && strcmp(argv[1], "load") == 0) {
local_ui_config_t config;
bool used_stored_config;
esp_err_t error = local_ui_config_load(&config, &used_stored_config);
if (error == ESP_OK) {
error = local_status_ui_apply_config(&config);
}
bool loaded_defaults = false;
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_LOAD, 0, NULL, &loaded_defaults);
if (error == ESP_OK) error = local_status_ui_get_config(&config);
if (error != ESP_OK) {
printf("Could not load display configuration: %s\n", esp_err_to_name(error));
return 1;
}
printf("Loaded %s display configuration.\n",
used_stored_config ? "stored" : "default");
loaded_defaults ? "default" : "stored");
print_config(&config);
return 0;
}
@@ -163,19 +158,9 @@ static int command_display(int argc, char **argv)
return 0;
}
if (argc == 2 && strcmp(argv[1], "reset") == 0) {
local_ui_config_t previous;
local_ui_config_t defaults;
local_ui_config_defaults(&defaults);
esp_err_t error = local_status_ui_get_config(&previous);
if (error == ESP_OK) {
error = local_status_ui_apply_config(&defaults);
}
if (error == ESP_OK) {
error = local_ui_config_reset_storage();
if (error != ESP_OK) {
(void)local_status_ui_apply_config(&previous);
}
}
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_RESET, 0, NULL, NULL);
if (error != ESP_OK) {
printf("Could not reset display configuration: %s\n", esp_err_to_name(error));
return 1;
+10 -27
View File
@@ -1,5 +1,3 @@
#include <string.h>
#include "driver/uart.h"
#include "admin_ssh_console.h"
#include "console_completion.h"
@@ -132,29 +130,16 @@ void app_main(void)
"HTTPS security material unavailable (%s); use UART0 'web reset --force' to replace it",
esp_err_to_name(web_security_error));
} else {
ESP_LOGI(TAG, "Using %s HTTPS identity and legacy recovery credential",
web_security_source == WEB_SECURITY_LOAD_STORED ? "stored" : "newly generated");
ESP_LOGI(TAG, "Using %s HTTPS identity",
web_security_source == WEB_SECURITY_LOAD_STORED
? "stored"
: (web_security_source == WEB_SECURITY_LOAD_MIGRATED_V1
? "migrated v1"
: "newly generated"));
}
user_database_load_result_t user_database_source = USER_DATABASE_LOAD_EMPTY;
web_security_credentials_t legacy_credentials;
memset(&legacy_credentials, 0, sizeof(legacy_credentials));
user_database_legacy_credentials_t legacy = {0};
const user_database_legacy_credentials_t *legacy_pointer = NULL;
if (web_security_error == ESP_OK &&
web_security_show_credentials(&legacy_credentials) == ESP_OK) {
legacy = (user_database_legacy_credentials_t){
.username = (const uint8_t *)legacy_credentials.username,
.username_length = legacy_credentials.username_length,
.password = (const uint8_t *)legacy_credentials.password,
.password_length = legacy_credentials.password_length,
};
legacy_pointer = &legacy;
}
esp_err_t user_database_error =
user_database_init(legacy_pointer, &user_database_source);
secure_wipe(&legacy_credentials, sizeof(legacy_credentials));
secure_wipe(&legacy, sizeof(legacy));
esp_err_t user_database_error = user_database_init(&user_database_source);
if (user_database_error != ESP_OK) {
ESP_LOGE(TAG, "User database unavailable: %s; HTTPS and SSH authentication will fail closed; use UART0 'user recover --force'",
esp_err_to_name(user_database_error));
@@ -162,9 +147,7 @@ void app_main(void)
ESP_LOGI(TAG, "Using %s user database",
user_database_source == USER_DATABASE_LOAD_STORED
? "stored"
: (user_database_source == USER_DATABASE_LOAD_MIGRATED_LEGACY
? "newly migrated user-level"
: "new empty"));
: "new empty");
}
esp_err_t web_runtime_error = web_server_init();
@@ -267,8 +250,8 @@ void app_main(void)
ESP_LOGI(TAG, "Authenticated HTTPS listening on TCP port 443");
}
}
if (wifi_error == ESP_OK && web_security_error == ESP_OK &&
ssh_security_error == ESP_OK && ssh_runtime_error == ESP_OK) {
if (wifi_error == ESP_OK && ssh_security_error == ESP_OK &&
ssh_runtime_error == ESP_OK) {
esp_err_t start_error = ssh_transport_start();
if (start_error != ESP_OK) {
ESP_LOGE(TAG, "SSH startup failed: %s; UART0 recovery remains available",
+51 -6
View File
@@ -12,6 +12,7 @@
static SemaphoreHandle_t s_mutex;
static mdns_config_t s_config;
static uint32_t s_config_generation;
static bool s_component_initialized;
static bool s_initialization_failed;
static bool s_announced;
@@ -45,6 +46,7 @@ esp_err_t mdns_service_init(const mdns_config_t *config)
return ESP_ERR_NO_MEM;
}
s_config = *config;
s_config_generation = 1;
s_last_error = ESP_OK;
return ESP_OK;
}
@@ -66,27 +68,68 @@ esp_err_t mdns_service_set_config(const mdns_config_t *config)
return ESP_ERR_INVALID_ARG;
}
lock_service();
if (s_config_generation == UINT32_MAX) { unlock_service(); return ESP_ERR_INVALID_STATE; }
s_config = *config;
++s_config_generation;
unlock_service();
return ESP_OK;
}
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot)
static void snapshot_locked(mdns_service_snapshot_t *snapshot)
{
if (snapshot == NULL || s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
lock_service();
memset(snapshot, 0, sizeof(*snapshot));
snapshot->config_generation = s_config_generation;
snapshot->initialized = true;
snapshot->announced = s_announced;
memcpy(snapshot->suffix, s_config.suffix, s_config.suffix_len);
make_hostname(&s_config, snapshot->hostname, sizeof(snapshot->hostname));
snapshot->last_error = s_last_error;
}
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot)
{
if (!snapshot || !s_mutex) return ESP_ERR_INVALID_STATE;
lock_service();
snapshot_locked(snapshot);
unlock_service();
return ESP_OK;
}
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *snapshot)
{
if (!snapshot) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (!s_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
snapshot_locked(snapshot);
unlock_service();
return ESP_OK;
}
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
const mdns_config_t *config, bool *stored)
{
if (!stored || action > MDNS_SETTINGS_DEFAULTS || action < MDNS_SETTINGS_SET ||
(action == MDNS_SETTINGS_SET && mdns_config_validate(config) != ESP_OK)) return ESP_ERR_INVALID_ARG;
*stored = true;
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_service();
if (!generation || generation != s_config_generation) { unlock_service(); return ESP_ERR_NOT_FOUND; }
esp_err_t error = ESP_OK;
mdns_config_t candidate = s_config;
if (action == MDNS_SETTINGS_SAVE) error = mdns_config_save(&s_config);
else if (s_config_generation == UINT32_MAX) error = ESP_ERR_INVALID_STATE;
else {
if (action == MDNS_SETTINGS_SET) candidate = *config;
else if (action == MDNS_SETTINGS_LOAD) error = mdns_config_load(&candidate, stored);
else mdns_config_defaults(&candidate);
if (error == ESP_OK) error = mdns_config_validate(&candidate);
if (error == ESP_OK) { s_config = candidate; ++s_config_generation; }
}
unlock_service();
return error;
}
esp_err_t mdns_service_start(void)
{
if (s_mutex == NULL) {
@@ -96,7 +139,9 @@ esp_err_t mdns_service_start(void)
if (s_component_initialized) {
s_announced = true;
unlock_service();
return ESP_OK;
/* A suffix staged while offline must reach the already-created responder
* when the next STA IP arrives, even if its reannounce command ran offline. */
return mdns_service_reannounce();
}
if (s_initialization_failed) {
esp_err_t error = s_last_error;
+11
View File
@@ -9,6 +9,7 @@
#include "mdns_config.h"
typedef struct {
uint32_t config_generation;
bool initialized;
bool announced;
char suffix[MDNS_CONFIG_SUFFIX_MAX_LEN + 1U];
@@ -21,6 +22,16 @@ esp_err_t mdns_service_get_config(mdns_config_t *config);
esp_err_t mdns_service_set_config(const mdns_config_t *config);
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot);
/* Zero-wait secret-free projection for HTTPD; ESP_ERR_TIMEOUT on contention. */
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *snapshot);
typedef enum { MDNS_SETTINGS_SET, MDNS_SETTINGS_SAVE, MDNS_SETTINGS_LOAD,
MDNS_SETTINGS_DEFAULTS } mdns_settings_action_t;
/* Dispatcher-only. Check generation and mutate/persist under the service mutex.
* ESP_ERR_NOT_FOUND is stale. LOAD may select deterministic MAC defaults (stored
* reports that distinction). Caller separately queues manager reannouncement. */
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
const mdns_config_t *config, bool *stored);
/* Only wifi_manager may call these lifecycle operations. */
esp_err_t mdns_service_start(void);
void mdns_service_stop(void);
+12
View File
@@ -646,6 +646,18 @@ esp_err_t serial_service_get_config(serial_config_t *config)
return ESP_OK;
}
esp_err_t serial_service_get_snapshot(serial_service_snapshot_t *snapshot)
{
if (snapshot == NULL) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (!s_initialized) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_state_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
snapshot->config = s_config;
snapshot->running = atomic_load(&s_running);
xSemaphoreGive(s_state_mutex);
return ESP_OK;
}
size_t serial_service_read(uint8_t *data, size_t size)
{
if (!s_initialized || data == NULL || size == 0) {
+8
View File
@@ -48,6 +48,14 @@ bool serial_service_is_running(void);
esp_err_t serial_service_apply_config(const serial_config_t *config);
esp_err_t serial_service_get_config(serial_config_t *config);
typedef struct {
serial_config_t config;
bool running;
} serial_service_snapshot_t;
/* Nonblocking, consistent working configuration/state; no hardware or NVS IO. */
esp_err_t serial_service_get_snapshot(serial_service_snapshot_t *snapshot);
/*
* Access is intentionally nonblocking. The session broker is the sole
* logical RX consumer and TX producer; calls are serialized internally to
+58 -5
View File
@@ -47,6 +47,8 @@ static session_broker_slot_t s_slots[SESSION_BROKER_MAX_CLIENTS];
static session_broker_client_id_t s_writer_id;
static uint32_t s_connected_clients;
static uint64_t s_event_sequence;
/* Saturation disables management confirmations, never ordinary recovery. */
static uint32_t s_writer_generation = 1U;
static session_broker_global_counters_t s_counters;
static bool s_initialized;
@@ -102,6 +104,10 @@ static void broadcast_event_locked(session_broker_event_type_t type,
session_broker_client_id_t client_id,
session_broker_client_id_t writer_id)
{
if ((type == SESSION_BROKER_EVENT_WRITER_GRANTED ||
type == SESSION_BROKER_EVENT_WRITER_RELEASED ||
type == SESSION_BROKER_EVENT_WRITER_REVOKED) && s_writer_generation != UINT32_MAX)
++s_writer_generation;
session_broker_event_t event = {
.sequence = ++s_event_sequence,
.type = type,
@@ -149,6 +155,10 @@ static void fan_out_rx_locked(const uint8_t *data, size_t size)
slot->counters.uart_rx_bytes += size;
size_t queued = xStreamBufferSend(slot->output, data, size, 0);
size_t pending = xStreamBufferBytesAvailable(slot->output);
if (pending > slot->counters.output_high_water_bytes) {
slot->counters.output_high_water_bytes = pending;
}
size_t dropped = size - queued;
slot->counters.output_queued_bytes += queued;
slot->counters.output_dropped_bytes += dropped;
@@ -295,7 +305,8 @@ esp_err_t session_broker_connect(session_broker_client_type_t type,
session_broker_slot_t *slot = NULL;
size_t slot_index = 0U;
for (; slot_index < SESSION_BROKER_MAX_CLIENTS; ++slot_index) {
if (!s_slots[slot_index].connected) {
if (!s_slots[slot_index].connected &&
s_slots[slot_index].generation < SESSION_BROKER_MAX_GENERATION) {
slot = &s_slots[slot_index];
break;
}
@@ -315,9 +326,7 @@ esp_err_t session_broker_connect(session_broker_client_type_t type,
}
uint32_t generation = slot->generation + 1U;
if (generation == 0U || generation > SESSION_BROKER_MAX_GENERATION) {
generation = 1U;
}
/* Exhausted slots are retired until reboot: no 29-bit ID reuse. */
xStreamBufferReset(slot->output);
xQueueReset(slot->events);
@@ -466,13 +475,19 @@ esp_err_t session_broker_release_writer(session_broker_client_id_t client_id)
return ESP_OK;
}
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
static esp_err_t broker_force_writer(session_broker_client_id_t client_id,
uint32_t expected_generation)
{
if (!s_initialized) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
if (expected_generation && (expected_generation == UINT32_MAX ||
expected_generation != s_writer_generation)) {
xSemaphoreGive(s_mutex);
return ESP_ERR_INVALID_STATE;
}
session_broker_slot_t *new_writer = NULL;
if (client_id != SESSION_BROKER_NO_CLIENT) {
new_writer = find_slot_locked(client_id);
@@ -518,6 +533,41 @@ esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
return ESP_OK;
}
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
{
return broker_force_writer(client_id, 0);
}
esp_err_t session_broker_assign_writer_current(session_broker_client_id_t client_id,
uint32_t generation)
{
if (!client_id || !generation) return ESP_ERR_INVALID_ARG;
return broker_force_writer(client_id, generation);
}
esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *snapshot)
{
if (!snapshot) return ESP_ERR_INVALID_ARG;
if (!s_initialized) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
memset(snapshot, 0, sizeof(*snapshot));
snapshot->generation = s_writer_generation;
snapshot->writer_id = s_writer_id;
for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) {
const session_broker_slot_t *slot = &s_slots[i];
if (!slot->connected) continue;
session_broker_management_client_t *client = &snapshot->clients[snapshot->count++];
client->id = slot->id;
client->type = slot->type;
memcpy(client->name, slot->name, sizeof(client->name));
client->pending = xStreamBufferBytesAvailable(slot->output);
client->high_water = slot->counters.output_high_water_bytes;
client->dropped = slot->counters.output_dropped_bytes;
}
xSemaphoreGive(s_mutex);
return ESP_OK;
}
esp_err_t session_broker_force_release_writer(
session_broker_client_id_t expected_writer_id)
{
@@ -740,6 +790,8 @@ esp_err_t session_broker_clear_counters(void)
memset(&s_counters, 0, sizeof(s_counters));
for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) {
memset(&s_slots[i].counters, 0, sizeof(s_slots[i].counters));
s_slots[i].counters.output_high_water_bytes =
xStreamBufferBytesAvailable(s_slots[i].output);
}
xSemaphoreGive(s_mutex);
return ESP_OK;
@@ -758,6 +810,7 @@ esp_err_t session_broker_clear_client_counters(session_broker_client_id_t client
return ESP_ERR_NOT_FOUND;
}
memset(&slot->counters, 0, sizeof(slot->counters));
slot->counters.output_high_water_bytes = xStreamBufferBytesAvailable(slot->output);
xSemaphoreGive(s_mutex);
return ESP_OK;
}
+39 -2
View File
@@ -49,6 +49,11 @@ typedef struct {
session_broker_client_id_t writer_id;
} session_broker_event_t;
/*
* Active-client counters cover this connection (or the last counter clear).
* They become unavailable on disconnect and reset on slot/generation reuse;
* global totals retain disconnected clients' traffic until explicitly cleared.
*/
typedef struct {
/* UART bytes considered for delivery while this client was connected. */
uint64_t uart_rx_bytes;
@@ -67,13 +72,18 @@ typedef struct {
uint64_t events_queued;
uint64_t events_popped;
uint64_t event_drops;
/* Peak output occupancy, <= SESSION_BROKER_OUTPUT_SIZE; clear seeds pending. */
size_t output_high_water_bytes;
} session_broker_client_counters_t;
typedef struct {
uint64_t uart_rx_bytes;
/* UART RX drained when there were no connected observers. */
uint64_t unobserved_rx_bytes;
/* Queue/read/drop totals count one copy per client observer. */
/* Queue/read/drop totals count one copy per client observer.
* Drops include full-buffer losses and unread output discarded on disconnect.
* Read means handed to a transport, not confirmed delivery to its peer.
*/
uint64_t output_queued_bytes;
uint64_t output_read_bytes;
uint64_t output_dropped_bytes;
@@ -110,6 +120,31 @@ typedef struct {
session_broker_global_counters_t counters;
} session_broker_global_snapshot_t;
/* Compact, atomic, non-consuming management projection. No transport pointers. */
typedef struct {
session_broker_client_id_t id;
session_broker_client_type_t type;
char name[SESSION_BROKER_CLIENT_NAME_MAX + 1U];
size_t pending, high_water;
uint64_t dropped;
} session_broker_management_client_t;
typedef struct {
uint32_t generation;
session_broker_client_id_t writer_id;
size_t count;
session_broker_management_client_t clients[SESSION_BROKER_MAX_CLIENTS];
} session_broker_management_snapshot_t;
/* Zero-wait atomic snapshot. Generation survives counter clears; UINT32_MAX
* means confirmations exhausted until reboot. Every lease transition advances
* it, including release/reacquire ABA. Client IDs never wrap within a boot. */
esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *snapshot);
/* Nonzero target and generation required; compare + target validation + transfer
* share the broker lock. Stale/exhausted generation or absent target has no effects.
* Existing unconditional force remains available to recovery/console callers. */
esp_err_t session_broker_assign_writer_current(session_broker_client_id_t client_id,
uint32_t generation);
/*
* Allocates all eight output streams and event queues, then starts the
* permanent broker task. The serial service must already be initialized
@@ -158,7 +193,9 @@ esp_err_t session_broker_get_global_snapshot(session_broker_global_snapshot_t *s
size_t session_broker_list_clients(session_broker_client_snapshot_t *clients,
size_t capacity);
/* Counter clearing does not reset client IDs, queued data, or event sequence. */
/* Counter clearing does not reset client IDs, queued data, or event sequence.
* Client output high-water marks restart at current queued occupancy, not zero.
*/
esp_err_t session_broker_clear_counters(void);
esp_err_t session_broker_clear_client_counters(session_broker_client_id_t client_id);
+20
View File
@@ -182,6 +182,26 @@ static int show_counters(void)
counter->events_queued,
counter->events_popped,
counter->event_drops);
session_broker_client_snapshot_t clients[SESSION_BROKER_MAX_CLIENTS];
size_t count = session_broker_list_clients(clients, SESSION_BROKER_MAX_CLIENTS);
printf("Active clients (since connect/clear; lost on disconnect; global totals retained):\n");
printf("Output bytes: HWM <= %u; clear seeds pending; read = handed to transport.\n",
(unsigned int)SESSION_BROKER_OUTPUT_SIZE);
printf("Global dropped also includes unread output discarded on disconnect.\n");
printf("ID type pending HWM UART queued read dropped\n");
for (size_t index = 0; index < count; ++index) {
const session_broker_client_snapshot_t *client = &clients[index];
printf("%-10lu %-9s %-7u %-7u %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64 "\n",
(unsigned long)client->id,
client_type_name(client->type),
(unsigned int)client->output_bytes_pending,
(unsigned int)client->counters.output_high_water_bytes,
client->counters.uart_rx_bytes,
client->counters.output_queued_bytes,
client->counters.output_read_bytes,
client->counters.output_dropped_bytes);
}
return 0;
}
+83 -45
View File
@@ -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); }
+17 -1
View File
@@ -3,6 +3,7 @@
#pragma once
#include <stdbool.h>
#include <stddef.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);
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);
+249 -37
View File
@@ -12,6 +12,7 @@
#include "admin_ssh_console.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
@@ -65,6 +66,7 @@ typedef struct {
bool pending_principal_valid;
bool authenticated;
bool shell_requested;
uint8_t console_slot_index;
uint8_t authentication_attempts;
word32 io_read_budget;
bool writer;
@@ -86,6 +88,9 @@ static ssh_slot_t s_slots[SSH_TRANSPORT_MAX_SESSIONS];
static ssh_transport_session_snapshot_t
s_session_snapshots[SSH_TRANSPORT_MAX_SESSIONS];
static uint32_t s_external_close_id[SSH_TRANSPORT_MAX_SESSIONS];
/* Published with snapshots; dispatcher never reads owner-task slot storage. */
static user_principal_t s_console_principals[SSH_TRANSPORT_MAX_SESSIONS];
static uint8_t s_console_slot_indices[SSH_TRANSPORT_MAX_SESSIONS];
static ssh_transport_counters_t s_counters;
static SemaphoreHandle_t s_command_mutex;
static bool s_initializing;
@@ -95,6 +100,8 @@ static bool s_running;
static bool s_transitioning;
static bool s_desired_running;
static bool s_cleanup_pending;
/* Saturates independently of the internal completion sequence; never reset by counters. */
static uint32_t s_management_generation = 1U;
static uint32_t s_requested_sequence;
static uint32_t s_completed_sequence;
static esp_err_t s_command_result = ESP_ERR_INVALID_STATE;
@@ -131,8 +138,9 @@ static void notify_task(void)
static admin_ssh_console_token_t admin_console_token(const ssh_slot_t *slot,
size_t slot_index)
{
(void)slot_index; /* Physical SSH index is not the shared console index. */
return (admin_ssh_console_token_t){
.slot_index = (uint8_t)slot_index,
.slot_index = slot->console_slot_index,
.session_id = slot->session_id,
.slot_generation = slot->generation,
};
@@ -176,14 +184,132 @@ static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
taskENTER_CRITICAL(&s_lock);
s_session_snapshots[slot_index] = snapshot;
s_console_slot_indices[slot_index] =
snapshot.active && slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE
? slot->console_slot_index : UINT8_MAX;
if (slot->principal_valid) {
s_console_principals[slot_index] = slot->principal;
} else {
secure_wipe(&s_console_principals[slot_index], sizeof(user_principal_t));
}
taskEXIT_CRITICAL(&s_lock);
}
/* Caller holds s_lock. Match session identity first, then the assigned console
* binding; callbacks must never index physical SSH storage by console slot.
*/
static size_t admin_console_snapshot_index_locked(const admin_ssh_console_token_t *token)
{
if (token == NULL || token->transport != ADMIN_CONSOLE_TRANSPORT_SSH ||
token->session_id == 0U || token->slot_generation == 0U) {
return SSH_TRANSPORT_MAX_SESSIONS;
}
for (size_t i = 0U; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
const ssh_transport_session_snapshot_t *slot = &s_session_snapshots[i];
if (slot->active && slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE &&
slot->session_id == token->session_id &&
slot->generation == token->slot_generation &&
s_console_slot_indices[i] != UINT8_MAX &&
s_console_slot_indices[i] == token->slot_index) {
return i;
}
}
return SSH_TRANSPORT_MAX_SESSIONS;
}
/* Dispatcher/control adapters: published state only, no runtime wolfSSH calls. */
static bool admin_console_is_current(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
if (principal == NULL) {
return false;
}
taskENTER_CRITICAL(&s_lock);
size_t index = admin_console_snapshot_index_locked(token);
if (index == SSH_TRANSPORT_MAX_SESSIONS) {
taskEXIT_CRITICAL(&s_lock);
return false;
}
const ssh_transport_session_snapshot_t *slot = &s_session_snapshots[index];
const user_principal_t *bound = &s_console_principals[index];
bool current = slot->active && slot->authenticated && slot->principal_valid &&
slot->state == SSH_TRANSPORT_SESSION_ACTIVE &&
slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE && !slot->close_requested &&
s_external_close_id[index] != token->session_id &&
slot->session_id == token->session_id && slot->generation == token->slot_generation &&
bound->user_id == principal->user_id && bound->auth_generation == principal->auth_generation &&
bound->role == USER_ROLE_ADMIN && bound->role == principal->role &&
bound->method == principal->method && bound->username_length == principal->username_length &&
bound->username_length <= USER_DATABASE_USERNAME_CAPACITY &&
memcmp(bound->username, principal->username, bound->username_length) == 0;
taskEXIT_CRITICAL(&s_lock);
return current;
}
static bool admin_console_drained(const admin_ssh_console_token_t *token)
{
taskENTER_CRITICAL(&s_lock);
size_t index = admin_console_snapshot_index_locked(token);
bool drained = index < SSH_TRANSPORT_MAX_SESSIONS &&
!s_session_snapshots[index].tx_pending;
taskEXIT_CRITICAL(&s_lock);
return drained;
}
static esp_err_t admin_console_perform(const admin_ssh_console_token_t *token,
admin_ssh_deferred_action_type_t action,
uint32_t argument)
{
if (!admin_console_drained(token)) {
return ESP_ERR_NOT_FOUND;
}
switch (action) {
case ADMIN_SSH_DEFER_REBOOT:
esp_restart();
return ESP_OK;
case ADMIN_SSH_DEFER_STOP:
return ssh_transport_stop();
case ADMIN_CONSOLE_DEFER_SELF_CLOSE:
return ssh_transport_disconnect(token->session_id);
case ADMIN_SSH_DEFER_DISCONNECT:
return ssh_transport_disconnect(argument);
case ADMIN_SSH_DEFER_HOST_KEY_ROTATE:
return ssh_transport_replace_host_key(false);
case ADMIN_SSH_DEFER_HOST_KEY_RESET:
return ssh_transport_replace_host_key(true);
default:
return ESP_ERR_NOT_SUPPORTED;
}
}
static const admin_console_owner_t s_admin_console_owner = {
.supported_actions = (1U << ADMIN_SSH_DEFER_REBOOT) |
(1U << ADMIN_SSH_DEFER_STOP) | (1U << ADMIN_SSH_DEFER_DISCONNECT) |
(1U << ADMIN_SSH_DEFER_HOST_KEY_ROTATE) |
(1U << ADMIN_SSH_DEFER_HOST_KEY_RESET) | (1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE),
.drained = admin_console_drained,
.is_current = admin_console_is_current,
.perform = admin_console_perform,
};
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
if (token == NULL || token->transport != ADMIN_CONSOLE_TRANSPORT_SSH) {
return ESP_ERR_INVALID_ARG;
}
return admin_ssh_console_open_owned(token, principal, &s_admin_console_owner);
}
static bool consume_external_close(const ssh_slot_t *slot, size_t slot_index)
{
taskENTER_CRITICAL(&s_lock);
bool requested = s_external_close_id[slot_index] != 0U &&
s_external_close_id[slot_index] == slot->session_id;
if (requested) {
/* Keep close intent visible while the owner begins cleanup. */
s_session_snapshots[slot_index].close_requested = true;
}
if (requested || slot->state == SSH_TRANSPORT_SESSION_FREE) {
s_external_close_id[slot_index] = 0U;
}
@@ -581,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();
@@ -626,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;
}
@@ -713,7 +844,8 @@ static ssh_slot_t *find_free_slot(size_t *slot_index)
}
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
if (s_slots[index].state == SSH_TRANSPORT_SESSION_FREE) {
if (s_slots[index].state == SSH_TRANSPORT_SESSION_FREE &&
s_slots[index].generation < SSH_TRANSPORT_GENERATION_MAX) {
*slot_index = index;
return &s_slots[index];
}
@@ -783,7 +915,8 @@ static void accept_connections(void)
(void)setsockopt(socket_fd, IPPROTO_TCP, TCP_NODELAY,
&enabled, sizeof(enabled));
uint32_t generation = next_generation(slot->generation);
/* Exhausted slots are retired by find_free_slot(), never reused after wrap. */
uint32_t generation = slot->generation + 1U;
memset(slot, 0, sizeof(*slot));
slot->state = SSH_TRANSPORT_SESSION_HANDSHAKE;
slot->generation = generation;
@@ -924,12 +1057,14 @@ static void process_handshake(ssh_slot_t *slot, size_t slot_index)
slot->route = SSH_TRANSPORT_ROUTE_BROKER;
} else if (slot->principal.role == USER_ROLE_ADMIN) {
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
error = admin_ssh_console_open(&token, &slot->principal);
error = admin_ssh_console_open_available(&token, &slot->principal,
&s_admin_console_owner);
if (error != ESP_OK) {
add_counter(&s_counters.admin_console_admission_failures, 1U);
request_slot_close(slot, false);
return;
}
slot->console_slot_index = token.slot_index;
slot->route = SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
add_counter(&s_counters.admin_console_admissions, 1U);
} else {
@@ -1263,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);
}
}
}
@@ -1386,6 +1529,7 @@ static esp_err_t request_running_locked(bool desired)
return ESP_OK;
}
s_desired_running = desired;
if (s_management_generation != UINT32_MAX) ++s_management_generation;
s_transitioning = true;
s_requested_sequence = next_generation(s_requested_sequence);
sequence = s_requested_sequence;
@@ -1432,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) {
@@ -1507,6 +1659,66 @@ esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot)
return ESP_OK;
}
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *snapshot)
{
if (snapshot == NULL) return ESP_ERR_INVALID_ARG;
taskENTER_CRITICAL(&s_lock);
if (!s_initialized) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
memset(snapshot, 0, sizeof(*snapshot));
snapshot->generation = s_management_generation;
snapshot->running = s_running;
snapshot->transitioning = s_transitioning || s_cleanup_pending;
for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
snapshot->sessions[i] = s_session_snapshots[i];
snapshot->sessions[i].close_requested |=
snapshot->sessions[i].active && s_external_close_id[i] == snapshot->sessions[i].session_id;
}
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action,
uint32_t target, uint32_t generation)
{
if (!generation || generation == UINT32_MAX ||
action < SSH_TRANSPORT_MANAGE_START || action > SSH_TRANSPORT_MANAGE_DISCONNECT ||
((action == SSH_TRANSPORT_MANAGE_DISCONNECT) != (target != 0U))) return ESP_ERR_INVALID_ARG;
if (s_command_mutex == NULL) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_command_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
esp_err_t error = ESP_ERR_INVALID_STATE;
taskENTER_CRITICAL(&s_lock);
if (s_initialized && !s_transitioning && !s_cleanup_pending &&
generation == s_management_generation) {
if (action == SSH_TRANSPORT_MANAGE_DISCONNECT) {
error = ESP_ERR_NOT_FOUND;
for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
const ssh_transport_session_snapshot_t *session = &s_session_snapshots[i];
if (session->active && session->session_id == target &&
!session->close_requested && session->state != SSH_TRANSPORT_SESSION_CLOSING &&
s_external_close_id[i] != target) {
s_external_close_id[i] = target;
error = ESP_OK;
break;
}
}
} else if (s_running != (action == SSH_TRANSPORT_MANAGE_START)) {
error = ESP_OK;
}
}
taskEXIT_CRITICAL(&s_lock);
/* The command mutex spans comparison and canonical lifecycle admission.
* No HTTPD work/lock is involved; only the SSH owner touches sockets/wolfSSH. */
if (error == ESP_OK) {
if (action == SSH_TRANSPORT_MANAGE_DISCONNECT) notify_task();
else error = request_running_locked(action == SSH_TRANSPORT_MANAGE_START);
}
xSemaphoreGive(s_command_mutex);
return error;
}
esp_err_t ssh_transport_clear_counters(void)
{
taskENTER_CRITICAL(&s_lock);
+28
View File
@@ -99,11 +99,39 @@ typedef struct {
ssh_transport_counters_t counters;
} ssh_transport_snapshot_t;
typedef enum {
SSH_TRANSPORT_MANAGE_START = 0,
SSH_TRANSPORT_MANAGE_STOP,
SSH_TRANSPORT_MANAGE_DISCONNECT,
} ssh_transport_management_action_t;
typedef struct {
uint32_t generation;
bool running;
bool transitioning;
ssh_transport_session_snapshot_t sessions[SSH_TRANSPORT_MAX_SESSIONS];
} ssh_transport_management_snapshot_t;
/* Compact published state only; no wolfSSH calls or task-stack scan. */
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *snapshot);
/* Dispatcher-only conditional admission; success on disconnect means owner notified,
* not peer receipt/cleanup. Lifecycle timeout does not cancel admitted work. */
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action,
uint32_t target, uint32_t generation);
/* Installs wolfCrypt RNG/PSRAM hooks and starts the sole wolfSSH owner task. */
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);
+1 -1
View File
@@ -54,7 +54,7 @@ static int command_reboot(int argc, char **argv)
printf("Could not schedule reboot: %s\n", esp_err_to_name(error));
return 1;
}
printf("Reboot scheduled after SSH output drains; unsaved changes will be lost.\n");
printf("Reboot scheduled after console output drains; unsaved changes will be lost.\n");
return 0;
}
printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n");
+31 -61
View File
@@ -15,7 +15,6 @@
#include "secure_random.h"
#include "ssh_transport.h"
#include "user_database.h"
#include "web_security.h"
#include "web_serial_transport.h"
#define USER_CONSOLE_KEY_LINE_CAPACITY 256U
@@ -28,7 +27,6 @@ static void print_usage(void)
printf("Usage:\n");
printf(" user status|list\n");
printf(" user show <username>\n");
printf(" user bootstrap [--generate]\n");
printf(" user recover --force\n");
printf(" user add <username> <user|admin> [--generate]\n");
printf(" user delete <username> --force\n");
@@ -100,12 +98,11 @@ static int show_users(const char *selected)
return 1;
}
if (selected == NULL) {
printf("User database: generation=%lu users=%u/%u admins=%u bootstrapped=%s\n",
printf("User database: generation=%lu users=%u/%u admins=%u\n",
(unsigned long)s_user_snapshot.generation,
(unsigned int)s_user_snapshot.user_count,
USER_DATABASE_MAX_USERS,
(unsigned int)s_user_snapshot.admin_count,
s_user_snapshot.admin_bootstrapped ? "yes" : "no");
(unsigned int)s_user_snapshot.admin_count);
}
bool found = false;
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) {
@@ -123,8 +120,8 @@ static int show_users(const char *selected)
printf("User '%s' not found.\n", selected);
return 1;
}
if (!s_user_snapshot.admin_bootstrapped) {
printf("Administrative network access is not bootstrapped; use 'user bootstrap'.\n");
if (s_user_snapshot.admin_count == 0U) {
printf("No administrators; use 'user add <username> admin' on UART0.\n");
}
return 0;
}
@@ -170,53 +167,23 @@ static void show_generated_password(const char *username,
static int recover_database(void)
{
web_security_credentials_t credentials;
memset(&credentials, 0, sizeof(credentials));
esp_err_t error = web_security_show_credentials(&credentials);
if (error == ESP_OK) {
const user_database_legacy_credentials_t legacy = {
.username = (const uint8_t *)credentials.username,
.username_length = credentials.username_length,
.password = (const uint8_t *)credentials.password,
.password_length = credentials.password_length,
};
error = user_database_recover_from_legacy(&legacy);
}
secure_wipe(&credentials, sizeof(credentials));
esp_err_t error = user_database_recover_empty();
if (error != ESP_OK) {
printf("Could not recover user database: %s\n", esp_err_to_name(error));
return 1;
}
printf("User database replaced from the current legacy network credential.\n");
printf("The imported account has role user; run 'user bootstrap' to establish an administrator.\n");
printf("User database rebuilt empty; no credentials imported.\n");
printf("Use 'user add <username> admin' on UART0 to create an administrator.\n");
return 0;
}
static int bootstrap(bool generated)
static esp_err_t mutation_currentness(void)
{
esp_err_t error;
if (generated) {
user_database_generated_password_t password;
error = user_database_bootstrap_admin_generated(&password);
if (error == ESP_OK) {
show_generated_password("admin", &password);
}
} else {
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
size_t password_length = 0U;
error = read_password(password, &password_length);
if (error == ESP_OK) {
error = user_database_bootstrap_admin(password, password_length);
}
secure_wipe(password, sizeof(password));
if (admin_ssh_console_dispatch_is_remote() &&
!admin_ssh_console_dispatch_is_current()) {
return ESP_ERR_NOT_ALLOWED;
}
if (error != ESP_OK) {
printf("Could not bootstrap administrator: %s\n", esp_err_to_name(error));
return 1;
}
revoke_user_network_sessions("admin");
printf("Administrator account bootstrapped. Role-aware HTTPS and SSH authentication is active.\n");
return 0;
return ESP_OK;
}
static int add_user(const char *username, const char *role_text, bool generated)
@@ -239,6 +206,7 @@ static int add_user(const char *username, const char *role_text, bool generated)
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
size_t password_length = 0U;
error = read_password(password, &password_length);
if (error == ESP_OK) error = mutation_currentness();
if (error == ESP_OK) {
error = user_database_create((const uint8_t *)username, strlen(username),
role, password, password_length);
@@ -268,6 +236,7 @@ static int change_password(const char *username, bool generated)
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
size_t password_length = 0U;
error = read_password(password, &password_length);
if (error == ESP_OK) error = mutation_currentness();
if (error == ESP_OK) {
error = user_database_set_password((const uint8_t *)username,
strlen(username),
@@ -388,6 +357,14 @@ static int command_user_inner(int argc, char **argv)
{
bool remote = admin_ssh_console_dispatch_is_remote();
const user_principal_t *principal = admin_ssh_console_dispatch_principal();
/* Repeat admission on canonical parsed arguments: direct handler calls must
* not bypass self-target, generated-secret or UART0-only restrictions. */
if (admin_ssh_console_dispatch_is_web() &&
(!admin_ssh_console_web_user_command_allowed((size_t)argc, argv, principal) ||
!admin_ssh_console_dispatch_is_current())) {
printf("Browser account command restricted or session no longer current.\n");
return 1;
}
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0) ||
(argc == 2 && strcmp(argv[1], "list") == 0)) {
return show_users(NULL);
@@ -403,18 +380,6 @@ static int command_user_inner(int argc, char **argv)
}
return recover_database();
}
if ((argc == 2 || argc == 3) && strcmp(argv[1], "bootstrap") == 0) {
bool generated = argc == 3 && strcmp(argv[2], "--generate") == 0;
if (argc == 3 && !generated) {
print_usage();
return 1;
}
if (remote) {
printf("Administrator bootstrap is restricted to physical UART0.\n");
return 1;
}
return bootstrap(generated);
}
if ((argc == 4 || argc == 5) && strcmp(argv[1], "add") == 0) {
bool generated = argc == 5 && strcmp(argv[4], "--generate") == 0;
if (argc == 5 && !generated) {
@@ -425,9 +390,12 @@ static int command_user_inner(int argc, char **argv)
}
if (argc == 4 && strcmp(argv[1], "delete") == 0 &&
strcmp(argv[3], "--force") == 0) {
esp_err_t error = user_database_delete((const uint8_t *)argv[2], strlen(argv[2]));
esp_err_t error = mutation_currentness();
if (error == ESP_OK) {
error = user_database_delete((const uint8_t *)argv[2], strlen(argv[2]));
}
if (error != ESP_OK) {
printf("Could not delete user (the migrated or final admin is protected): %s\n",
printf("Could not delete user (the final admin is protected): %s\n",
esp_err_to_name(error));
return 1;
}
@@ -443,8 +411,10 @@ static int command_user_inner(int argc, char **argv)
printf("Role must be user or admin.\n");
return 1;
}
esp_err_t error = user_database_set_role((const uint8_t *)argv[2],
strlen(argv[2]), role);
esp_err_t error = mutation_currentness();
if (error == ESP_OK) {
error = user_database_set_role((const uint8_t *)argv[2], strlen(argv[2]), role);
}
if (error != ESP_OK) {
printf("Could not change role (the final admin is protected): %s\n",
esp_err_to_name(error));
+195 -182
View File
@@ -25,7 +25,6 @@
static const uint8_t s_generated_alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
static const uint8_t s_admin_username[] = "admin";
static const uint8_t s_ed25519_type[] = "ssh-ed25519";
static const uint8_t s_ecdsa_type[] = "ecdsa-sha2-nistp256";
static const uint8_t s_ecdsa_curve[] = "nistp256";
@@ -58,7 +57,8 @@ typedef struct {
uint32_t version;
uint32_t size;
uint32_t generation;
uint8_t admin_bootstrapped;
/* Retain the v1 wire byte/layout; derived by recount, never policy state. */
uint8_t v1_admin_marker;
uint8_t user_count;
uint8_t admin_count;
uint8_t reserved;
@@ -295,7 +295,7 @@ static esp_err_t set_record_password(stored_user_t *user,
return error;
}
static esp_err_t generate_password(user_database_generated_password_t *generated)
esp_err_t user_database_generate_password_value(user_database_generated_password_t *generated)
{
if (generated == NULL) {
return ESP_ERR_INVALID_ARG;
@@ -351,7 +351,7 @@ static esp_err_t validate_database(const stored_database_t *database)
{
if (database->version != USER_DATABASE_SCHEMA_VERSION ||
database->size != sizeof(*database) || database->generation == 0U ||
database->admin_bootstrapped > 1U || database->reserved != 0U) {
database->v1_admin_marker > 1U || database->reserved != 0U) {
return ESP_ERR_INVALID_VERSION;
}
uint8_t users = 0U;
@@ -428,7 +428,7 @@ static esp_err_t validate_database(const stored_database_t *database)
}
}
if (users != database->user_count || admins != database->admin_count ||
(database->admin_bootstrapped != 0U) != (admins > 0U)) {
(database->v1_admin_marker != 0U) != (admins > 0U)) {
return ESP_ERR_INVALID_RESPONSE;
}
return ESP_OK;
@@ -446,6 +446,7 @@ static void recount(stored_database_t *database)
}
}
}
database->v1_admin_marker = database->admin_count > 0U ? 1U : 0U;
}
static esp_err_t next_generation(uint32_t *generation)
@@ -518,58 +519,7 @@ static esp_err_t initialize_user(stored_user_t *user,
return error;
}
static bool legacy_credentials_valid(const user_database_legacy_credentials_t *legacy)
{
return legacy != NULL && legacy->username != NULL && legacy->password != NULL &&
user_database_username_valid(legacy->username, legacy->username_length) &&
user_database_password_valid(legacy->password, legacy->password_length);
}
static esp_err_t synchronize_legacy_locked(
const user_database_legacy_credentials_t *legacy, bool *synchronized)
{
*synchronized = false;
if (s_database.admin_bootstrapped != 0U) {
return ESP_OK;
}
int index = find_user(&s_database, legacy->username, legacy->username_length);
if (index < 0) {
return ESP_OK;
}
const stored_user_t *stored = &s_database.users[index];
uint8_t derived[USER_DATABASE_PASSWORD_HASH_LENGTH] = {0};
esp_err_t error = derive_password(legacy->password, legacy->password_length,
stored->password_salt,
stored->password_iterations, derived);
bool already_current = error == ESP_OK &&
constant_time_equal(derived, stored->password_hash,
sizeof(derived));
secure_wipe(derived, sizeof(derived));
if (error != ESP_OK || already_current) {
*synchronized = already_current;
return error;
}
*s_candidate = s_database;
stored_user_t *candidate_user = &s_candidate->users[index];
error = set_record_password(candidate_user, legacy->password,
legacy->password_length);
if (error == ESP_OK) {
error = next_generation(&candidate_user->auth_generation);
}
if (error == ESP_OK) {
error = commit_candidate_locked();
} else {
discard_candidate();
}
*synchronized = error == ESP_OK;
return error;
}
esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
user_database_load_result_t *load_result)
esp_err_t user_database_init(user_database_load_result_t *load_result)
{
if (load_result == NULL || s_mutex != NULL) {
return ESP_ERR_INVALID_ARG;
@@ -609,10 +559,6 @@ esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
if (error == ESP_OK && !storage_missing) {
error = validate_database(&s_database);
}
if (error == ESP_OK && !storage_missing && legacy_credentials_valid(legacy)) {
bool synchronized = false;
error = synchronize_legacy_locked(legacy, &synchronized);
}
if (error == ESP_OK && !storage_missing) {
error = initialize_dummy_verifier();
if (error == ESP_OK) {
@@ -630,7 +576,7 @@ esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
goto init_failed;
}
if (!storage_missing || !legacy_credentials_valid(legacy)) {
if (!storage_missing) {
error = ESP_ERR_INVALID_STATE;
goto init_failed;
}
@@ -639,13 +585,6 @@ esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
s_database.version = USER_DATABASE_SCHEMA_VERSION;
s_database.size = sizeof(s_database);
s_database.generation = 1U;
error = initialize_user(&s_database.users[0], legacy->username,
legacy->username_length, USER_ROLE_USER,
legacy->password, legacy->password_length);
if (error != ESP_OK) {
goto init_failed;
}
*load_result = USER_DATABASE_LOAD_MIGRATED_LEGACY;
recount(&s_database);
*s_candidate = s_database;
error = commit_candidate_locked();
@@ -669,28 +608,8 @@ init_failed:
return error;
}
esp_err_t user_database_sync_legacy_credentials(
const user_database_legacy_credentials_t *legacy, bool *synchronized)
esp_err_t user_database_recover_empty(void)
{
if (synchronized == NULL || !legacy_credentials_valid(legacy)) {
return ESP_ERR_INVALID_ARG;
}
*synchronized = false;
if (!s_initialized || s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
esp_err_t error = synchronize_legacy_locked(legacy, synchronized);
xSemaphoreGive(s_mutex);
return error;
}
esp_err_t user_database_recover_from_legacy(
const user_database_legacy_credentials_t *legacy)
{
if (!legacy_credentials_valid(legacy)) {
return ESP_ERR_INVALID_ARG;
}
if (s_initialized || s_mutex != NULL) {
return ESP_ERR_INVALID_STATE;
}
@@ -715,14 +634,7 @@ esp_err_t user_database_recover_from_legacy(
s_candidate->version = USER_DATABASE_SCHEMA_VERSION;
s_candidate->size = sizeof(*s_candidate);
s_candidate->generation = 1U;
error = initialize_user(&s_candidate->users[0], legacy->username,
legacy->username_length, USER_ROLE_USER,
legacy->password, legacy->password_length);
if (error == ESP_OK) {
error = commit_candidate_locked();
} else {
discard_candidate();
}
error = commit_candidate_locked();
if (error == ESP_OK) {
error = initialize_dummy_verifier();
}
@@ -752,7 +664,6 @@ esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot)
memset(snapshot, 0, sizeof(*snapshot));
xSemaphoreTake(s_mutex, portMAX_DELAY);
snapshot->initialized = true;
snapshot->admin_bootstrapped = s_database.admin_bootstrapped != 0U;
snapshot->generation = s_database.generation;
snapshot->user_count = s_database.user_count;
snapshot->admin_count = s_database.admin_count;
@@ -788,6 +699,25 @@ esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot)
return ESP_OK;
}
esp_err_t user_database_get_accounts(user_database_accounts_t *accounts)
{
if (accounts == NULL) return ESP_ERR_INVALID_ARG;
memset(accounts, 0, sizeof(*accounts));
if (!s_initialized || s_mutex == NULL) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
for (size_t i = 0; i < USER_DATABASE_MAX_USERS; ++i) {
const stored_user_t *user = &s_database.users[i];
if (!user->active) continue;
user_database_account_t *out = &accounts->users[accounts->count++];
out->user_id = user->user_id;
out->auth_generation = user->auth_generation;
out->role = (user_role_t)user->role;
memcpy(out->username, user->username, user->username_length);
}
xSemaphoreGive(s_mutex);
return ESP_OK;
}
static void fill_principal(const stored_user_t *user, user_auth_method_t method,
user_principal_t *principal)
{
@@ -953,9 +883,6 @@ static esp_err_t create_locked(const uint8_t *username, size_t username_length,
*s_candidate = s_database;
esp_err_t error = initialize_user(&s_candidate->users[free_index], username,
username_length, role, password, password_length);
if (error == ESP_OK && role == USER_ROLE_ADMIN) {
s_candidate->admin_bootstrapped = 1U;
}
if (error == ESP_OK) {
return commit_candidate_locked();
}
@@ -986,7 +913,7 @@ esp_err_t user_database_create_generated(
const uint8_t *username, size_t username_length, user_role_t role,
user_database_generated_password_t *generated_password)
{
esp_err_t error = generate_password(generated_password);
esp_err_t error = user_database_generate_password_value(generated_password);
if (error == ESP_OK) {
error = user_database_create(username, username_length, role,
generated_password->password,
@@ -998,56 +925,6 @@ esp_err_t user_database_create_generated(
return error;
}
esp_err_t user_database_bootstrap_admin(const uint8_t *password,
size_t password_length)
{
if (!s_initialized || s_mutex == NULL ||
!user_database_password_valid(password, password_length)) {
return ESP_ERR_INVALID_ARG;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
if (s_database.admin_bootstrapped != 0U) {
xSemaphoreGive(s_mutex);
return ESP_ERR_INVALID_STATE;
}
int index = find_user(&s_database, s_admin_username, sizeof(s_admin_username) - 1U);
esp_err_t error;
if (index < 0) {
error = create_locked(s_admin_username, sizeof(s_admin_username) - 1U,
USER_ROLE_ADMIN, password, password_length);
} else {
*s_candidate = s_database;
stored_user_t *user = &s_candidate->users[index];
error = set_record_password(user, password, password_length);
if (error == ESP_OK) {
user->role = USER_ROLE_ADMIN;
error = next_generation(&user->auth_generation);
}
if (error == ESP_OK) {
s_candidate->admin_bootstrapped = 1U;
error = commit_candidate_locked();
} else {
discard_candidate();
}
}
xSemaphoreGive(s_mutex);
return error;
}
esp_err_t user_database_bootstrap_admin_generated(
user_database_generated_password_t *generated_password)
{
esp_err_t error = generate_password(generated_password);
if (error == ESP_OK) {
error = user_database_bootstrap_admin(generated_password->password,
generated_password->password_length);
}
if (error != ESP_OK && generated_password != NULL) {
secure_wipe(generated_password, sizeof(*generated_password));
}
return error;
}
static esp_err_t mutate_user_begin(const uint8_t *username, size_t username_length,
int *index)
{
@@ -1062,23 +939,67 @@ static esp_err_t mutate_user_begin(const uint8_t *username, size_t username_leng
return ESP_OK;
}
esp_err_t user_database_delete(const uint8_t *username, size_t username_length)
static bool target_matches_locked(const uint8_t *username, size_t length,
const user_database_account_t *expected)
{
if (!expected) return true;
int index = find_user(&s_database, username, length);
return index >= 0 && expected->user_id != 0 && expected->auth_generation != 0 &&
s_database.users[index].user_id == expected->user_id &&
s_database.users[index].auth_generation == expected->auth_generation;
}
esp_err_t user_database_get_account_keys(const user_database_account_t *expected,
user_database_user_snapshot_t *snapshot)
{
if (!snapshot) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (!expected) return ESP_ERR_INVALID_ARG;
size_t length = strnlen(expected->username, sizeof(expected->username));
if (!user_database_username_valid((const uint8_t *)expected->username, length))
return ESP_ERR_INVALID_ARG;
if (!s_initialized || !s_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
esp_err_t error = ESP_ERR_NOT_FOUND;
if (target_matches_locked((const uint8_t *)expected->username, length, expected)) {
const stored_user_t *user = &s_database.users[find_user(&s_database,
(const uint8_t *)expected->username, length)];
snapshot->active = true;
snapshot->user_id = user->user_id;
snapshot->auth_generation = user->auth_generation;
snapshot->role = (user_role_t)user->role;
snapshot->username_length = length;
memcpy(snapshot->username, user->username, length);
snapshot->public_key_count = user->key_count;
for (size_t i = 0; i < USER_DATABASE_MAX_SSH_KEYS_PER_USER; ++i) {
const stored_key_t *key = &user->keys[i];
if (!key->active) continue;
user_database_key_snapshot_t *out = &snapshot->public_keys[i];
out->active = true;
out->index = (uint8_t)i;
out->key_type_length = key->type_length;
memcpy(out->key_type, key->type, key->type_length);
memcpy(out->sha256_fingerprint, key->fingerprint, sizeof(out->sha256_fingerprint));
}
error = ESP_OK;
}
xSemaphoreGive(s_mutex);
return error;
}
static esp_err_t delete_user(const uint8_t *username, size_t username_length,
const user_database_account_t *expected)
{
if (!s_initialized || s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int index;
esp_err_t error = mutate_user_begin(username, username_length, &index);
esp_err_t error = target_matches_locked(username, username_length, expected)
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
const stored_user_t *user = &s_database.users[index];
bool protected_migrated_admin =
s_database.admin_bootstrapped == 0U &&
user->username_length == sizeof(s_admin_username) - 1U &&
memcmp(user->username, s_admin_username,
sizeof(s_admin_username) - 1U) == 0;
if (protected_migrated_admin ||
(user->role == USER_ROLE_ADMIN && s_database.admin_count <= 1U)) {
if (user->role == USER_ROLE_ADMIN && s_database.admin_count <= 1U) {
error = ESP_ERR_INVALID_STATE;
discard_candidate();
} else {
@@ -1090,8 +1011,8 @@ esp_err_t user_database_delete(const uint8_t *username, size_t username_length)
return error;
}
esp_err_t user_database_set_role(const uint8_t *username, size_t username_length,
user_role_t role)
static esp_err_t set_role(const uint8_t *username, size_t username_length,
user_role_t role, const user_database_account_t *expected)
{
if (!s_initialized || s_mutex == NULL ||
(role != USER_ROLE_USER && role != USER_ROLE_ADMIN)) {
@@ -1099,7 +1020,8 @@ esp_err_t user_database_set_role(const uint8_t *username, size_t username_length
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int index;
esp_err_t error = mutate_user_begin(username, username_length, &index);
esp_err_t error = target_matches_locked(username, username_length, expected)
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
stored_user_t *user = &s_candidate->users[index];
if (user->role == role) {
@@ -1111,9 +1033,6 @@ esp_err_t user_database_set_role(const uint8_t *username, size_t username_length
} else {
user->role = (uint8_t)role;
error = next_generation(&user->auth_generation);
if (error == ESP_OK && role == USER_ROLE_ADMIN) {
s_candidate->admin_bootstrapped = 1U;
}
if (error == ESP_OK) {
error = commit_candidate_locked();
} else {
@@ -1125,8 +1044,37 @@ esp_err_t user_database_set_role(const uint8_t *username, size_t username_length
return error;
}
esp_err_t user_database_set_password(const uint8_t *username, size_t username_length,
const uint8_t *password, size_t password_length)
esp_err_t user_database_delete(const uint8_t *username, size_t length)
{
return delete_user(username, length, NULL);
}
esp_err_t user_database_set_role(const uint8_t *username, size_t length, user_role_t role)
{
return set_role(username, length, role, NULL);
}
esp_err_t user_database_delete_current(const user_database_account_t *expected)
{
if (!expected) return ESP_ERR_INVALID_ARG;
size_t length = strnlen(expected->username, sizeof(expected->username));
if (!user_database_username_valid((const uint8_t *)expected->username, length))
return ESP_ERR_INVALID_ARG;
return delete_user((const uint8_t *)expected->username, length, expected);
}
esp_err_t user_database_set_role_current(const user_database_account_t *expected, user_role_t role)
{
if (!expected) return ESP_ERR_INVALID_ARG;
size_t length = strnlen(expected->username, sizeof(expected->username));
if (!user_database_username_valid((const uint8_t *)expected->username, length))
return ESP_ERR_INVALID_ARG;
return set_role((const uint8_t *)expected->username, length, role, expected);
}
static esp_err_t set_password(const uint8_t *username, size_t username_length,
const uint8_t *password, size_t password_length,
const user_database_account_t *expected)
{
if (!s_initialized || s_mutex == NULL ||
!user_database_password_valid(password, password_length)) {
@@ -1134,7 +1082,8 @@ esp_err_t user_database_set_password(const uint8_t *username, size_t username_le
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int index;
esp_err_t error = mutate_user_begin(username, username_length, &index);
esp_err_t error = target_matches_locked(username, username_length, expected)
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
stored_user_t *user = &s_candidate->users[index];
error = set_record_password(user, password, password_length);
@@ -1151,11 +1100,27 @@ esp_err_t user_database_set_password(const uint8_t *username, size_t username_le
return error;
}
esp_err_t user_database_set_password(const uint8_t *username, size_t username_length,
const uint8_t *password, size_t password_length)
{
return set_password(username, username_length, password, password_length, NULL);
}
esp_err_t user_database_set_password_current(const user_database_account_t *expected,
const uint8_t *password, size_t password_length)
{
if (!expected) return ESP_ERR_INVALID_ARG;
size_t length = strnlen(expected->username, sizeof(expected->username));
if (!user_database_username_valid((const uint8_t *)expected->username, length))
return ESP_ERR_INVALID_ARG;
return set_password((const uint8_t *)expected->username, length, password, password_length, expected);
}
esp_err_t user_database_generate_password(
const uint8_t *username, size_t username_length,
user_database_generated_password_t *generated_password)
{
esp_err_t error = generate_password(generated_password);
esp_err_t error = user_database_generate_password_value(generated_password);
if (error == ESP_OK) {
error = user_database_set_password(username, username_length,
generated_password->password,
@@ -1167,11 +1132,11 @@ esp_err_t user_database_generate_password(
return error;
}
esp_err_t user_database_add_ssh_key(
static esp_err_t add_ssh_key(
const uint8_t *username, size_t username_length,
const uint8_t *key_type, size_t key_type_length,
const uint8_t *key_blob, size_t key_blob_length,
uint8_t *key_index)
uint8_t *key_index, const user_database_account_t *expected)
{
if (!s_initialized || s_mutex == NULL || key_index == NULL ||
!user_database_key_valid(key_type, key_type_length, key_blob, key_blob_length)) {
@@ -1179,7 +1144,8 @@ esp_err_t user_database_add_ssh_key(
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int user_index;
esp_err_t error = mutate_user_begin(username, username_length, &user_index);
esp_err_t error = target_matches_locked(username, username_length, expected)
? mutate_user_begin(username, username_length, &user_index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
stored_user_t *user = &s_candidate->users[user_index];
int free_index = -1;
@@ -1230,9 +1196,9 @@ esp_err_t user_database_add_ssh_key(
return error;
}
esp_err_t user_database_remove_ssh_key(const uint8_t *username,
size_t username_length,
uint8_t key_index)
static esp_err_t remove_ssh_key(const uint8_t *username,
size_t username_length, uint8_t key_index,
const user_database_account_t *expected)
{
if (!s_initialized || s_mutex == NULL ||
key_index >= USER_DATABASE_MAX_SSH_KEYS_PER_USER) {
@@ -1240,7 +1206,8 @@ esp_err_t user_database_remove_ssh_key(const uint8_t *username,
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int user_index;
esp_err_t error = mutate_user_begin(username, username_length, &user_index);
esp_err_t error = target_matches_locked(username, username_length, expected)
? mutate_user_begin(username, username_length, &user_index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
stored_user_t *user = &s_candidate->users[user_index];
if (user->keys[key_index].active == 0U) {
@@ -1261,15 +1228,17 @@ esp_err_t user_database_remove_ssh_key(const uint8_t *username,
return error;
}
esp_err_t user_database_clear_ssh_keys(const uint8_t *username,
size_t username_length)
static esp_err_t clear_ssh_keys(const uint8_t *username,
size_t username_length,
const user_database_account_t *expected)
{
if (!s_initialized || s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_mutex, portMAX_DELAY);
int user_index;
esp_err_t error = mutate_user_begin(username, username_length, &user_index);
esp_err_t error = target_matches_locked(username, username_length, expected)
? mutate_user_begin(username, username_length, &user_index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) {
stored_user_t *user = &s_candidate->users[user_index];
if (user->key_count == 0U) {
@@ -1289,3 +1258,47 @@ esp_err_t user_database_clear_ssh_keys(const uint8_t *username,
xSemaphoreGive(s_mutex);
return error;
}
esp_err_t user_database_add_ssh_key(const uint8_t *username, size_t length,
const uint8_t *type, size_t type_length, const uint8_t *blob, size_t blob_length,
uint8_t *index)
{
return add_ssh_key(username, length, type, type_length, blob, blob_length, index, NULL);
}
esp_err_t user_database_remove_ssh_key(const uint8_t *username, size_t length, uint8_t index)
{
return remove_ssh_key(username, length, index, NULL);
}
esp_err_t user_database_clear_ssh_keys(const uint8_t *username, size_t length)
{
return clear_ssh_keys(username, length, NULL);
}
static bool key_target_valid(const user_database_account_t *expected)
{
return expected && user_database_username_valid((const uint8_t *)expected->username,
strnlen(expected->username, sizeof(expected->username)));
}
esp_err_t user_database_add_ssh_key_current(const user_database_account_t *expected,
const uint8_t *type, size_t type_length, const uint8_t *blob, size_t blob_length,
uint8_t *index)
{
if (!key_target_valid(expected)) return ESP_ERR_INVALID_ARG;
return add_ssh_key((const uint8_t *)expected->username, strlen(expected->username),
type, type_length, blob, blob_length, index, expected);
}
esp_err_t user_database_remove_ssh_key_current(const user_database_account_t *expected, uint8_t index)
{
if (!key_target_valid(expected)) return ESP_ERR_INVALID_ARG;
return remove_ssh_key((const uint8_t *)expected->username, strlen(expected->username), index, expected);
}
esp_err_t user_database_clear_ssh_keys_current(const user_database_account_t *expected)
{
if (!key_target_valid(expected)) return ESP_ERR_INVALID_ARG;
return clear_ssh_keys((const uint8_t *)expected->username, strlen(expected->username), expected);
}
+38 -25
View File
@@ -38,17 +38,9 @@ typedef enum {
typedef enum {
USER_DATABASE_LOAD_STORED = 0,
USER_DATABASE_LOAD_MIGRATED_LEGACY,
USER_DATABASE_LOAD_EMPTY,
} user_database_load_result_t;
typedef struct {
const uint8_t *username;
size_t username_length;
const uint8_t *password;
size_t password_length;
} user_database_legacy_credentials_t;
typedef struct {
uint32_t user_id;
uint32_t auth_generation;
@@ -84,27 +76,52 @@ typedef struct {
typedef struct {
bool initialized;
bool admin_bootstrapped;
uint32_t generation;
uint8_t user_count;
uint8_t admin_count;
user_database_user_snapshot_t users[USER_DATABASE_MAX_USERS];
} user_database_snapshot_t;
esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
user_database_load_result_t *load_result);
/*
* Before the first administrator is established, keep the migrated account in
* sync with the legacy recovery credential. Once bootstrapped, that credential
* remains independent and no longer authenticates Phase 8B network services.
*/
esp_err_t user_database_sync_legacy_credentials(
const user_database_legacy_credentials_t *legacy, bool *synchronized);
/* Explicit UART0 recovery: replace unavailable user storage with one legacy user. */
esp_err_t user_database_recover_from_legacy(
const user_database_legacy_credentials_t *legacy);
/* Missing storage is persisted empty; valid v1 records load unchanged.
* Corrupt/unsupported storage fails closed and is never automatically replaced. */
esp_err_t user_database_init(user_database_load_result_t *load_result);
/* Explicit UART0 recovery only; refuses an initialized database. No credentials
* are imported or created. Caller enforces physical-console authorization. */
esp_err_t user_database_recover_empty(void);
esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot);
/* Compact secret-free list, zero-wait mutex acquisition; no key material. */
typedef struct {
uint32_t user_id, auth_generation;
user_role_t role;
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
} user_database_account_t;
typedef struct {
size_t count;
user_database_account_t users[USER_DATABASE_MAX_USERS];
} user_database_accounts_t;
esp_err_t user_database_get_accounts(user_database_accounts_t *accounts);
/* Zero-wait, identity-conditional projection; fingerprints only, no key blobs.
* Output is cleared on failure; absent/stale identity returns NOT_FOUND. */
esp_err_t user_database_get_account_keys(const user_database_account_t *expected,
user_database_user_snapshot_t *snapshot);
esp_err_t user_database_add_ssh_key_current(const user_database_account_t *expected,
const uint8_t *key_type, size_t key_type_length,
const uint8_t *key_blob, size_t key_blob_length, uint8_t *key_index);
esp_err_t user_database_remove_ssh_key_current(const user_database_account_t *expected,
uint8_t key_index);
esp_err_t user_database_clear_ssh_keys_current(const user_database_account_t *expected);
/* Compare target identity under the mutation lock, before candidate/commit.
* ESP_ERR_NOT_FOUND means absent or stale; existing account invariants apply. */
esp_err_t user_database_delete_current(const user_database_account_t *expected);
esp_err_t user_database_set_role_current(const user_database_account_t *expected,
user_role_t role);
esp_err_t user_database_set_password_current(const user_database_account_t *expected,
const uint8_t *password, size_t password_length);
/* RNG only: no database initialization, account mutation or persistence. Caller
* owns/wipes successful output; failures clear it. Same generator as CLI. */
esp_err_t user_database_generate_password_value(user_database_generated_password_t *generated);
esp_err_t user_database_authenticate_password(
const uint8_t *username, size_t username_length,
const uint8_t *password, size_t password_length,
@@ -117,10 +134,6 @@ esp_err_t user_database_authorize_ssh_public_key(
esp_err_t user_database_principal_is_current(const user_principal_t *principal,
bool *current);
esp_err_t user_database_bootstrap_admin(const uint8_t *password,
size_t password_length);
esp_err_t user_database_bootstrap_admin_generated(
user_database_generated_password_t *generated_password);
esp_err_t user_database_create(const uint8_t *username, size_t username_length,
user_role_t role,
const uint8_t *password, size_t password_length);
+484
View File
@@ -0,0 +1,484 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_account_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "mbedtls/base64.h"
#include "ssh_transport.h"
#include "web_cookie_auth.h"
#include "web_auth_parse.h"
#include "web_httpd_adapter.h"
#include "web_serial_transport.h"
enum { IDLE, PENDING, OK, FAILED, CANCELLED, STALE, PROTECTED, DUPLICATE, FULL };
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "stale", "protected", "duplicate", "full"};
typedef enum { ACTION_ROLE, ACTION_DELETE, ACTION_CREATE, ACTION_PASSWORD,
ACTION_KEY_ADD, ACTION_KEY_DELETE, ACTION_KEY_CLEAR } account_action_t;
static const char *const s_actions[] = {"role", "delete", "create", "password", "key-add", "key-delete", "key-clear"};
typedef struct {
uint32_t id;
web_session_id_t session;
user_principal_t principal;
user_database_account_t target;
int64_t deadline;
user_role_t role;
unsigned state;
account_action_t action;
bool executing;
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U];
size_t password_length;
char key_type[USER_DATABASE_SSH_KEY_TYPE_CAPACITY + 1U];
uint8_t key_blob[USER_DATABASE_SSH_KEY_BLOB_CAPACITY];
size_t key_blob_length;
uint8_t key_index;
} account_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static account_operation_t s_operation;
static uint32_t s_next_id;
static esp_timer_handle_t s_secret_timer;
static bool s_secret_timer_started;
static bool credential_action(account_action_t action)
{
return action == ACTION_CREATE || action == ACTION_PASSWORD;
}
static void wipe_input(account_operation_t *operation)
{
secure_wipe(&operation->principal, sizeof(operation->principal));
secure_wipe(&operation->target, sizeof(operation->target));
secure_wipe(operation->password, sizeof(operation->password));
operation->password_length = 0;
secure_wipe(operation->key_type, sizeof(operation->key_type));
secure_wipe(operation->key_blob, sizeof(operation->key_blob));
operation->key_blob_length = 0;
operation->key_index = 0;
}
static void expire_secret(void *unused)
{
(void)unused;
taskENTER_CRITICAL(&s_lock);
/* Inspect only the current ID/deadline, never a captured/rearmed job. A late
* tick cannot cancel a replacement before its own deadline or executing work. */
if (s_operation.id && s_operation.state == PENDING && !s_operation.executing &&
credential_action(s_operation.action) && esp_timer_get_time() >= s_operation.deadline) {
s_operation.state = CANCELLED;
wipe_input(&s_operation);
}
taskEXIT_CRITICAL(&s_lock);
}
static bool ensure_secret_timer(void)
{
/* HTTPD is the sole admission owner. Once started, this one firmware-lifetime
* timer is never stopped/rearmed/deleted. Expiry is best-effort scheduling,
* not hard realtime; no network/database work runs in its callback. */
if (!s_secret_timer) {
const esp_timer_create_args_t args = {.callback = expire_secret, .name = "account-secret"};
if (esp_timer_create(&args, &s_secret_timer) != ESP_OK) return false;
}
if (!s_secret_timer_started) {
if (esp_timer_start_periodic(s_secret_timer, 1000000ULL) != ESP_OK) return false;
s_secret_timer_started = true;
}
return true;
}
/* OpenSSH text envelope only. The canonical database parser validates the SSH
* blob (including the P256 point) on the dispatcher, not the HTTPD stack. */
static bool parse_public_key(const char *text, size_t length, account_operation_t *operation)
{
size_t type_length = 0;
while (type_length < length && text[type_length] != ' ' && text[type_length] != '\t') ++type_length;
if (!((type_length == 11 && !memcmp(text, "ssh-ed25519", 11)) ||
(type_length == 19 && !memcmp(text, "ecdsa-sha2-nistp256", 19)))) return false;
size_t start = type_length;
while (start < length && (text[start] == ' ' || text[start] == '\t')) ++start;
size_t end = start;
while (end < length && text[end] != ' ' && text[end] != '\t') ++end;
size_t encoded_length = end - start;
if (!encoded_length || encoded_length > 172 || encoded_length % 4) return false;
for (size_t i = start; i < end; ++i) {
unsigned char c = (unsigned char)text[i];
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '+' || c == '/' ||
(c == '=' && i >= end - 2))) return false;
}
for (size_t i = end; i < length; ++i)
if ((text[i] < ' ' || text[i] > '~') && text[i] != '\t') return false;
if (mbedtls_base64_decode(operation->key_blob, sizeof(operation->key_blob),
&operation->key_blob_length, (const uint8_t *)text + start, encoded_length) != 0) return false;
/* Round-trip rejects noncanonical padding and unused base64 bits. */
unsigned char encoded[173];
size_t written = 0;
if (mbedtls_base64_encode(encoded, sizeof(encoded), &written, operation->key_blob,
operation->key_blob_length) != 0 || written != encoded_length ||
memcmp(encoded, text + start, written)) return false;
memcpy(operation->key_type, text, type_length);
return true;
}
/* Exact flat schemas. Password/public_key accept JSON escapes; canonical database
* policy validates the decoded bytes. No coercion/unknown/duplicate fields. */
static bool parse_request(const char *body, size_t length, account_operation_t *operation, bool keys_only)
{
const char *keys[] = {"action", "username", "user_id", "auth_generation", "role", "password", "public_key", "key_index"};
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 < 8; ++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 < 8; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 8 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
uint32_t number = 0;
char value[USER_DATABASE_USERNAME_CAPACITY + 1] = {0};
if (key == 2 || key == 3 || key == 7) {
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if ((!number && key != 7) || pos == start || (pos - start > 1 && body[start] == '0')) return false;
if (key == 7 && number >= USER_DATABASE_MAX_SSH_KEYS_PER_USER) return false;
} else if (key == 6) {
char text[385] = {0};
size_t text_length = 0;
if (!web_auth_parse_json_string(body, length, &pos, (uint8_t *)text,
sizeof(text), &text_length) || !parse_public_key(text, text_length, operation)) return false;
} else if (key == 5) {
if (!web_auth_parse_json_string(body, length, &pos, operation->password,
sizeof(operation->password), &operation->password_length) ||
!user_database_password_valid(operation->password, operation->password_length)) return false;
} else {
TAKE('"'); start = pos;
while (pos < length && body[pos] != '"') {
if (body[pos] < ' ' || body[pos] > '~' || body[pos] == '\\' || pos - start >= sizeof(value) - 1) return false;
++pos;
}
if (pos == length) return false;
memcpy(value, body + start, pos - start); ++pos;
}
switch (key) {
case 0:
{
unsigned action = 0;
for (; action < sizeof(s_actions) / sizeof(*s_actions); ++action)
if (!strcmp(value, s_actions[action])) break;
if (action == sizeof(s_actions) / sizeof(*s_actions)) return false;
operation->action = (account_action_t)action;
}
break;
case 1:
if (!user_database_username_valid((const uint8_t *)value, strlen(value))) return false;
memcpy(operation->target.username, value, sizeof(value)); break;
case 2: operation->target.user_id = number; break;
case 3: operation->target.auth_generation = number; break;
case 4: if (!user_role_parse(value, &operation->role)) return false; break;
case 7: operation->key_index = (uint8_t)number; break;
}
seen |= 1U << key;
SPACE();
if (pos < length && body[pos] == '}') break;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
const unsigned schemas[] = {31U, 15U, 51U, 47U, 79U, 143U, 15U};
return pos == length && seen == (keys_only ? 14U : schemas[operation->action]);
}
static bool parse(const char *body, size_t length, account_operation_t *operation)
{
return parse_request(body, length, operation, false);
}
void web_account_settings_execute(uint32_t id)
{
account_operation_t operation = {0};
taskENTER_CRITICAL(&s_lock);
bool admitted = id && s_operation.id == id && s_operation.state == PENDING && !s_operation.executing;
if (admitted) {
s_operation.executing = true;
operation = s_operation;
wipe_input(&s_operation);
}
taskEXIT_CRITICAL(&s_lock);
if (!admitted) return;
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
/* CLI and typed mutations share this dispatcher. Target identity is also
* compared under the database mutation lock, not just at HTTP admission. */
switch (operation.action) {
case ACTION_ROLE: error = user_database_set_role_current(&operation.target, operation.role); break;
case ACTION_DELETE: error = user_database_delete_current(&operation.target); break;
case ACTION_CREATE:
error = user_database_create((const uint8_t *)operation.target.username,
strlen(operation.target.username), operation.role, operation.password, operation.password_length);
break;
case ACTION_KEY_ADD:
error = user_database_add_ssh_key_current(&operation.target,
(const uint8_t *)operation.key_type, strlen(operation.key_type),
operation.key_blob, operation.key_blob_length, &operation.key_index);
break;
case ACTION_KEY_DELETE:
error = user_database_remove_ssh_key_current(&operation.target, operation.key_index);
break;
case ACTION_KEY_CLEAR:
error = user_database_clear_ssh_keys_current(&operation.target);
break;
case ACTION_PASSWORD:
error = user_database_set_password_current(&operation.target, operation.password, operation.password_length);
break;
}
secure_wipe(operation.password, sizeof(operation.password));
operation.password_length = 0;
state = error == ESP_OK ? OK : error == ESP_ERR_NOT_FOUND ? STALE :
error == ESP_ERR_INVALID_STATE ? (operation.action == ACTION_CREATE ? DUPLICATE :
operation.action <= ACTION_PASSWORD ? PROTECTED : FAILED) :
error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY && operation.action == ACTION_KEY_ADD ? DUPLICATE :
error == ESP_ERR_NO_MEM && (operation.action == ACTION_CREATE || operation.action == ACTION_KEY_ADD) ? FULL : FAILED;
if (error == ESP_OK && operation.action != ACTION_CREATE) {
size_t length = strlen(operation.target.username);
(void)web_serial_transport_revoke_user((const uint8_t *)operation.target.username, length);
(void)ssh_transport_revoke_user((const uint8_t *)operation.target.username, length);
}
}
secure_wipe(operation.password, sizeof(operation.password));
operation.password_length = 0;
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING && s_operation.executing) {
s_operation.state = state;
s_operation.executing = false;
wipe_input(&s_operation);
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
static esp_err_t list_accounts(httpd_req_t *request)
{
user_database_accounts_t accounts;
if (user_database_get_accounts(&accounts) != ESP_OK)
return respond(request, "503 Service Unavailable", "{\"error\":\"accounts_unavailable\"}");
char body[1024];
size_t used = (size_t)snprintf(body, sizeof(body), "{\"users\":[");
for (size_t i = 0; i < accounts.count; ++i) {
const user_database_account_t *user = &accounts.users[i];
/* Database username policy makes these ASCII strings JSON-safe. */
int written = snprintf(body + used, sizeof(body) - used,
"%s{\"username\":\"%s\",\"user_id\":%" PRIu32 ",\"auth_generation\":%" PRIu32 ",\"role\":\"%s\"}",
i ? "," : "", user->username, user->user_id, user->auth_generation, user_role_to_string(user->role));
if (written < 0 || (size_t)written >= sizeof(body) - used) return ESP_FAIL;
used += (size_t)written;
}
if (used + 3 > sizeof(body)) return ESP_FAIL;
memcpy(body + used, "]}", 3);
return respond(request, "200 OK", body);
}
static bool read_request(httpd_req_t *request, account_operation_t *operation, bool keys_only)
{
char type[40] = {0}, body[768];
size_t received = 0;
bool valid = request->content_len && request->content_len <= sizeof(body) &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len &&
(keys_only ? parse_request(body, received, operation, true) : parse(body, received, operation));
secure_wipe(body, sizeof(body));
return valid;
}
esp_err_t web_account_keys_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
account_operation_t operation = {0};
user_database_user_snapshot_t snapshot = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require_json(request, 768, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
if (strcmp(request->uri, "/api/settings/accounts/keys") || !read_request(request, &operation, true)) {
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}");
goto done;
}
error = user_database_get_account_keys(&operation.target, &snapshot);
if (error != ESP_OK) {
error = error == ESP_ERR_NOT_FOUND ? respond(request, "409 Conflict", "{\"error\":\"stale\"}") :
respond(request, "503 Service Unavailable", "{\"error\":\"accounts_unavailable\"}");
goto done;
}
char body[512];
int written = snprintf(body, sizeof(body),
"{\"username\":\"%s\",\"user_id\":%" PRIu32 ",\"auth_generation\":%" PRIu32 ",\"keys\":[",
snapshot.username, snapshot.user_id, snapshot.auth_generation);
if (written < 0 || (size_t)written >= sizeof(body)) { error = ESP_FAIL; goto done; }
size_t used = (size_t)written;
bool comma = false;
for (size_t i = 0; i < USER_DATABASE_MAX_SSH_KEYS_PER_USER; ++i) {
const user_database_key_snapshot_t *key = &snapshot.public_keys[i];
if (!key->active) continue;
unsigned char fingerprint[45];
size_t length = 0;
if (mbedtls_base64_encode(fingerprint, sizeof(fingerprint), &length,
key->sha256_fingerprint, sizeof(key->sha256_fingerprint)) != 0 || length != 44) {
error = ESP_FAIL; goto done;
}
fingerprint[43] = 0; /* OpenSSH SHA256 fingerprints omit base64 padding. */
written = snprintf(body + used, sizeof(body) - used,
"%s{\"index\":%u,\"type\":\"%s\",\"fingerprint\":\"SHA256:%s\"}",
comma ? "," : "", key->index, key->key_type, (const char *)fingerprint);
if (written < 0 || (size_t)written >= sizeof(body) - used) { error = ESP_FAIL; goto done; }
used += (size_t)written;
comma = true;
}
if (used + 3 > sizeof(body)) { error = ESP_FAIL; goto done; }
memcpy(body + used, "]}", 3);
error = respond(request, "200 OK", body);
done:
secure_wipe(&operation, sizeof(operation));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_account_generate_password_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
user_database_generated_password_t generated = {0};
char response[96] = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
error = user_database_generate_password_value(&generated);
if (error != ESP_OK) {
secure_wipe(&generated, sizeof(generated));
error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}");
goto done;
}
bool current = false;
error = web_session_store_check_principal(view.id, &view.principal, &current);
if (error != ESP_OK || !current) {
secure_wipe(&generated, sizeof(generated));
error = respond(request, "401 Unauthorized", "{\"error\":\"authentication_required\"}");
goto done;
}
int written = snprintf(response, sizeof(response), "{\"password\":\"%s\"}", (const char *)generated.password);
secure_wipe(&generated, sizeof(generated));
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response);
done:
secure_wipe(&generated, sizeof(generated));
secure_wipe(response, sizeof(response));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_account_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
account_operation_t operation = {0};
bool allowed = false, mutation = request->method == HTTP_POST;
esp_err_t error = mutation ? web_cookie_auth_require_json(request, 768, &view, &allowed) :
web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
if (!strcmp(request->uri, "/api/settings/accounts")) {
error = mutation ? respond(request, "400 Bad Request", "{\"error\":\"invalid_request\"}") : list_accounts(request);
goto done;
}
if (mutation) {
if (!read_request(request, &operation, false)) {
wipe_input(&operation);
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}");
goto done;
}
if (credential_action(operation.action) && !ensure_secret_timer()) {
wipe_input(&operation);
error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) { operation.id = ++s_next_id; s_operation = operation; }
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_account_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id && !s_operation.executing)
secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
wipe_input(&operation);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id;
operation.action = s_operation.action;
operation.state = s_operation.state;
}
taskEXIT_CRITICAL(&s_lock);
}
wipe_input(&operation);
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
done:
secure_wipe(&operation, sizeof(operation));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+27
View File
@@ -0,0 +1,27 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* One session-bound pending/result slot. Dispatcher execution only; completed
* results are replaceable, not durable history or an idempotent retry API. */
esp_err_t web_account_settings_handler(httpd_req_t *request);
void web_account_settings_execute(uint32_t id);
/* POST /api/settings/accounts/keys: admin cookie + Origin/CSRF, JSON exactly
* {username,user_id,auth_generation}. Read-only zero-wait snapshot, 512-byte
* response bound: {username,user_id,auth_generation,keys:[{index,type,fingerprint}]}.
* Fingerprints are OpenSSH SHA256: base64 without padding, never key blobs.
* Stale/absent target: 409 {error:"stale"}; busy DB: 503 accounts_unavailable.
* Register independently as an optional POST route.
*
* Existing account-operation POST adds key-add (+public_key, OpenSSH text <=384
* decoded bytes), key-delete (+key_index integer 0..2), key-clear. All require
* username/user_id/auth_generation. Exact schemas, <=768 body bytes/4 receives.
* Text/base64 errors: 400; canonical SSH blob/curve validation runs on dispatcher
* (failed result). Duplicate/full/stale use existing named result states.
* Success target-revokes immediately, including self; lost response/401 remains
* uncertain, never proof of cancellation. No automatic mutation retries. */
esp_err_t web_account_keys_handler(httpd_req_t *request);
/* POST /api/settings/accounts/generate-password; bodyless admin cookie +
* Origin/CSRF. RNG only, no queued/account/persistent state or retrieval. */
esp_err_t web_account_generate_password_handler(httpd_req_t *request);
+300
View File
@@ -0,0 +1,300 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_admin_tickets.h"
#include <limits.h>
#include <string.h>
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "mbedtls/sha256.h"
#include "secure_random.h"
typedef struct {
uint64_t generation;
web_session_id_t id;
int64_t expires_at_us;
user_principal_t principal;
uint8_t digest[32];
} ticket_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static struct {
ticket_t tickets[WEB_ADMIN_TICKET_CAPACITY];
uint64_t epoch;
uint64_t generation;
uint32_t issued, consumed, rejected, capacity_rejections;
bool ready;
} s_state;
static void increment(uint32_t *counter)
{
if (*counter != UINT32_MAX) ++*counter;
}
static bool equal_digest(const uint8_t *a, const uint8_t *b)
{
volatile uint8_t difference = 0;
for (size_t i = 0; i < 32; ++i) difference |= a[i] ^ b[i];
return difference == 0;
}
static bool admin(const user_principal_t *p)
{
return p != NULL && p->role == USER_ROLE_ADMIN &&
p->method == USER_AUTH_METHOD_PASSWORD && p->user_id != 0 &&
p->auth_generation != 0 && p->username_length != 0 &&
p->username_length <= USER_DATABASE_USERNAME_CAPACITY;
}
static bool same_principal(const user_principal_t *a, const user_principal_t *b)
{
return admin(b) && a->user_id == b->user_id &&
a->auth_generation == b->auth_generation && a->role == b->role &&
a->method == b->method && a->username_length == b->username_length &&
memcmp(a->username, b->username, a->username_length) == 0;
}
static bool current(web_session_id_t id, const user_principal_t *p)
{
bool valid = false;
return id != 0 && admin(p) &&
web_session_store_check_principal(id, p, &valid) == ESP_OK && valid;
}
static void expire_locked(int64_t now)
{
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t *t = &s_state.tickets[i];
if (t->generation && t->expires_at_us <= now) secure_wipe(t, sizeof(*t));
}
}
/* Fixed two-slot walk. Generation prevents an external check from deleting a
* replacement, including when RNG returns the same bytes on a later issue. */
static void prune(void)
{
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t copy = {0};
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
copy = s_state.tickets[i];
taskEXIT_CRITICAL(&s_lock);
if (copy.generation && !current(copy.id, &copy.principal)) {
taskENTER_CRITICAL(&s_lock);
if (s_state.tickets[i].generation == copy.generation)
secure_wipe(&s_state.tickets[i], sizeof(ticket_t));
taskEXIT_CRITICAL(&s_lock);
}
secure_wipe(&copy, sizeof(copy));
}
}
static void advance_epoch_locked(void)
{
if (s_state.epoch != UINT64_MAX) ++s_state.epoch;
if (s_state.epoch == UINT64_MAX) {
s_state.ready = false;
secure_wipe(s_state.tickets, sizeof(s_state.tickets));
}
}
void web_admin_tickets_start(void)
{
taskENTER_CRITICAL(&s_lock);
if (!s_state.ready && s_state.epoch != UINT64_MAX &&
s_state.generation != UINT64_MAX) {
advance_epoch_locked();
s_state.ready = s_state.epoch != UINT64_MAX;
}
taskEXIT_CRITICAL(&s_lock);
}
void web_admin_tickets_stop(void)
{
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
s_state.ready = false;
secure_wipe(s_state.tickets, sizeof(s_state.tickets));
taskEXIT_CRITICAL(&s_lock);
}
static bool capture_epoch(uint64_t *epoch)
{
taskENTER_CRITICAL(&s_lock);
*epoch = s_state.epoch;
bool ready = s_state.ready;
taskEXIT_CRITICAL(&s_lock);
return ready;
}
static esp_err_t result(esp_err_t error)
{
if (error != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
increment(&s_state.rejected);
taskEXIT_CRITICAL(&s_lock);
}
return error;
}
esp_err_t web_admin_tickets_issue(web_session_id_t id,
const user_principal_t *principal, char token[WEB_ADMIN_TICKET_LENGTH + 1U])
{
ticket_t candidate = {0};
uint8_t random[32] = {0};
uint64_t epoch = 0;
esp_err_t error = ESP_ERR_INVALID_ARG;
if (token == NULL) return result(error);
secure_wipe(token, WEB_ADMIN_TICKET_LENGTH + 1U);
if (id == 0 || principal == NULL) goto done;
error = ESP_ERR_INVALID_STATE;
if (!capture_epoch(&epoch) || !current(id, principal)) goto done;
candidate.id = id;
candidate.principal = *principal;
prune();
if (!current(id, &candidate.principal)) goto done;
error = secure_random_fill(random, sizeof(random));
/* Recheck even when crypto fails; never use an old authorization result. */
bool valid = current(id, &candidate.principal);
if (error != ESP_OK) goto done;
error = ESP_ERR_INVALID_STATE;
if (!valid) goto done;
static const char hex[] = "0123456789abcdef";
for (size_t i = 0; i < sizeof(random); ++i) {
token[2 * i] = hex[random[i] >> 4];
token[2 * i + 1] = hex[random[i] & 15];
}
int crypto = mbedtls_sha256(random, sizeof(random), candidate.digest, 0);
valid = current(id, &candidate.principal);
error = crypto == 0 ? ESP_ERR_INVALID_STATE : ESP_FAIL;
if (crypto != 0 || !valid) goto done;
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
if (s_state.ready && epoch == s_state.epoch && now >= 0 &&
now <= INT64_MAX - WEB_ADMIN_TICKET_LIFETIME_US &&
s_state.generation != UINT64_MAX) {
ticket_t *free_slot = NULL;
bool duplicate = false;
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t *t = &s_state.tickets[i];
if (!t->generation) free_slot = t;
else if (equal_digest(t->digest, candidate.digest)) duplicate = true;
}
if (duplicate) error = ESP_FAIL;
else if (free_slot == NULL) {
increment(&s_state.capacity_rejections);
error = ESP_ERR_NO_MEM;
} else {
candidate.generation = ++s_state.generation;
candidate.expires_at_us = now + WEB_ADMIN_TICKET_LIFETIME_US;
*free_slot = candidate;
increment(&s_state.issued);
error = ESP_OK;
}
}
taskEXIT_CRITICAL(&s_lock);
done:
secure_wipe(random, sizeof(random));
secure_wipe(&candidate, sizeof(candidate));
if (error != ESP_OK) secure_wipe(token, WEB_ADMIN_TICKET_LENGTH + 1U);
return result(error);
}
static int unhex(char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
esp_err_t web_admin_tickets_consume(const char *token, web_session_id_t id,
const user_principal_t *principal)
{
uint8_t bytes[32] = {0}, digest[32] = {0};
ticket_t found = {0};
uint64_t epoch = 0;
esp_err_t error = ESP_ERR_INVALID_ARG;
if (token == NULL) goto done;
for (size_t i = 0; i < WEB_ADMIN_TICKET_LENGTH; ++i) {
int n = unhex(token[i]);
if (n < 0) goto done;
bytes[i / 2] |= (uint8_t)(n << ((i % 2 == 0) ? 4 : 0));
}
if (token[WEB_ADMIN_TICKET_LENGTH] != '\0') goto done;
error = ESP_ERR_INVALID_STATE;
if (!capture_epoch(&epoch)) goto done;
bool before = current(id, principal);
if (mbedtls_sha256(bytes, sizeof(bytes), digest, 0) != 0) {
(void)current(id, principal);
error = ESP_FAIL;
goto done;
}
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
if (s_state.ready && epoch == s_state.epoch) {
error = ESP_ERR_NOT_FOUND;
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t *t = &s_state.tickets[i];
if (t->generation && equal_digest(t->digest, digest)) {
found = *t;
secure_wipe(t, sizeof(*t));
increment(&s_state.consumed);
break;
}
}
}
taskEXIT_CRITICAL(&s_lock);
/* Burn precedes acting on either currentness result or identity binding. */
bool after = current(id, principal);
if (found.generation) {
now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
error = before && after && found.id == id &&
same_principal(&found.principal, principal) && s_state.ready &&
epoch == s_state.epoch && now < found.expires_at_us ?
ESP_OK : ESP_ERR_INVALID_STATE;
taskEXIT_CRITICAL(&s_lock);
}
done:
secure_wipe(bytes, sizeof(bytes));
secure_wipe(digest, sizeof(digest));
secure_wipe(&found, sizeof(found));
return result(error);
}
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *username,
size_t length)
{
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t *t = &s_state.tickets[i];
bool match = id != 0 ? t->id == id : username == NULL ||
(length == t->principal.username_length &&
length <= USER_DATABASE_USERNAME_CAPACITY &&
memcmp(username, t->principal.username, length) == 0);
if (match) secure_wipe(t, sizeof(*t));
}
taskEXIT_CRITICAL(&s_lock);
}
void web_admin_tickets_get_snapshot(web_admin_tickets_snapshot_t *snapshot)
{
if (snapshot == NULL) return;
prune();
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
*snapshot = (web_admin_tickets_snapshot_t) {
.issued = s_state.issued, .consumed = s_state.consumed,
.rejected = s_state.rejected,
.capacity_rejections = s_state.capacity_rejections,
.storage_bytes = sizeof(s_state) + sizeof(s_lock), .ready = s_state.ready,
};
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i)
if (s_state.tickets[i].generation) ++snapshot->active;
taskEXIT_CRITICAL(&s_lock);
}
+44
View File
@@ -0,0 +1,44 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "web_session_store.h"
#define WEB_ADMIN_TICKET_LENGTH 64U
#define WEB_ADMIN_TICKET_CAPACITY 2U
#define WEB_ADMIN_TICKET_LIFETIME_US 30000000LL
typedef struct {
uint32_t issued;
uint32_t consumed;
uint32_t rejected;
uint32_t capacity_rejections;
uint32_t active;
size_t storage_bytes;
bool ready;
} web_admin_tickets_snapshot_t;
/* Trusted internal API, not HTTP authorization. Start is idempotent while ready;
* stop wipes records. Neither lifecycle operation resets epochs or counters.
* RNG must already be initialized. Exhausted generations fail closed. */
void web_admin_tickets_start(void);
void web_admin_tickets_stop(void);
/* Only current password-authenticated administrators. No live eviction.
* Output must not alias inputs; all 65 bytes are wiped on failure.
* NO_MEM: capacity; INVALID_ARG: malformed input; INVALID_STATE: stopped,
* stale, unauthorized or raced; FAIL: SHA failure; RNG errors propagate. */
esp_err_t web_admin_tickets_issue(web_session_id_t id,
const user_principal_t *principal, char token[WEB_ADMIN_TICKET_LENGTH + 1U]);
/* Exact hex string (either case). Matching tickets are burned even for wrong
* session/principal or failed currentness. NOT_FOUND means no live match.
* Crypto failure cannot identify/burn a ticket. Success is not a session lease. */
esp_err_t web_admin_tickets_consume(const char *token, web_session_id_t id,
const user_principal_t *principal);
/* Caller invalidates sessions FIRST. Nonzero ID takes precedence; otherwise
* non-NULL username matches exact bytes/length; otherwise revoke all.
* Every call cancels in-flight work, even when no record matches. */
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *username,
size_t length);
/* Saturating lifetime counters; consumed counts burned matches, not admissions.
* rejected counts failed issue/consume (including capacity). Snapshot prunes
* expired/stale records; storage_bytes includes state and lock, no secrets. */
void web_admin_tickets_get_snapshot(web_admin_tickets_snapshot_t *snapshot);
+606
View File
@@ -0,0 +1,606 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* One optional admin socket. HTTPD owns IO; the canonical dispatcher owns commands. */
#include "web_admin_transport.h"
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include "admin_ssh_console.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "web_server.h"
#include "web_security.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "secure_random.h"
#include "web_admin_tickets.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#define ADMIN_POLL_US 20000ULL
#define ADMIN_INPUT_TIMEOUT_US 5000000LL
#define ADMIN_DETACH_TIMEOUT_US 2000000LL
typedef struct {
uint8_t rx[WEB_ADMIN_RX_CAPACITY];
uint8_t tx[WEB_ADMIN_TX_CAPACITY];
size_t rx_length, rx_offset;
int64_t input_deadline;
} admin_payload_t;
typedef struct {
bool occupied, active, console_open, close_requested, close_triggered, sending;
int fd;
web_session_id_t session;
user_principal_t principal;
admin_ssh_console_token_t token;
} admin_slot_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static admin_slot_t s_slot;
static admin_payload_t *s_payload; /* PSRAM; touched only by HTTPD while attached. */
static esp_timer_handle_t s_timer;
static httpd_handle_t s_server;
static bool s_initialized, s_accepting, s_queued;
static unsigned s_submitting;
static uint32_t s_generation; /* Never wrap/reuse within a boot. */
static web_admin_transport_snapshot_t s_counts;
static void count(uint32_t *value, uint32_t amount)
{
taskENTER_CRITICAL(&s_lock);
*value = UINT32_MAX - *value < amount ? UINT32_MAX : *value + amount;
taskEXIT_CRITICAL(&s_lock);
}
static bool token_matches(const admin_ssh_console_token_t *token)
{
return token && s_slot.occupied && s_slot.console_open &&
token->transport == ADMIN_CONSOLE_TRANSPORT_WEB &&
token->session_id == s_slot.token.session_id &&
token->slot_generation == s_slot.token.slot_generation &&
token->slot_index == s_slot.token.slot_index;
}
static bool owner_current(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
taskENTER_CRITICAL(&s_lock);
bool valid = token_matches(token) && s_accepting && s_slot.active && !s_slot.close_requested;
web_session_id_t id = valid ? s_slot.session : 0;
taskEXIT_CRITICAL(&s_lock);
bool current = false;
if (!valid || !principal || principal->role != USER_ROLE_ADMIN ||
web_session_store_check_principal(id, principal, &current) != ESP_OK || !current)
return false;
taskENTER_CRITICAL(&s_lock);
valid = token_matches(token) && s_accepting && s_slot.active &&
!s_slot.close_requested && s_slot.session == id;
taskEXIT_CRITICAL(&s_lock);
return valid;
}
static bool owner_drained(const admin_ssh_console_token_t *token)
{
taskENTER_CRITICAL(&s_lock);
bool drained = token_matches(token) && s_slot.active &&
!s_slot.close_requested && !s_slot.sending;
taskEXIT_CRITICAL(&s_lock);
return drained;
}
static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
admin_ssh_deferred_action_type_t action, uint32_t argument)
{
(void)argument;
if (action != ADMIN_CONSOLE_DEFER_SELF_CLOSE && action != ADMIN_SSH_DEFER_REBOOT &&
action != ADMIN_CONSOLE_DEFER_WEB_STOP &&
action != ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE) return ESP_ERR_NOT_SUPPORTED;
/* Drain is only a delivery heuristic, not an authorization lease. The
* execution task must recheck cookie/account binding after delay/queueing. */
taskENTER_CRITICAL(&s_lock);
user_principal_t principal = s_slot.principal;
taskEXIT_CRITICAL(&s_lock);
bool current = owner_current(token, &principal);
secure_wipe(&principal, sizeof(principal));
if (!current) return ESP_ERR_NOT_FOUND;
if (action == ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE) {
/* The owner mask routes this crypto/NVS path to the 12KiB dispatcher.
* Commit before restart; a failed stop retains HTTPD ownership and must
* not be followed by start. No socket IO or console handler calls here. */
bool committed = false;
return web_server_replace_identity(0, 0, false, &committed);
}
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
if (action == ADMIN_SSH_DEFER_REBOOT) {
esp_restart();
return ESP_OK;
}
taskENTER_CRITICAL(&s_lock);
bool valid = token_matches(token) && s_slot.active && s_accepting && !s_slot.close_requested;
if (valid) s_slot.close_requested = true;
taskEXIT_CRITICAL(&s_lock);
if (valid) admin_ssh_console_close(token);
return valid ? ESP_OK : ESP_ERR_NOT_FOUND;
}
static const admin_console_owner_t s_owner = {
.supported_actions = (1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE) |
(1U << ADMIN_SSH_DEFER_REBOOT) | (1U << ADMIN_CONSOLE_DEFER_WEB_STOP) |
(1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE),
.dispatcher_actions = 1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
.is_current = owner_current, .drained = owner_drained, .perform = owner_perform,
};
/* No IO and no payload mutation: safe on console/revocation/lifecycle callers. */
static void request_close(void)
{
taskENTER_CRITICAL(&s_lock);
bool opened = s_slot.console_open;
admin_ssh_console_token_t token = s_slot.token;
if (s_slot.occupied) s_slot.close_requested = true;
taskEXIT_CRITICAL(&s_lock);
if (opened) admin_ssh_console_close(&token);
}
/* HTTPD callback, or lifecycle caller ONLY after HTTPD has successfully stopped. */
static void session_free(void *context)
{
if (context != &s_slot) return;
taskENTER_CRITICAL(&s_lock);
bool occupied = s_slot.occupied;
bool opened = s_slot.console_open;
bool active = s_slot.active;
admin_ssh_console_token_t token = s_slot.token;
secure_wipe(&s_slot, sizeof(s_slot));
taskEXIT_CRITICAL(&s_lock);
if (opened) admin_ssh_console_close(&token);
if (occupied && s_payload) secure_wipe(s_payload, sizeof(*s_payload));
if (active) count(&s_counts.disconnections, 1);
}
static bool capture(admin_ssh_console_token_t *token, user_principal_t *principal, int *fd)
{
taskENTER_CRITICAL(&s_lock);
bool active = s_slot.active;
*token = s_slot.token;
*principal = s_slot.principal;
*fd = s_slot.fd;
taskEXIT_CRITICAL(&s_lock);
return active;
}
static bool input_current(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
if (owner_current(token, principal)) return true;
count(&s_counts.authorization_rejections, 1);
request_close();
return false;
}
static void discard_pending_input(void)
{
secure_wipe(s_payload->rx, sizeof(s_payload->rx));
s_payload->rx_offset = s_payload->rx_length = 0;
s_payload->input_deadline = 0;
count(&s_counts.input_backpressure, 1);
}
static bool feed_pending(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
if (s_payload->rx_offset == s_payload->rx_length) return true;
if (!input_current(token, principal)) return false;
admin_ssh_console_session_snapshot_t console;
if (admin_ssh_console_get_session_snapshot(token, &console) != ESP_OK || !console.active) {
request_close();
return false;
}
if (console.deferred_action_pending) {
/* Never replay buffered keystrokes if a deferred action fails/cancels. */
discard_pending_input();
return true;
}
if (esp_timer_get_time() >= s_payload->input_deadline) {
count(&s_counts.input_backpressure, 1);
request_close();
return false;
}
size_t consumed = 0;
(void)admin_ssh_console_feed_input(token, s_payload->rx + s_payload->rx_offset,
s_payload->rx_length - s_payload->rx_offset, &consumed);
secure_wipe(s_payload->rx + s_payload->rx_offset, consumed);
s_payload->rx_offset += consumed;
if (s_payload->rx_offset == s_payload->rx_length) {
s_payload->rx_offset = s_payload->rx_length = 0;
s_payload->input_deadline = 0;
}
return true;
}
/* Only this HTTPD work callback sends console output or requests idle closure. */
static void poll_work(void *argument)
{
httpd_handle_t server = argument;
admin_ssh_console_token_t token;
user_principal_t principal;
int fd;
bool active = capture(&token, &principal, &fd);
taskENTER_CRITICAL(&s_lock);
bool attached = s_accepting && server == s_server;
taskEXIT_CRITICAL(&s_lock);
if (!active || !attached) goto done;
if (!input_current(&token, &principal)) goto closing;
if (httpd_sess_get_ctx(server, fd) != &s_slot ||
httpd_ws_get_fd_info(server, fd) != HTTPD_WS_CLIENT_WEBSOCKET) {
request_close();
goto closing;
}
admin_ssh_console_session_snapshot_t console;
if (admin_ssh_console_get_session_snapshot(&token, &console) != ESP_OK || !console.active) {
request_close();
goto closing;
}
if (!feed_pending(&token, &principal)) goto closing;
taskENTER_CRITICAL(&s_lock);
s_slot.sending = true; /* Covers the gap between ring consumption and socket send. */
taskEXIT_CRITICAL(&s_lock);
size_t length = 0;
esp_err_t error = admin_ssh_console_read_output(&token, s_payload->tx,
sizeof(s_payload->tx), &length);
if (error == ESP_OK && length && input_current(&token, &principal)) {
httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_BINARY,
.payload = s_payload->tx, .len = length};
error = httpd_ws_send_frame_async(server, fd, &frame);
if (error == ESP_OK) count(&s_counts.tx_bytes, (uint32_t)length);
}
secure_wipe(s_payload->tx, sizeof(s_payload->tx));
taskENTER_CRITICAL(&s_lock);
s_slot.sending = false;
taskEXIT_CRITICAL(&s_lock);
if (error != ESP_OK) {
count(&s_counts.send_failures, 1);
request_close();
}
closing:
taskENTER_CRITICAL(&s_lock);
bool close = s_slot.active && s_slot.close_requested && !s_slot.close_triggered;
taskEXIT_CRITICAL(&s_lock);
if (close && httpd_sess_get_ctx(server, fd) == &s_slot) {
/* IDF's queued close retains a reusable sock_db pointer. Shutdown on
* HTTPD instead: its next read owns deletion, with no late close that
* could evict a replacement (including a serial client). */
if (shutdown(fd, SHUT_RDWR) == 0) {
taskENTER_CRITICAL(&s_lock);
s_slot.close_triggered = true;
taskEXIT_CRITICAL(&s_lock);
} else count(&s_counts.send_failures, 1); /* Retry on the next bounded poll. */
}
done:
secure_wipe(&principal, sizeof(principal));
taskENTER_CRITICAL(&s_lock);
s_queued = false;
taskEXIT_CRITICAL(&s_lock);
}
/* ESP timer task: no database/console/socket calls, no waits, one queue entry max.
* Detach prevents new submissions and fences any submission already outside lock. */
static void poll_timer(void *argument)
{
(void)argument;
taskENTER_CRITICAL(&s_lock);
httpd_handle_t server = NULL;
uint32_t generation = 0;
if (s_accepting && s_slot.active && !s_queued) {
server = s_server;
generation = s_slot.token.slot_generation;
s_queued = true;
++s_submitting;
}
taskEXIT_CRITICAL(&s_lock);
if (!server) return;
esp_err_t error = httpd_queue_work(server, poll_work, server);
taskENTER_CRITICAL(&s_lock);
--s_submitting;
if (error != ESP_OK) {
s_queued = false;
if (s_slot.active && s_slot.token.slot_generation == generation)
s_slot.close_requested = true;
}
taskEXIT_CRITICAL(&s_lock);
if (error != ESP_OK) count(&s_counts.queue_failures, 1);
}
esp_err_t web_admin_transport_init(void)
{
#if defined(CONFIG_HTTPD_QUEUE_WORK_BLOCKING) && CONFIG_HTTPD_QUEUE_WORK_BLOCKING
return ESP_ERR_NOT_SUPPORTED;
#else
if (s_initialized) return ESP_OK; /* Lifecycle caller serializes initialization. */
admin_payload_t *payload = heap_caps_calloc(1, sizeof(*payload), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
esp_err_t error = payload ? ESP_OK : ESP_ERR_NO_MEM;
esp_timer_handle_t timer = NULL;
const esp_timer_create_args_t args = {
.callback = poll_timer, .name = "web_admin", .skip_unhandled_events = true,
};
if (error == ESP_OK) error = esp_timer_create(&args, &timer);
if (error == ESP_OK) error = esp_timer_start_periodic(timer, ADMIN_POLL_US);
if (error != ESP_OK) {
if (timer) (void)esp_timer_delete(timer);
if (payload) heap_caps_free(payload);
}
taskENTER_CRITICAL(&s_lock);
if (error == ESP_OK) {
s_payload = payload;
s_timer = timer;
s_initialized = true;
}
s_counts.last_error = error;
taskEXIT_CRITICAL(&s_lock);
return error;
#endif
}
esp_err_t web_admin_transport_attach(httpd_handle_t server)
{
if (!server) return ESP_ERR_INVALID_ARG;
taskENTER_CRITICAL(&s_lock);
bool allowed = s_initialized && !s_server && !s_queued && !s_submitting && !s_slot.occupied;
taskEXIT_CRITICAL(&s_lock);
if (!allowed) return ESP_ERR_INVALID_STATE;
web_admin_tickets_start();
taskENTER_CRITICAL(&s_lock);
s_server = server;
s_accepting = true;
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t web_admin_transport_detach(httpd_handle_t server)
{
taskENTER_CRITICAL(&s_lock);
bool owned = server && server == s_server;
if (owned) s_accepting = false;
taskEXIT_CRITICAL(&s_lock);
if (!owned) return ESP_ERR_INVALID_STATE;
web_admin_tickets_stop();
request_close();
int64_t deadline = esp_timer_get_time() + ADMIN_DETACH_TIMEOUT_US;
for (;;) {
taskENTER_CRITICAL(&s_lock);
bool submitting = s_submitting != 0;
taskEXIT_CRITICAL(&s_lock);
if (!submitting) return ESP_OK;
if (esp_timer_get_time() >= deadline) return ESP_ERR_TIMEOUT;
vTaskDelay(1);
}
}
void web_admin_transport_stopped(httpd_handle_t server)
{
taskENTER_CRITICAL(&s_lock);
bool owned = server && s_server == server && !s_accepting && !s_submitting;
taskEXIT_CRITICAL(&s_lock);
if (!owned) return;
session_free(&s_slot);
taskENTER_CRITICAL(&s_lock);
s_server = NULL;
s_queued = false; /* HTTPD is gone; its queued callbacks can no longer execute. */
taskEXIT_CRITICAL(&s_lock);
}
void web_admin_transport_revoke(web_session_id_t id, const uint8_t *username, size_t length)
{
web_admin_tickets_revoke(id, username, length);
taskENTER_CRITICAL(&s_lock);
bool match = s_slot.occupied && (id ? s_slot.session == id :
!username || (length == s_slot.principal.username_length &&
length <= USER_DATABASE_USERNAME_CAPACITY &&
memcmp(username, s_slot.principal.username, length) == 0));
admin_ssh_console_token_t token = s_slot.token;
bool opened = match && s_slot.console_open;
if (match) s_slot.close_requested = true;
taskEXIT_CRITICAL(&s_lock);
if (opened) admin_ssh_console_close(&token);
}
static esp_err_t response(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return error;
}
static esp_err_t deny(httpd_req_t *request, const char *status, const char *body)
{
if (!strcmp(status, "503 Service Unavailable") &&
httpd_resp_set_hdr(request, "Retry-After", "5") != ESP_OK) return ESP_FAIL;
(void)response(request, status, body);
return ESP_FAIL; /* Close after rejection, never leave unread frames/body alive. */
}
esp_err_t web_admin_transport_ticket_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
char ticket[WEB_ADMIN_TICKET_LENGTH + 1U] = {0}, body[128] = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto cleanup;
if (view.principal.role != USER_ROLE_ADMIN) {
count(&s_counts.authorization_rejections, 1);
error = deny(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto cleanup;
}
taskENTER_CRITICAL(&s_lock);
bool attached = s_accepting && s_server == request->handle;
taskEXIT_CRITICAL(&s_lock);
error = attached ? web_admin_tickets_issue(view.id, &view.principal, ticket) : ESP_ERR_INVALID_STATE;
if (error != ESP_OK) {
if (error == ESP_ERR_NO_MEM) count(&s_counts.capacity_rejections, 1);
error = deny(request, "503 Service Unavailable", "{\"error\":\"admin_unavailable_or_capacity\"}");
goto cleanup;
}
int n = snprintf(body, sizeof(body), "{\"ticket\":\"%s\",\"expires_in\":30}", ticket);
error = n > 0 && (size_t)n < sizeof(body) ? response(request, "200 OK", body) : ESP_FAIL;
cleanup:
secure_wipe(ticket, sizeof(ticket));
secure_wipe(body, sizeof(body));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
static esp_err_t frame_handler(httpd_req_t *request)
{
admin_ssh_console_token_t token;
user_principal_t principal;
int fd;
bool active = capture(&token, &principal, &fd);
bool valid = active && request->sess_ctx == &s_slot &&
fd == httpd_req_to_sockfd(request) && input_current(&token, &principal);
if (!valid) goto failure;
httpd_ws_frame_t frame = {0};
if (httpd_ws_recv_frame(request, &frame, 0) != ESP_OK || !frame.final ||
frame.type != HTTPD_WS_TYPE_BINARY || frame.len > WEB_ADMIN_RX_CAPACITY) {
count(&s_counts.protocol_errors, 1);
goto failure;
}
admin_ssh_console_session_snapshot_t console;
if (admin_ssh_console_get_session_snapshot(&token, &console) != ESP_OK || !console.active)
goto failure;
/* Latch before the potentially blocking receive: cancellation during receive
* must not turn input observed during deferral into a new command. */
bool discard_frame = console.deferred_action_pending;
if (s_payload->rx_length != s_payload->rx_offset) {
if (!discard_frame) {
count(&s_counts.input_backpressure, 1);
goto failure;
}
/* Deferral may start before the next poll discards buffered trailing input. */
discard_pending_input();
}
frame.payload = s_payload->rx;
/* IDF treats len==0 as another header probe, not an empty payload read. */
if (frame.len && httpd_ws_recv_frame(request, &frame, sizeof(s_payload->rx)) != ESP_OK)
goto failure;
s_payload->rx_length = frame.len;
s_payload->rx_offset = 0;
s_payload->input_deadline = esp_timer_get_time() + ADMIN_INPUT_TIMEOUT_US;
if (discard_frame) discard_pending_input();
else if (!feed_pending(&token, &principal)) goto failure;
count(&s_counts.rx_bytes, (uint32_t)frame.len);
secure_wipe(&principal, sizeof(principal));
return ESP_OK;
failure:
secure_wipe(&principal, sizeof(principal));
request_close();
return ESP_FAIL;
}
esp_err_t web_admin_transport_upgrade_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
char ticket[WEB_ADMIN_TICKET_LENGTH + 1U] = {0};
admin_ssh_console_token_t token = {0};
bool allowed = false, reserved = false, opened = false;
esp_err_t error = web_cookie_auth_require(request, false, true, &view, &allowed);
if (error != ESP_OK || !allowed) goto cleanup;
if (view.principal.role != USER_ROLE_ADMIN) {
count(&s_counts.authorization_rejections, 1);
error = deny(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto cleanup;
}
static const char prefix[] = WEB_ADMIN_WS_URI "?ticket=";
if (!web_httpd_upgrade_requested(request) ||
strncmp(request->uri, prefix, sizeof(prefix) - 1U) ||
strlen(request->uri) != sizeof(prefix) - 1U + WEB_ADMIN_TICKET_LENGTH) {
error = deny(request, "400 Bad Request", "{\"error\":\"invalid_upgrade\"}");
goto cleanup;
}
memcpy(ticket, request->uri + sizeof(prefix) - 1U, WEB_ADMIN_TICKET_LENGTH);
if (web_admin_tickets_consume(ticket, view.id, &view.principal) != ESP_OK) {
count(&s_counts.authorization_rejections, 1);
error = deny(request, "403 Forbidden", "{\"error\":\"invalid_ticket\"}");
goto cleanup;
}
int socket_fd = httpd_req_to_sockfd(request);
taskENTER_CRITICAL(&s_lock);
if (socket_fd >= 0 && s_accepting && s_server == request->handle &&
!s_slot.occupied && s_generation != UINT32_MAX) {
++s_generation;
token = (admin_ssh_console_token_t){.transport = ADMIN_CONSOLE_TRANSPORT_WEB,
.session_id = s_generation, .slot_generation = s_generation};
s_slot.occupied = true;
s_slot.session = view.id;
s_slot.principal = view.principal;
s_slot.fd = socket_fd;
s_slot.token = token;
reserved = true;
}
taskEXIT_CRITICAL(&s_lock);
if (!reserved) {
count(&s_counts.capacity_rejections, 1);
error = deny(request, "503 Service Unavailable", "{\"error\":\"admin_capacity\"}");
goto cleanup;
}
error = admin_ssh_console_open_available(&token, &view.principal, &s_owner);
if (error != ESP_OK) {
count(&s_counts.capacity_rejections, 1);
error = deny(request, "503 Service Unavailable", "{\"error\":\"console_capacity_or_unavailable\"}");
goto cleanup;
}
opened = true;
bool current = false;
error = web_session_store_check_principal(view.id, &view.principal, &current);
taskENTER_CRITICAL(&s_lock);
s_slot.token = token;
s_slot.console_open = true;
bool admitted = error == ESP_OK && current && s_accepting && !s_slot.close_requested;
taskEXIT_CRITICAL(&s_lock);
if (!admitted) {
error = deny(request, "403 Forbidden", "{\"error\":\"session_revoked\"}");
goto cleanup;
}
error = web_httpd_upgrade(request, frame_handler);
if (error != ESP_OK) goto cleanup;
taskENTER_CRITICAL(&s_lock);
admitted = s_accepting && !s_slot.close_requested;
if (admitted) s_slot.active = true;
taskEXIT_CRITICAL(&s_lock);
if (!admitted) { error = ESP_FAIL; goto cleanup; }
request->sess_ctx = &s_slot;
request->free_ctx = session_free;
count(&s_counts.connections, 1);
reserved = false; /* HTTPD context now owns cleanup. */
cleanup:
if (reserved) {
if (opened) admin_ssh_console_close(&token);
session_free(&s_slot);
}
secure_wipe(ticket, sizeof(ticket));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
void web_admin_transport_get_snapshot(web_admin_transport_snapshot_t *snapshot)
{
if (!snapshot) return;
taskENTER_CRITICAL(&s_lock);
*snapshot = s_counts;
snapshot->initialized = s_initialized;
snapshot->attached = s_accepting;
snapshot->active = s_slot.active;
snapshot->closing = s_slot.close_requested;
snapshot->payload_bytes = s_payload ? sizeof(*s_payload) : 0;
snapshot->static_bytes = sizeof(s_lock) + sizeof(s_slot) + sizeof(s_payload) +
sizeof(s_timer) + sizeof(s_server) + sizeof(s_initialized) + sizeof(s_accepting) +
sizeof(s_queued) + sizeof(s_submitting) + sizeof(s_generation) + sizeof(s_counts);
taskEXIT_CRITICAL(&s_lock);
}
+42
View File
@@ -0,0 +1,42 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_http_server.h"
#include "web_session_store.h"
#define WEB_ADMIN_TICKET_URI "/api/admin/ws-ticket"
#define WEB_ADMIN_WS_URI "/ws/admin"
#define WEB_ADMIN_MAX_SESSIONS 1U
#define WEB_ADMIN_RX_CAPACITY 512U
#define WEB_ADMIN_TX_CAPACITY 1024U
typedef struct {
bool initialized, attached, active, closing;
uint32_t connections, disconnections, capacity_rejections, authorization_rejections;
uint32_t protocol_errors, input_backpressure, send_failures, queue_failures;
uint32_t rx_bytes, tx_bytes;
size_t static_bytes, payload_bytes;
esp_err_t last_error;
} web_admin_transport_snapshot_t;
/* Lifecycle caller serializes init/attach/detach/stopped. Optional PSRAM-only
* payload allocation; no internal fallback, new task, broker client or dispatcher.
* Timer only queues at most one poll; HTTPD owns all payload/IO/session cleanup. */
esp_err_t web_admin_transport_init(void);
esp_err_t web_admin_transport_attach(httpd_handle_t server);
/* Disable admission and console access, then wait a bounded time for timer
* submissions to finish. On timeout do NOT stop/free HTTPD; retry detach first. */
esp_err_t web_admin_transport_detach(httpd_handle_t server);
/* Call ONLY after successful httpd_ssl_stop, including partial startup cleanup.
* Retires any unexecuted queued poll before allowing reuse of its static storage. */
void web_admin_transport_stopped(httpd_handle_t server);
/* Ordinary HTTP routes, never register is_websocket=true: admission before 101.
* These handlers enforce cookie/Origin/CSRF/role themselves. Binary frames carry
* console bytes, final/unfragmented, at most RX_CAPACITY; no serial controls. */
esp_err_t web_admin_transport_ticket_handler(httpd_req_t *request);
esp_err_t web_admin_transport_upgrade_handler(httpd_req_t *request);
/* Notification after authoritative store invalidation. id wins; else exact
* username; else all. Safe before init. No socket calls from notifier context. */
void web_admin_transport_revoke(web_session_id_t id, const uint8_t *username, size_t length);
void web_admin_transport_get_snapshot(web_admin_transport_snapshot_t *snapshot);
+261
View File
@@ -0,0 +1,261 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_auth_parse.h"
#include <string.h>
static void wipe(void *buffer, size_t length)
{
volatile uint8_t *p = buffer;
while (length--) *p++ = 0;
}
static bool alnum_ascii(unsigned char c)
{
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9');
}
static bool authority(const char *text, size_t length, char *out)
{
if (!text || !length || length > WEB_AUTH_ORIGIN_CAPACITY - 5U) return false;
if (length >= 4U && memcmp(text + length - 4U, ":443", 4U) == 0) length -= 4U;
if (!length || length > WEB_AUTH_ORIGIN_CAPACITY - 9U) return false;
size_t label = 0;
for (size_t i = 0; i < length; ++i) {
unsigned char c = (unsigned char)text[i];
if (c == '.') {
if (!label || text[i - 1U] == '-') return false;
label = 0;
} else {
if (!alnum_ascii(c) && c != '-') return false;
if ((!label && c == '-') || ++label > 63U) return false;
}
out[i] = c >= 'A' && c <= 'Z' ? (char)(c + ('a' - 'A')) : (char)c;
}
if (!label || text[length - 1U] == '-') return false;
out[length] = 0;
return true;
}
bool web_auth_parse_origin(const char *host, size_t host_length,
const char *origin, size_t origin_length,
char canonical[WEB_AUTH_ORIGIN_CAPACITY])
{
if (!canonical) return false;
memset(canonical, 0, WEB_AUTH_ORIGIN_CAPACITY);
char other[WEB_AUTH_ORIGIN_CAPACITY] = {0};
if (!origin || origin_length < 9U || origin_length > WEB_AUTH_ORIGIN_CAPACITY + 3U ||
memcmp(origin, "https://", 8U) != 0 ||
!authority(host, host_length, canonical + 8U) ||
!authority(origin + 8U, origin_length - 8U, other) ||
strcmp(canonical + 8U, other) != 0) {
memset(canonical, 0, WEB_AUTH_ORIGIN_CAPACITY);
return false;
}
memcpy(canonical, "https://", 8U);
return true;
}
static bool cookie_name_char(unsigned char c)
{
return alnum_ascii(c) || (c && strchr("!#$%&'*+-.^_`|~", c));
}
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present)
{
if (!present) return false;
*present = false;
if (!token) return false;
memset(token, 0, WEB_AUTH_TOKEN_LENGTH + 1U);
if (!header || !name || !*name || !length || length > WEB_AUTH_COOKIE_HEADER_MAX)
return false;
size_t pos = 0, selected = 0, name_length = strlen(name);
bool found = false;
while (pos < length) {
while (pos < length && header[pos] == ' ') ++pos;
size_t start = pos;
while (pos < length && cookie_name_char((unsigned char)header[pos])) ++pos;
size_t key_length = pos - start;
if (!key_length || pos == length || header[pos++] != '=') return false;
size_t value = pos;
while (pos < length && header[pos] != ';') {
unsigned char c = (unsigned char)header[pos++];
if (c < 0x21 || c > 0x7e || c == '"' || c == ',' || c == '\\') return false;
}
if (key_length == name_length && memcmp(header + start, name, key_length) == 0) {
if (found || pos - value != WEB_AUTH_TOKEN_LENGTH) return false;
for (size_t i = value; i < pos; ++i)
if (!((header[i] >= '0' && header[i] <= '9') ||
(header[i] >= 'a' && header[i] <= 'f'))) return false;
found = true;
selected = value;
}
if (pos < length && ++pos == length) return false;
}
if (found) memcpy(token, header + selected, WEB_AUTH_TOKEN_LENGTH);
*present = found;
return true;
}
bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
char token[WEB_AUTH_TOKEN_LENGTH + 1U])
{
bool present = false;
return web_auth_parse_optional_cookie(header, length, name, token, &present) && present;
}
typedef struct { const uint8_t *data; size_t length; size_t pos; } json_cursor_t;
static void whitespace(json_cursor_t *c)
{
while (c->pos < c->length) {
uint8_t b = c->data[c->pos];
if (b != ' ' && b != '\t' && b != '\r' && b != '\n') break;
++c->pos;
}
}
static bool take(json_cursor_t *c, uint8_t byte)
{
whitespace(c);
if (c->pos == c->length || c->data[c->pos] != byte) return false;
++c->pos;
return true;
}
static bool hex4(json_cursor_t *c, uint32_t *value)
{
*value = 0;
for (unsigned i = 0; i < 4; ++i) {
if (c->pos == c->length) return false;
uint8_t b = c->data[c->pos++];
unsigned digit;
if (b >= '0' && b <= '9') digit = b - '0';
else if (b >= 'a' && b <= 'f') digit = b - 'a' + 10U;
else if (b >= 'A' && b <= 'F') digit = b - 'A' + 10U;
else return false;
*value = (*value << 4) | digit;
}
return true;
}
static bool codepoint(json_cursor_t *c, uint32_t *value)
{
if (c->pos == c->length) return false;
uint8_t b = c->data[c->pos++];
if (b == '\\') {
if (c->pos == c->length) return false;
b = c->data[c->pos++];
switch (b) {
case '"': case '\\': case '/': *value = b; return true;
case 'b': *value = 8; return true;
case 'f': *value = 12; return true;
case 'n': *value = 10; return true;
case 'r': *value = 13; return true;
case 't': *value = 9; return true;
case 'u': break;
default: return false;
}
if (!hex4(c, value)) return false;
if (*value >= 0xd800 && *value <= 0xdbff) {
uint32_t low;
if (c->length - c->pos < 2U || c->data[c->pos++] != '\\' ||
c->data[c->pos++] != 'u' || !hex4(c, &low) ||
low < 0xdc00 || low > 0xdfff) return false;
*value = 0x10000 + ((*value - 0xd800) << 10) + low - 0xdc00;
}
return *value && !(*value >= 0xd800 && *value <= 0xdfff);
}
if (b < 0x20) return false;
if (b < 0x80) { *value = b; return true; }
unsigned extra;
uint32_t minimum;
if (b >= 0xc2 && b <= 0xdf) { extra = 1; minimum = 0x80; *value = b & 0x1f; }
else if (b >= 0xe0 && b <= 0xef) { extra = 2; minimum = 0x800; *value = b & 0x0f; }
else if (b >= 0xf0 && b <= 0xf4) { extra = 3; minimum = 0x10000; *value = b & 7; }
else return false;
while (extra--) {
if (c->pos == c->length) return false;
b = c->data[c->pos++];
if ((b & 0xc0) != 0x80) return false;
*value = (*value << 6) | (b & 0x3f);
}
return *value >= minimum && *value <= 0x10ffff &&
!(*value >= 0xd800 && *value <= 0xdfff);
}
static bool string(json_cursor_t *c, uint8_t *out, size_t capacity, size_t *length)
{
*length = 0;
if (!take(c, '"')) return false;
while (c->pos < c->length && c->data[c->pos] != '"') {
uint32_t cp;
if (!codepoint(c, &cp)) return false;
size_t bytes = cp < 0x80 ? 1U : cp < 0x800 ? 2U : cp < 0x10000 ? 3U : 4U;
if (bytes > capacity - *length) return false;
if (bytes == 1U) out[(*length)++] = (uint8_t)cp;
else {
out[(*length)++] = (uint8_t)((bytes == 2U ? 0xc0 : bytes == 3U ? 0xe0 : 0xf0) |
(cp >> (6U * (bytes - 1U))));
for (size_t i = bytes - 1U; i > 0; --i)
out[(*length)++] = (uint8_t)(0x80 | ((cp >> (6U * (i - 1U))) & 0x3f));
}
}
if (c->pos == c->length) return false;
++c->pos;
out[*length] = 0;
return true;
}
bool web_auth_parse_json_string(const char *body, size_t length, size_t *position,
uint8_t *output, size_t capacity, size_t *decoded_length)
{
if (output && capacity) wipe(output, capacity);
if (decoded_length) *decoded_length = 0;
if (!body || !position || *position > length || !output || !capacity || !decoded_length)
return false;
json_cursor_t c = { (const uint8_t *)body, length, *position };
if (!string(&c, output, capacity - 1U, decoded_length)) {
wipe(output, capacity);
*decoded_length = 0;
return false;
}
*position = c.pos;
return true;
}
bool web_auth_parse_login(const char *body, size_t length,
web_auth_credentials_t *credentials)
{
if (!credentials) return false;
wipe(credentials, sizeof(*credentials));
if (!body || !length || length > WEB_AUTH_LOGIN_BODY_MAX) return false;
json_cursor_t c = { (const uint8_t *)body, length, 0 };
unsigned seen = 0;
if (!take(&c, '{')) return false;
for (unsigned field = 0; field < 2; ++field) {
uint8_t key[9] = {0};
size_t key_length;
if ((field && !take(&c, ',')) || !string(&c, key, 8U, &key_length) ||
!take(&c, ':')) goto invalid;
unsigned bit;
uint8_t *output;
size_t *output_length, capacity;
if (key_length == 8U && memcmp(key, "username", 8U) == 0) {
bit = 1; output = credentials->username;
output_length = &credentials->username_length; capacity = WEB_AUTH_USERNAME_MAX;
} else if (key_length == 8U && memcmp(key, "password", 8U) == 0) {
bit = 2; output = credentials->password;
output_length = &credentials->password_length; capacity = WEB_AUTH_PASSWORD_MAX;
} else goto invalid;
if ((seen & bit) || !string(&c, output, capacity, output_length)) goto invalid;
seen |= bit;
}
if (!take(&c, '}')) goto invalid;
whitespace(&c);
if (c.pos == c.length && seen == 3U) return true;
invalid:
wipe(credentials, sizeof(*credentials));
return false;
}
+53
View File
@@ -0,0 +1,53 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Private, allocation-free parsing only: these helpers do not authorize requests. */
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define WEB_AUTH_ORIGIN_CAPACITY 129U
#define WEB_AUTH_COOKIE_HEADER_MAX 1024U
#define WEB_AUTH_TOKEN_LENGTH 64U
#define WEB_AUTH_LOGIN_BODY_MAX 512U
#define WEB_AUTH_USERNAME_MAX 16U
#define WEB_AUTH_PASSWORD_MAX 64U
typedef struct {
size_t username_length;
size_t password_length;
uint8_t username[WEB_AUTH_USERNAME_MAX + 1U];
uint8_t password[WEB_AUTH_PASSWORD_MAX + 1U];
} web_auth_credentials_t;
/* Exact byte spans, not necessarily NUL-terminated. Inputs and output must not
* alias. Failures clear output. Host supports ASCII DNS/IPv4 authorities only;
* IPv6 literals are deliberately rejected until the device supports that route.
* Only optional :443 is accepted. Origin is mandatory and must match Host.
* HTTP callers must separately reject duplicate header lines, enforce methods,
* body/content-type limits, Fetch Metadata and CSRF/session policy. */
bool web_auth_parse_origin(const char *host, size_t host_length,
const char *origin, size_t origin_length,
char canonical[WEB_AUTH_ORIGIN_CAPACITY]);
/* Extract exactly one named lowercase-hex token; malformed/duplicate or missing
* selected cookie fails. Other cookies are syntax-checked but not retained.
* This deliberately accepts only unquoted cookie values, including unrelated
* cookies; quoted values fail closed. No whitespace inside a cookie pair.
* name is a trusted, nonempty C string. Output is sensitive: wipe after use. */
bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
char token[WEB_AUTH_TOKEN_LENGTH + 1U]);
/* As above, but a missing selected cookie is valid with present=false. This
* lets HTTP policy distinguish absence from malformed/ambiguous cookies. */
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present);
/* Decode one string at *position (including optional JSON whitespace). Capacity
* includes the terminator. Failure wipes output and leaves position unchanged.
* Success output is sensitive; caller must wipe it. Same strict decoder as login. */
bool web_auth_parse_json_string(const char *body, size_t length, size_t *position,
uint8_t *output, size_t capacity, size_t *decoded_length);
/* Exactly username/password string fields, either order. JSON escapes and valid
* UTF-8 accepted; unknown/duplicate fields, NUL and malformed Unicode rejected.
* Database credential policy remains authoritative. Caller must wipe BOTH the
* original request body and successful credentials using secure_wipe(). */
bool web_auth_parse_login(const char *body, size_t length,
web_auth_credentials_t *credentials);
+234
View File
@@ -0,0 +1,234 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_broker_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "session_broker.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"};
typedef struct {
uint32_t id;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
uint32_t generation, target;
unsigned state;
} broker_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static broker_operation_t s_operation;
static uint32_t s_next_id;
/* Narrow flat JSON: exact action plus two unsigned decimal integers, no
* escapes, duplicates, unknown fields, nesting, fractions or exponents. */
static bool parse(const char *body, size_t length, broker_operation_t *operation)
{
const char *keys[] = {"action", "generation", "target"};
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) {
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)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 3 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
if (key == 0) {
const char action[] = "\"assign\"";
if (length - pos < sizeof(action) - 1 || memcmp(body + pos, action, sizeof(action) - 1)) return false;
pos += sizeof(action) - 1;
} else {
uint32_t number = 0;
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
if (key == 1) operation->generation = number;
else operation->target = number;
}
seen |= 1U << key;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length && seen == 7 && operation->target &&
operation->generation && operation->generation != UINT32_MAX;
}
void web_broker_settings_execute(uint32_t id)
{
broker_operation_t operation;
taskENTER_CRITICAL(&s_lock);
operation = s_operation;
taskEXIT_CRITICAL(&s_lock);
if (!id || operation.id != id || operation.state != PENDING) {
secure_wipe(&operation, sizeof(operation));
return;
}
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
error = session_broker_assign_writer_current(operation.target, operation.generation);
state = error == ESP_OK ? OK :
(error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
esp_err_t web_broker_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
bool mutation = request->method == HTTP_POST;
esp_err_t error = mutation
? web_cookie_auth_require_json(request, 256, &view, &allowed)
: web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
broker_operation_t operation = {0};
if (mutation) {
char type[40] = {0}, body[256];
size_t received = 0;
bool valid = request->content_len && request->content_len <= sizeof(body) &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_broker_request\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id;
s_operation = operation;
}
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_broker_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
secure_wipe(&operation, sizeof(operation));
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id;
operation.state = s_operation.state;
}
taskEXIT_CRITICAL(&s_lock);
}
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? "assign" : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
secure_wipe(&operation, sizeof(operation));
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_broker_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
session_broker_management_snapshot_t snapshot;
error = session_broker_get_management_snapshot(&snapshot);
if (error != ESP_OK) {
error = respond(request, "503 Service Unavailable", "{\"error\":\"broker_unavailable\"}");
goto done;
}
/* Eight rows; names are exact bounded bytes as hex, never unescaped JSON.
* Decimal-string drop counters retain all 64 bits in the browser. */
char response[2048];
int written = snprintf(response, sizeof(response),
"{\"generation\":%" PRIu32 ",\"writer\":%" PRIu32 ",\"clients\":[",
snapshot.generation, snapshot.writer_id);
size_t used = 0;
if (written < 0 || (size_t)written >= sizeof(response)) { error = ESP_FAIL; goto done; }
used = (size_t)written;
for (size_t i = 0; i < snapshot.count; ++i) {
const session_broker_management_client_t *client = &snapshot.clients[i];
char name[SESSION_BROKER_CLIENT_NAME_MAX * 2 + 1];
static const char hex[] = "0123456789abcdef";
size_t n = 0;
for (; n < SESSION_BROKER_CLIENT_NAME_MAX && client->name[n]; ++n) {
unsigned byte = (unsigned char)client->name[n];
name[n * 2] = hex[byte >> 4]; name[n * 2 + 1] = hex[byte & 15];
}
name[n * 2] = 0;
written = snprintf(response + used, sizeof(response) - used,
"%s{\"id\":%" PRIu32 ",\"type\":%u,\"name_hex\":\"%s\",\"pending\":%u,\"high_water\":%u,\"dropped\":\"%" PRIu64 "\"}",
i ? "," : "", client->id, (unsigned)client->type, name,
(unsigned)client->pending, (unsigned)client->high_water, client->dropped);
if (written < 0 || (size_t)written >= sizeof(response) - used) { error = ESP_FAIL; goto done; }
used += (size_t)written;
}
written = snprintf(response + used, sizeof(response) - used, "]}");
error = written < 0 || (size_t)written >= sizeof(response) - used ? ESP_FAIL :
respond(request, "200 OK", response);
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+9
View File
@@ -0,0 +1,9 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* Optional admin-only snapshot and login-isolated typed assignment/results. */
esp_err_t web_broker_settings_handler(httpd_req_t *request);
esp_err_t web_broker_operation_handler(httpd_req_t *request);
void web_broker_settings_execute(uint32_t id);
+124 -145
View File
@@ -1,30 +1,33 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* UART0 HTTPS lifecycle, legacy recovery credential, and certificate commands. */
/* HTTPS lifecycle and TLS certificate commands. */
#include "web_console.h"
#include "admin_ssh_console.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "esp_console.h"
#include "secure_random.h"
#include "ssh_transport.h"
#include "user_database.h"
#include "web_security.h"
#include "web_serial_transport.h"
#include "web_server.h"
#include "web_cookie_auth.h"
#include "web_admin_transport.h"
#include "web_admin_tickets.h"
#include "web_diagnostics.h"
static void print_usage(void)
{
printf("Usage:\n");
printf(" web status|start|stop\n");
printf(" web counters|clear-counters\n");
printf(" web credentials show\n");
printf(" web credentials rotate --force\n");
printf(" web diagnostics enable|disable|show|clear\n");
printf(" web performance enable|disable|show|clear\n");
printf(" web certificate info\n");
printf(" web certificate rotate --force\n");
printf(" web reset --force\n");
printf(" web reset --force (TLS certificate and private key only)\n");
}
static void print_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH])
@@ -34,6 +37,30 @@ static void print_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LENG
}
}
static void show_admin_transport(void)
{
web_admin_transport_snapshot_t admin;
web_admin_tickets_snapshot_t tickets;
web_admin_transport_get_snapshot(&admin);
web_admin_tickets_get_snapshot(&tickets);
printf("WebSocket admin: initialized=%s attached=%s active=%s/1 closing=%s init-error=%s\n",
admin.initialized ? "yes" : "no", admin.attached ? "yes" : "no",
admin.active ? "yes" : "no", admin.closing ? "yes" : "no", esp_err_to_name(admin.last_error));
printf(" tickets=%" PRIu32 "/%u issued=%" PRIu32 " consumed=%" PRIu32
" rejected=%" PRIu32 " capacity=%" PRIu32 "\n",
tickets.active, WEB_ADMIN_TICKET_CAPACITY, tickets.issued, tickets.consumed,
tickets.rejected, tickets.capacity_rejections);
printf(" connected=%" PRIu32 " disconnected=%" PRIu32 " capacity=%" PRIu32
" authorization=%" PRIu32 " protocol=%" PRIu32 " input-backpressure=%" PRIu32 "\n",
admin.connections, admin.disconnections, admin.capacity_rejections,
admin.authorization_rejections, admin.protocol_errors, admin.input_backpressure);
printf(" rx-bytes=%" PRIu32 " tx-bytes=%" PRIu32 " send-failures=%" PRIu32
" queue-failures=%" PRIu32 " static=%u ticket-storage=%u PSRAM-payload=%u bytes\n",
admin.rx_bytes, admin.tx_bytes, admin.send_failures, admin.queue_failures,
(unsigned)admin.static_bytes, (unsigned)tickets.storage_bytes, (unsigned)admin.payload_bytes);
printf(" Admin counters are saturating lifetime counts (not reset by web clear-counters).\n");
}
static int show_status(void)
{
web_server_snapshot_t snapshot;
@@ -52,13 +79,26 @@ static int show_status(void)
(unsigned int)snapshot.port,
esp_err_to_name(snapshot.last_error));
if (users_error == ESP_OK) {
printf("Authentication: HTTP Basic over TLS via user database, users=%u admins=%u\n",
printf("Authentication: HTTPS cookie sessions via user database, users=%u admins=%u\n",
(unsigned int)users.user_count, (unsigned int)users.admin_count);
} else {
printf("Authentication database unavailable: %s; use 'user recover --force'.\n",
esp_err_to_name(users_error));
}
printf("Endpoints: GET /, GET /api/status, POST /api/ws-ticket, WSS /ws/serial\n");
printf("Authentication routes: GET /login, GET /api/login-challenge, POST /api/login, GET /api/session, POST /api/logout\n");
printf("Admin-only backend: POST /api/admin/ws-ticket, WSS /ws/admin (no normal UI entry)\n");
show_admin_transport();
web_cookie_auth_snapshot_t auth;
web_cookie_auth_get_snapshot(&auth);
web_session_store_snapshot_t sessions;
if (web_session_store_get_snapshot(&sessions) == ESP_OK)
printf("Cookie authentication: ready=%s sessions=%" PRIu32 "/4 challenges=%" PRIu32 "/4\n",
auth.ready ? "yes" : "no", sessions.active, auth.active_challenges);
printf("Login attempts=%" PRIu32 " invalid-credentials=%" PRIu32 " throttled=%" PRIu32
" auth-capacity-rejections=%" PRIu32 " CSRF/origin-rejections=%" PRIu32 " logouts=%" PRIu32 "\n",
auth.login_attempts, auth.login_failures, auth.throttled, auth.capacity_rejections,
auth.security_rejections, auth.logouts);
web_serial_transport_snapshot_t transport;
esp_err_t transport_error = web_serial_transport_get_snapshot(&transport);
@@ -110,6 +150,7 @@ static int show_counters(void)
return 1;
}
show_admin_transport();
const web_server_counters_t *counter = &snapshot.counters;
printf("Lifecycle: starts=%" PRIu64 " start-failures=%" PRIu64
" stops=%" PRIu64 "\n",
@@ -163,25 +204,6 @@ static int show_counters(void)
return 0;
}
static int show_credentials(void)
{
web_security_credentials_t credentials;
esp_err_t error = web_security_show_credentials(&credentials);
if (error != ESP_OK) {
printf("Could not read web credentials: %s\n", esp_err_to_name(error));
return 1;
}
printf("Username: %.*s\n", (int)credentials.username_length,
credentials.username);
printf("Password: %.*s\n", (int)credentials.password_length,
credentials.password);
printf("Phase 8B uses the user database for HTTPS and SSH authentication.\n");
printf("This legacy credential is retained only for migration and physical recovery.\n");
secure_wipe(&credentials, sizeof(credentials));
return 0;
}
static int show_certificate(void)
{
web_security_certificate_metadata_t metadata;
@@ -212,125 +234,65 @@ static bool force_is_present(int argc, char **argv, int expected_argc)
return argc == expected_argc && strcmp(argv[expected_argc - 1], "--force") == 0;
}
static int restart_if_running(bool was_running)
static int replace_material(bool reset)
{
if (!was_running) {
return 0;
}
esp_err_t error = web_server_stop();
bool committed = false;
esp_err_t error = web_server_replace_identity(0, 0, reset, &committed);
if (error != ESP_OK) {
printf("Material changed, but the old TLS server could not stop: %s\n",
esp_err_to_name(error));
return 1;
}
error = web_server_start();
if (error != ESP_OK) {
printf("Material changed, but HTTPS could not restart: %s\n",
esp_err_to_name(error));
printf("%s: %s\n", committed
? "New HTTPS identity persisted, but stop/start failed; no rollback. Inspect via UART0 before retrying"
: "HTTPS identity replacement rejected or failed before publication",
esp_err_to_name(error));
return 1;
}
printf("HTTPS certificate and private key replaced and persisted; user accounts unchanged.\n");
printf("Verify the new fingerprint via trusted UART0, renew browser trust, and sign in again.\n");
return 0;
}
static void synchronize_migrated_user(
const web_security_credentials_t *credentials)
{
const user_database_legacy_credentials_t legacy = {
.username = (const uint8_t *)credentials->username,
.username_length = credentials->username_length,
.password = (const uint8_t *)credentials->password,
.password_length = credentials->password_length,
};
bool synchronized = false;
esp_err_t error = user_database_sync_legacy_credentials(&legacy, &synchronized);
if (error != ESP_OK) {
printf("Warning: migrated user synchronization failed: %s. Boot will retry a valid stored database; otherwise use 'user recover --force'.\n",
esp_err_to_name(error));
return;
}
if (synchronized) {
(void)web_serial_transport_revoke_user(
(const uint8_t *)credentials->username,
credentials->username_length);
(void)ssh_transport_revoke_user(
(const uint8_t *)credentials->username,
credentials->username_length);
printf("The pre-bootstrap migrated user credential was synchronized.\n");
return;
}
static int rotate_certificate(void) { return replace_material(false); }
static int reset_material(void) { return replace_material(true); }
user_database_snapshot_t snapshot;
if (user_database_get_snapshot(&snapshot) == ESP_OK &&
snapshot.admin_bootstrapped) {
printf("This legacy recovery credential is separate from role-based user passwords.\n");
} else {
printf("Warning: no matching pre-bootstrap migrated user was synchronized; establish an administrator with 'user bootstrap'.\n");
}
static void print_performance_time(const char *name, const web_serial_performance_timing_t *t)
{
printf(" %s: count=%" PRIu64 " sum_us=%" PRIu64 " avg_us_est=%" PRIu64 " max_us=%" PRIu64 "\n",
name, t->count, t->sum_us, t->count ? t->sum_us / t->count : 0, t->max_us);
}
static int rotate_credentials(void)
static int performance_command(const char *action)
{
web_security_credentials_t credentials;
esp_err_t error = web_security_rotate_credentials(&credentials);
if (error != ESP_OK) {
printf("Could not rotate web credentials: %s\n", esp_err_to_name(error));
esp_err_t result = ESP_OK;
if (!strcmp(action, "enable")) result = web_serial_performance_enable(true);
else if (!strcmp(action, "disable")) result = web_serial_performance_enable(false);
else if (!strcmp(action, "clear")) result = web_serial_performance_clear();
else if (strcmp(action, "show")) return 1;
if (result != ESP_OK) {
printf("Web performance: %s\n", esp_err_to_name(result));
return 1;
}
synchronize_migrated_user(&credentials);
printf("Legacy migration/recovery credential rotated and persisted.\n");
printf("Username: %.*s\nPassword: %.*s\n",
(int)credentials.username_length, credentials.username,
(int)credentials.password_length, credentials.password);
secure_wipe(&credentials, sizeof(credentials));
return 0;
}
static int rotate_certificate(void)
{
web_server_snapshot_t snapshot;
esp_err_t error = web_server_get_snapshot(&snapshot);
if (error != ESP_OK) {
printf("Could not inspect HTTPS runtime: %s\n", esp_err_to_name(error));
return 1;
web_serial_performance_snapshot_t s;
web_serial_performance_snapshot(&s);
printf("Web performance: enabled=%u epoch=%" PRIu32 " epoch_exhausted=%u; binary TX only\n",
s.enabled, s.epoch, s.epoch_exhausted);
printf("Send-call return is synchronous HTTPD-owner bytes send, not peer receipt. Timings are instrumented estimates; saturated=1 invalidates averages/count totals.\n");
printf("Completion->nonempty includes idle gaps; first-nonempty excludes observed empty attempts, not proof of backlog.\n");
for (unsigned i = 0; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) {
const web_serial_performance_session_t *r = &s.sessions[i];
if (!r->active) continue;
printf("slot=%u fd=%d generation=%" PRIu32 " broker=%" PRIu32
" pending=%u measured_pending=%u executing=%u pending_age_us=%" PRIu64 " saturated=%u\n",
i, r->socket_fd, r->generation, (uint32_t)r->broker_client_id,
r->pending, r->measured_pending, r->executing, r->pending_age_us, r->saturated);
printf(" queued_frames=%" PRIu64 " queued_bytes=%" PRIu64 " queue_errors=%" PRIu64
" sent_frames=%" PRIu64 " sent_bytes=%" PRIu64 " send_errors=%" PRIu64 " retired=%" PRIu64 "\n",
r->queued_frames, r->queued_bytes, r->queue_errors, r->sent_frames,
r->sent_bytes, r->send_errors, r->retired);
print_performance_time("queue->callback-entry", &r->queue_wait);
print_performance_time("send-call", &r->send_call);
print_performance_time("completion->first-drain-attempt-return", &r->completion_attempt);
print_performance_time("completion->next-nonempty-drain-return (includes idle)", &r->completion_nonempty);
print_performance_time("completion->first-attempt-nonempty-return", &r->completion_first_nonempty);
}
error = web_security_rotate_certificate();
if (error != ESP_OK) {
printf("Could not rotate web certificate: %s\n", esp_err_to_name(error));
return 1;
}
printf("Web certificate and private key rotated and persisted.\n");
return restart_if_running(snapshot.running);
}
static int reset_material(void)
{
web_server_snapshot_t snapshot;
bool was_running = web_server_get_snapshot(&snapshot) == ESP_OK && snapshot.running;
web_security_credentials_t credentials;
esp_err_t error = web_security_reset_all(&credentials);
if (error != ESP_OK) {
printf("Could not reset web security material: %s\n", esp_err_to_name(error));
return 1;
}
synchronize_migrated_user(&credentials);
printf("Legacy recovery credential, HTTPS certificate, and HTTPS private key replaced and persisted.\n");
printf("Username: %.*s\nPassword: %.*s\n",
(int)credentials.username_length, credentials.username,
(int)credentials.password_length, credentials.password);
secure_wipe(&credentials, sizeof(credentials));
if (was_running) {
return restart_if_running(true);
}
error = web_server_start();
if (error != ESP_OK) {
printf("Security material recovered, but HTTPS could not start: %s\n",
esp_err_to_name(error));
return 1;
}
printf("HTTPS started with the recovered security material.\n");
return 0;
}
@@ -340,6 +302,16 @@ static int command_web(int argc, char **argv)
print_usage();
return 0;
}
if (argc == 3 && strcmp(argv[1], "performance") == 0) {
if (performance_command(argv[2]) == 0) return 0;
print_usage();
return 1;
}
if (argc == 3 && strcmp(argv[1], "diagnostics") == 0) {
if (web_diagnostics_command(argv[2]) == 0) return 0;
print_usage();
return 1;
}
if (argc == 2 && strcmp(argv[1], "status") == 0) {
return show_status();
}
@@ -353,6 +325,15 @@ static int command_web(int argc, char **argv)
return 0;
}
if (argc == 2 && strcmp(argv[1], "stop") == 0) {
if (admin_ssh_console_dispatch_is_web()) {
esp_err_t error = admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_STOP, 0U);
if (error != ESP_OK) {
printf("Could not schedule HTTPS stop: %s\n", esp_err_to_name(error));
return 1;
}
printf("HTTPS stop scheduled after console output drains; both browser connections will close.\n");
return 0;
}
esp_err_t error = web_server_stop();
if (error != ESP_OK) {
printf("Could not stop HTTPS: %s\n", esp_err_to_name(error));
@@ -376,18 +357,6 @@ static int command_web(int argc, char **argv)
printf("HTTPS and WebSocket counters cleared.\n");
return 0;
}
if (argc == 3 && strcmp(argv[1], "credentials") == 0 &&
strcmp(argv[2], "show") == 0) {
return show_credentials();
}
if (strcmp(argv[1], "credentials") == 0 && argc >= 3 &&
strcmp(argv[2], "rotate") == 0) {
if (!force_is_present(argc, argv, 4)) {
printf("Credential rotation requires: web credentials rotate --force\n");
return 1;
}
return rotate_credentials();
}
if (argc == 3 && strcmp(argv[1], "certificate") == 0 &&
strcmp(argv[2], "info") == 0) {
return show_certificate();
@@ -398,11 +367,21 @@ static int command_web(int argc, char **argv)
printf("Certificate rotation requires: web certificate rotate --force\n");
return 1;
}
if (admin_ssh_console_dispatch_is_web()) {
esp_err_t error = admin_ssh_console_dispatch_defer(
ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE, 0U);
if (error != ESP_OK) {
printf("Could not schedule HTTPS certificate rotation: %s\n", esp_err_to_name(error));
return 1;
}
printf("HTTPS identity rotation scheduled after console output drains; all web logins and browser terminals will close. A new identity may persist even if stop/start fails; no rollback. Verify the new fingerprint via trusted UART0 web certificate info before renewing browser trust, then reload and sign in. SSH and USB UART1 access remain independent.\n");
return 0;
}
return rotate_certificate();
}
if (strcmp(argv[1], "reset") == 0) {
if (!force_is_present(argc, argv, 3)) {
printf("Full material replacement requires: web reset --force\n");
printf("TLS-only certificate/private-key replacement requires: web reset --force\n");
return 1;
}
return reset_material();
@@ -416,7 +395,7 @@ esp_err_t web_console_register_commands(void)
{
const esp_console_cmd_t command = {
.command = "web",
.help = "Manage authenticated HTTPS and recover web credentials/certificate",
.help = "Manage authenticated HTTPS and recover TLS certificate/private key",
.hint = NULL,
.func = &command_web,
.argtable = NULL,
+429
View File
@@ -0,0 +1,429 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_cookie_auth.h"
#include <stdio.h>
#include <string.h>
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "mbedtls/sha256.h"
#include "secure_random.h"
#include "web_auth_parse.h"
#include "web_httpd_adapter.h"
#include "web_login_ui.h"
#include "web_serial_transport.h"
#define SESSION_COOKIE "__Host-sak-session"
#define PRELOGIN_COOKIE "__Host-sak-prelogin"
#define COOKIE_FLAGS "; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age="
#define CHALLENGE_US 120000000LL
#define WINDOW_US 60000000LL
typedef struct {
int64_t expiry;
uint8_t token_digest[32];
uint8_t origin_digest[32];
char csrf[65];
} challenge_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static challenge_t s_challenges[4];
static bool s_ready;
static uint64_t s_epoch;
static int64_t s_window;
static unsigned s_attempts;
static web_cookie_auth_snapshot_t s_counts;
static bool equal(const void *a, const void *b, size_t size)
{
const uint8_t *x = a, *y = b;
unsigned difference = 0;
for (size_t i = 0; i < size; ++i) difference |= x[i] ^ y[i];
return difference == 0;
}
static bool header(httpd_req_t *r, const char *name, char *out, size_t size)
{
size_t length = httpd_req_get_hdr_value_len(r, name);
out[0] = 0;
return length < size && httpd_req_get_hdr_value_str(r, name, out, size) == ESP_OK;
}
static bool origin(httpd_req_t *r, bool required, char canonical[129])
{
char host[129] = {0}, supplied[137] = {0}, site[16] = {0};
if (!header(r, "Host", host, sizeof(host))) return false;
if (header(r, "Sec-Fetch-Site", site, sizeof(site))) {
if (strcmp(site, "same-origin") && strcmp(site, "none")) return false;
} else if (httpd_req_get_hdr_value_len(r, "Sec-Fetch-Site")) return false;
if (!header(r, "Origin", supplied, sizeof(supplied))) {
if (required || httpd_req_get_hdr_value_len(r, "Origin")) return false;
int length = snprintf(supplied, sizeof(supplied), "https://%s", host);
if (length < 0 || (size_t)length >= sizeof(supplied)) return false;
}
return web_auth_parse_origin(host, strlen(host), supplied, strlen(supplied), canonical);
}
static bool cookie(httpd_req_t *r, const char *name, char token[65])
{
char cookies[1025] = {0};
bool valid = header(r, "Cookie", cookies, sizeof(cookies)) &&
web_auth_parse_cookie(cookies, strlen(cookies), name, token);
secure_wipe(cookies, sizeof(cookies));
return valid;
}
static bool cookies_valid(httpd_req_t *r)
{
char cookies[1025] = {0}, token[65] = {0};
bool present;
esp_err_t error = httpd_req_get_hdr_value_str(r, "Cookie", cookies, sizeof(cookies));
bool valid = error == ESP_ERR_NOT_FOUND ||
(error == ESP_OK && httpd_req_get_hdr_value_len(r, "Cookie") < sizeof(cookies) &&
web_auth_parse_optional_cookie(cookies, strlen(cookies), SESSION_COOKIE, token, &present) &&
web_auth_parse_optional_cookie(cookies, strlen(cookies), PRELOGIN_COOKIE, token, &present));
secure_wipe(cookies, sizeof(cookies));
secure_wipe(token, sizeof(token));
return valid;
}
static esp_err_t response(httpd_req_t *r, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(r, status);
if (error == ESP_OK) error = httpd_resp_set_type(r, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Frame-Options", "DENY");
if (error == ESP_OK) error = httpd_resp_sendstr(r, body);
/* Never let HTTPD drain an attacker-controlled rejected request body. */
return web_httpd_unread_body(r) ? ESP_FAIL : error;
}
static esp_err_t failure(httpd_req_t *r, const char *status, const char *code)
{
bool security = !strcmp(status, "403 Forbidden");
bool credentials = !strcmp(code, "invalid_credentials");
bool throttled = !strcmp(code, "throttled");
bool full = !strcmp(code, "capacity");
taskENTER_CRITICAL(&s_lock);
s_counts.security_rejections += security;
s_counts.login_failures += credentials;
s_counts.throttled += throttled;
s_counts.capacity_rejections += full;
taskEXIT_CRITICAL(&s_lock);
char body[80];
snprintf(body, sizeof(body), "{\"error\":\"%s\"}", code);
return response(r, status, body);
}
static esp_err_t capacity(httpd_req_t *r)
{
if (httpd_resp_set_hdr(r, "Retry-After", "5") != ESP_OK) return ESP_FAIL;
return failure(r, "503 Service Unavailable", "capacity");
}
esp_err_t web_cookie_auth_start(void)
{
esp_err_t error = web_session_store_init();
taskENTER_CRITICAL(&s_lock);
if (error == ESP_OK && s_epoch != UINT64_MAX) {
++s_epoch;
secure_wipe(s_challenges, sizeof(s_challenges));
s_window = 0;
s_attempts = 0;
s_ready = true;
} else {
s_ready = false;
error = ESP_ERR_INVALID_STATE;
}
taskEXIT_CRITICAL(&s_lock);
return error;
}
void web_cookie_auth_stop(void)
{
taskENTER_CRITICAL(&s_lock);
s_ready = false;
if (s_epoch != UINT64_MAX) ++s_epoch;
secure_wipe(s_challenges, sizeof(s_challenges));
s_window = 0;
s_attempts = 0;
taskEXIT_CRITICAL(&s_lock);
web_session_store_stop();
}
void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot)
{
if (!snapshot) return;
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
*snapshot = s_counts;
snapshot->ready = s_ready;
snapshot->active_challenges = 0;
for (unsigned i = 0; i < 4; ++i) {
if (s_challenges[i].expiry <= now) secure_wipe(&s_challenges[i], sizeof(s_challenges[i]));
else ++snapshot->active_challenges;
}
taskEXIT_CRITICAL(&s_lock);
}
void web_cookie_auth_clear_counters(void)
{
taskENTER_CRITICAL(&s_lock);
memset(&s_counts, 0, sizeof(s_counts));
taskEXIT_CRITICAL(&s_lock);
}
static esp_err_t require(httpd_req_t *r, bool mutation, bool upgrade, size_t body_limit,
web_session_view_t *view, bool *allowed)
{
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
*allowed = false;
memset(view, 0, sizeof(*view));
if (!web_httpd_headers_valid(r) || !cookies_valid(r) || (!upgrade && strchr(r->uri, '?')) ||
r->content_len > body_limit || r->method != (mutation ? HTTP_POST : HTTP_GET))
return failure(r, "400 Bad Request", "invalid_request");
if (!origin(r, mutation || upgrade, canonical))
return failure(r, "403 Forbidden", "origin");
taskENTER_CRITICAL(&s_lock);
bool ready = s_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready) return failure(r, "503 Service Unavailable", "unavailable");
esp_err_t error = ESP_ERR_NOT_FOUND;
if (cookie(r, SESSION_COOKIE, token))
error = web_session_store_lookup(token, strlen(token), canonical, strlen(canonical), view);
secure_wipe(token, sizeof(token));
if (error != ESP_OK) {
if (error != ESP_ERR_NOT_FOUND)
return failure(r, "503 Service Unavailable", "unavailable");
if (!strcmp(r->uri, "/")) {
if (httpd_resp_set_hdr(r, "Location", "/login") != ESP_OK) return ESP_FAIL;
return response(r, "303 See Other", "");
}
return failure(r, "401 Unauthorized", "authentication_required");
}
if (mutation && (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) ||
strlen(csrf) != 64U || !equal(csrf, view->csrf, 64U))) {
secure_wipe(csrf, sizeof(csrf));
secure_wipe(view, sizeof(*view));
return failure(r, "403 Forbidden", "csrf");
}
secure_wipe(csrf, sizeof(csrf));
*allowed = true;
return ESP_OK;
}
esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
web_session_view_t *view, bool *allowed)
{
return require(r, mutation, upgrade, 0, view, allowed);
}
esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit,
web_session_view_t *view, bool *allowed)
{
return require(r, true, false, body_limit, view, allowed);
}
static bool secret(char out[65])
{
uint8_t bytes[32];
bool ok = secure_random_fill(bytes, sizeof(bytes)) == ESP_OK;
if (ok) {
static const char hex[] = "0123456789abcdef";
for (size_t i = 0; i < sizeof(bytes); ++i) {
out[2*i] = hex[bytes[i] >> 4];
out[2*i+1] = hex[bytes[i] & 15];
}
out[64] = 0;
}
secure_wipe(bytes, sizeof(bytes));
return ok;
}
esp_err_t web_cookie_auth_handler(httpd_req_t *r)
{
bool login = !strcmp(r->uri, "/api/login");
bool bootstrap = !strcmp(r->uri, "/api/login-challenge");
bool logout = !strcmp(r->uri, "/api/logout");
bool document = !strcmp(r->uri, "/login");
web_session_view_t view = {0};
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
char set_cookie[180] = {0}, body[513] = {0};
web_auth_credentials_t credentials = {0};
challenge_t candidate = {0};
uint8_t digest[32] = {0}, origin_digest[32] = {0};
esp_err_t result = ESP_FAIL;
const char *status = "400 Bad Request", *code = "invalid_request";
bool consumed = false, allowed = false;
bool challenge_published = false;
uint64_t epoch;
int selected = -1;
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
bool ready = s_ready;
epoch = s_epoch;
taskEXIT_CRITICAL(&s_lock);
if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
if (!web_httpd_headers_valid(r) || !cookies_valid(r) || strchr(r->uri, '?') ||
r->method != ((login || logout) ? HTTP_POST : HTTP_GET) ||
(!login && r->content_len)) goto deny;
if (document) { result = web_login_ui_send_response(r); goto cleanup; }
if (!login && !bootstrap) {
result = web_cookie_auth_require(r, logout, false, &view, &allowed);
if (!allowed) goto cleanup;
if (logout) {
web_serial_transport_revoke_web_session(view.id);
taskENTER_CRITICAL(&s_lock);
++s_counts.logouts;
taskEXIT_CRITICAL(&s_lock);
result = httpd_resp_set_hdr(r, "Set-Cookie", SESSION_COOKIE "=" COOKIE_FLAGS "0");
if (result != ESP_OK) goto cleanup;
result = response(r, "204 No Content", "");
} else {
/* Database usernames are restricted ASCII; encode nevertheless. */
char username[97] = {0};
size_t used = 0;
for (size_t i = 0; i < view.principal.username_length && i < 16; ++i)
used += (size_t)snprintf(username + used, sizeof(username) - used,
"\\u%04x", (unsigned char)view.principal.username[i]);
int64_t remaining = (view.expires_at_us - esp_timer_get_time()) / 1000000LL;
snprintf(body, sizeof(body), "{\"username\":\"%s\",\"role\":\"%s\",\"csrf\":\"%s\",\"expires_in\":%lld}",
username, view.principal.role == USER_ROLE_ADMIN ? "admin" : "user", view.csrf,
(long long)(remaining > 0 ? remaining : 0));
result = response(r, "200 OK", body);
}
goto cleanup;
}
if (!origin(r, login, canonical)) { status = "403 Forbidden"; code = "origin"; goto deny; }
if (mbedtls_sha256((const uint8_t *)canonical, strlen(canonical), origin_digest, 0)) goto deny;
if (bootstrap) {
char flag[2];
if (!header(r, "X-Login-Bootstrap", flag, sizeof(flag)) || strcmp(flag, "1")) {
status = "403 Forbidden"; code = "csrf"; goto deny;
}
} else {
char type[40];
if (!header(r, "Content-Type", type, sizeof(type)) ||
(strcmp(type, "application/json") && strcmp(type, "application/json; charset=utf-8"))) {
status = "415 Unsupported Media Type"; code = "content_type"; goto deny;
}
if (!r->content_len || r->content_len > 512U) {
status = "413 Payload Too Large"; code = "body_size"; goto deny;
}
if (cookie(r, SESSION_COOKIE, token) &&
web_session_store_lookup(token, 64, canonical, strlen(canonical), &view) == ESP_OK) {
status = "409 Conflict"; code = "already_authenticated"; goto deny;
}
if (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) || strlen(csrf) != 64) {
status = "403 Forbidden"; code = "csrf"; goto deny;
}
}
bool has_cookie = cookie(r, PRELOGIN_COOKIE, token);
if (has_cookie && mbedtls_sha256((const uint8_t *)token, 64, digest, 0)) goto deny;
taskENTER_CRITICAL(&s_lock);
for (int i = 0; i < 4; ++i) {
challenge_t *entry = &s_challenges[i];
if (entry->expiry <= now) secure_wipe(entry, sizeof(*entry));
if (s_ready && epoch == s_epoch && entry->expiry && has_cookie &&
equal(entry->token_digest, digest, 32) && equal(entry->origin_digest, origin_digest, 32)) {
if (bootstrap || equal(entry->csrf, csrf, 64)) {
candidate = *entry;
selected = i;
if (login) { secure_wipe(entry, sizeof(*entry)); consumed = true; }
}
}
}
taskEXIT_CRITICAL(&s_lock);
if (bootstrap) {
bool fresh = selected < 0;
if (fresh) {
if (!secret(token) || !secret(candidate.csrf) ||
mbedtls_sha256((const uint8_t *)token, 64, candidate.token_digest, 0)) {
status = "503 Service Unavailable"; code = "unavailable"; goto deny;
}
memcpy(candidate.origin_digest, origin_digest, 32);
candidate.expiry = now + CHALLENGE_US;
taskENTER_CRITICAL(&s_lock);
if (s_ready && epoch == s_epoch) for (int i = 0; i < 4; ++i) {
if (!s_challenges[i].expiry) {
s_challenges[i] = candidate; selected = i; challenge_published = true; break;
}
}
taskEXIT_CRITICAL(&s_lock);
if (selected < 0) { result = capacity(r); goto cleanup; }
snprintf(set_cookie, sizeof(set_cookie), PRELOGIN_COOKIE "=%s" COOKIE_FLAGS "120", token);
if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) goto cleanup;
}
snprintf(body, sizeof(body), "{\"csrf\":\"%s\",\"expires_in\":%lld}", candidate.csrf,
(long long)((candidate.expiry - now) / 1000000LL));
result = response(r, "200 OK", body);
goto cleanup;
}
if (!consumed) { status = "403 Forbidden"; code = "challenge_expired"; goto deny; }
if (httpd_resp_set_hdr(r, "Set-Cookie", PRELOGIN_COOKIE "=" COOKIE_FLAGS "0") != ESP_OK) goto cleanup;
size_t received = 0;
int64_t deadline = now + 3000000LL;
while (received < r->content_len && esp_timer_get_time() < deadline) {
int count = httpd_req_recv(r, body + received, r->content_len - received);
if (count <= 0) goto deny;
received += (size_t)count;
}
if (received != r->content_len || !web_auth_parse_login(body, received, &credentials)) goto deny;
now = esp_timer_get_time();
unsigned attempts;
int64_t retry;
taskENTER_CRITICAL(&s_lock);
ready = s_ready && epoch == s_epoch;
if (now - s_window >= WINDOW_US) { s_window = now; s_attempts = 0; }
attempts = s_attempts;
if (ready && attempts < 5) { ++s_attempts; ++s_counts.login_attempts; }
retry = (s_window + WINDOW_US - now + 999999LL) / 1000000LL;
taskEXIT_CRITICAL(&s_lock);
if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
if (attempts >= 5) {
char seconds[16];
snprintf(seconds, sizeof(seconds), "%lld", (long long)retry);
if (httpd_resp_set_hdr(r, "Retry-After", seconds) != ESP_OK) goto cleanup;
result = failure(r, "429 Too Many Requests", "throttled");
goto cleanup;
}
bool authenticated = false;
user_principal_t principal = {0};
esp_err_t error = user_database_authenticate_password(credentials.username, credentials.username_length,
credentials.password, credentials.password_length, &principal, &authenticated);
secure_wipe(body, sizeof(body));
secure_wipe(&credentials, sizeof(credentials));
if (error == ESP_OK && authenticated)
error = web_session_store_issue(&principal, canonical, strlen(canonical), token, &view);
secure_wipe(&principal, sizeof(principal));
if (error == ESP_ERR_NO_MEM) { result = capacity(r); goto cleanup; }
if (error != ESP_OK) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
if (!authenticated) { status = "401 Unauthorized"; code = "invalid_credentials"; goto deny; }
snprintf(set_cookie, sizeof(set_cookie), SESSION_COOKIE "=%s" COOKIE_FLAGS "3600", token);
if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) {
web_session_store_invalidate(view.id); goto cleanup;
}
result = response(r, "200 OK", "{\"authenticated\":true}");
if (result != ESP_OK) web_session_store_invalidate(view.id);
goto cleanup;
deny:
result = failure(r, status, code);
cleanup:
if (challenge_published && result != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (epoch == s_epoch && selected >= 0 &&
equal(s_challenges[selected].token_digest, candidate.token_digest, 32))
secure_wipe(&s_challenges[selected], sizeof(s_challenges[selected]));
taskEXIT_CRITICAL(&s_lock);
}
web_httpd_wipe_request(r, web_httpd_unread_body(r));
secure_wipe(&view, sizeof(view));
secure_wipe(token, sizeof(token));
secure_wipe(csrf, sizeof(csrf));
secure_wipe(set_cookie, sizeof(set_cookie));
secure_wipe(body, sizeof(body));
secure_wipe(&credentials, sizeof(credentials));
secure_wipe(&candidate, sizeof(candidate));
secure_wipe(digest, sizeof(digest));
return result;
}
+22
View File
@@ -0,0 +1,22 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_http_server.h"
#include "web_session_store.h"
esp_err_t web_cookie_auth_start(void);
void web_cookie_auth_stop(void);
typedef struct {
uint32_t login_attempts, login_failures, throttled, capacity_rejections;
uint32_t security_rejections, logouts, active_challenges;
bool ready;
} web_cookie_auth_snapshot_t;
void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot);
void web_cookie_auth_clear_counters(void);
/* Sends an error on denial, with allowed=false. View is caller-wiped. */
esp_err_t web_cookie_auth_require(httpd_req_t *request, bool mutation,
bool upgrade, web_session_view_t *view,
bool *allowed);
esp_err_t web_cookie_auth_handler(httpd_req_t *request);
/* Same mutation policy, allowing a bounded body; caller validates JSON/content type. */
esp_err_t web_cookie_auth_require_json(httpd_req_t *request, size_t body_limit,
web_session_view_t *view, bool *allowed);
+233
View File
@@ -0,0 +1,233 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Post-TLS observation only. Never retain request data or replace TLS cleanup. */
#include "web_diagnostics.h"
#include <inttypes.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "esp_heap_caps.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define DIAG_SOCKETS 6U
#define DIAG_EVENTS 32U
typedef struct {
const esp_tls_t *tls; /* Identity only, never dereferenced or printed. */
uint64_t seq;
int64_t opened_us;
int fd;
unsigned kind; /* 0 ordinary, 1 serial WS, 2 admin WS */
} connection_t;
typedef struct {
uint64_t id, seq;
int64_t at_us, elapsed_us;
uint32_t free_bytes[3], largest[3], stack_bytes;
int fd, result;
unsigned event, route, ordinary, serial, admin;
} trace_t;
enum { TLS_OPEN, TLS_CLOSE, ENTER, RESULT };
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static connection_t s_connections[DIAG_SOCKETS];
static trace_t s_events[DIAG_EVENTS];
static bool s_enabled;
static uint64_t s_connection_seq, s_event_seq, s_epoch;
static uint32_t s_overwritten, s_unmatched, s_lost;
static unsigned s_count, s_next;
static void increment(uint32_t *value)
{
if (*value != UINT32_MAX) ++*value;
}
static void occupancy(trace_t *event)
{
for (unsigned i = 0; i < DIAG_SOCKETS; ++i) {
if (!s_connections[i].seq) continue;
if (s_connections[i].kind == 1) ++event->serial;
else if (s_connections[i].kind == 2) ++event->admin;
else ++event->ordinary;
}
}
/* Called by HTTPD only. Expensive capability scans stay outside the portMUX.
* Epoch rejects a sample crossing clear/disable/enable. Sequence fences fd reuse. */
static void record(connection_t connection, unsigned event, unsigned route,
int result, int64_t elapsed_us, uint64_t epoch)
{
portENTER_CRITICAL(&s_lock);
bool enabled = s_enabled && s_epoch == epoch;
portEXIT_CRITICAL(&s_lock);
if (!enabled) return;
trace_t row = {.seq = connection.seq, .fd = connection.fd,
.at_us = esp_timer_get_time(), .elapsed_us = elapsed_us,
.event = event, .route = route, .result = result};
const uint32_t caps[] = {MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT,
MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT};
for (unsigned i = 0; i < 3; ++i) {
row.free_bytes[i] = heap_caps_get_free_size(caps[i]);
row.largest[i] = heap_caps_get_largest_free_block(caps[i]);
}
/* ESP-IDF FreeRTOS reports minimum-free stack in bytes, not vanilla words. */
row.stack_bytes = uxTaskGetStackHighWaterMark(NULL);
portENTER_CRITICAL(&s_lock);
if (s_enabled && s_epoch == epoch && s_event_seq != UINT64_MAX) {
row.id = ++s_event_seq;
occupancy(&row);
s_events[s_next] = row;
s_next = (s_next + 1U) % DIAG_EVENTS;
if (s_count < DIAG_EVENTS) ++s_count;
else increment(&s_overwritten);
}
portEXIT_CRITICAL(&s_lock);
}
void web_diagnostics_tls(esp_https_server_user_cb_arg_t *arg)
{
if (!arg || !arg->tls) return;
connection_t connection = {0};
int fd = -1;
if (arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE &&
(esp_tls_get_conn_sockfd(arg->tls, &fd) != ESP_OK || fd < 0)) {
portENTER_CRITICAL(&s_lock);
increment(&s_lost);
portEXIT_CRITICAL(&s_lock);
return;
}
int64_t now = esp_timer_get_time();
portENTER_CRITICAL(&s_lock);
uint64_t epoch = s_epoch;
if (arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE) {
unsigned i;
for (i = 0; i < DIAG_SOCKETS; ++i)
if (s_connections[i].seq && (s_connections[i].tls == arg->tls ||
s_connections[i].fd == fd)) break;
/* Duplicate notifications are not new connections. */
if (i != DIAG_SOCKETS) {
increment(&s_unmatched);
} else {
for (i = 0; i < DIAG_SOCKETS; ++i) if (!s_connections[i].seq) break;
if (i < DIAG_SOCKETS && s_connection_seq != UINT64_MAX) {
connection = (connection_t){.tls = arg->tls, .fd = fd,
.seq = ++s_connection_seq, .opened_us = now};
s_connections[i] = connection;
} else increment(&s_lost);
}
} else if (arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CLOSE) {
unsigned i;
for (i = 0; i < DIAG_SOCKETS; ++i)
if (s_connections[i].seq && s_connections[i].tls == arg->tls) break;
if (i < DIAG_SOCKETS) {
connection = s_connections[i];
memset(&s_connections[i], 0, sizeof(s_connections[i]));
} else increment(&s_unmatched);
}
portEXIT_CRITICAL(&s_lock);
if (connection.seq)
record(connection, arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE ? TLS_OPEN : TLS_CLOSE,
0, 0, now - connection.opened_us, epoch);
}
esp_err_t web_diagnostics_handler(httpd_req_t *request, web_diag_route_t route,
esp_err_t (*handler)(httpd_req_t *))
{
int fd = httpd_req_to_sockfd(request);
connection_t connection = {.fd = fd};
portENTER_CRITICAL(&s_lock);
uint64_t epoch = s_epoch;
bool enabled = s_enabled;
for (unsigned i = 0; i < DIAG_SOCKETS; ++i)
if (s_connections[i].seq && s_connections[i].fd == fd) connection = s_connections[i];
portEXIT_CRITICAL(&s_lock);
record(connection, ENTER, route, 0, 0, epoch);
int64_t start = enabled ? esp_timer_get_time() : 0;
esp_err_t result = handler(request);
int64_t elapsed = enabled ? esp_timer_get_time() - start : 0;
if (route == WEB_DIAG_SERIAL_UPGRADE || route == WEB_DIAG_ADMIN_UPGRADE) {
bool upgraded = httpd_ws_get_fd_info(request->handle, fd) == HTTPD_WS_CLIENT_WEBSOCKET;
portENTER_CRITICAL(&s_lock);
for (unsigned i = 0; i < DIAG_SOCKETS; ++i)
if (connection.seq && s_connections[i].seq == connection.seq && upgraded)
s_connections[i].kind = route == WEB_DIAG_SERIAL_UPGRADE ? 1U : 2U;
portEXIT_CRITICAL(&s_lock);
}
record(connection, RESULT, route, result, elapsed, epoch);
return result;
}
static void show(void)
{
connection_t connections[DIAG_SOCKETS];
trace_t counts = {0};
portENTER_CRITICAL(&s_lock);
memcpy(connections, s_connections, sizeof(connections));
occupancy(&counts);
uint64_t last = s_event_seq;
unsigned count = s_count;
bool enabled = s_enabled;
uint32_t overwritten = s_overwritten, unmatched = s_unmatched, lost = s_lost;
portEXIT_CRITICAL(&s_lock);
int64_t now = esp_timer_get_time();
printf("Web diagnostics %s; post-TLS occupancy=%u/6 ordinary=%u serial=%u admin=%u\n",
enabled ? "enabled" : "disabled", counts.ordinary + counts.serial + counts.admin,
counts.ordinary, counts.serial, counts.admin);
printf("snapshot_us=%" PRId64 " retained=%u/32 overwritten=%" PRIu32 " unmatched=%" PRIu32 " lost=%" PRIu32 "\n",
now, count, overwritten, unmatched, lost);
printf("No preaccept/TLS-failure timing; occupancy excludes in-progress TLS. rc is handler return, NOT HTTP status.\n");
for (unsigned i = 0; i < DIAG_SOCKETS; ++i) {
connection_t c = connections[i];
if (c.seq) printf("live fd=%d conn=%" PRIu64 " kind=%u opened_us=%" PRId64 " age_us=%" PRId64 "\n",
c.fd, c.seq, c.kind, c.opened_us, now - c.opened_us);
}
printf("events: open/close/enter/result; routes: serial-ticket/admin-ticket/serial-upgrade/admin-upgrade; heap pairs free/largest internal,DMA,PSRAM bytes; stack=HTTPD minimum-free bytes\n");
const char *const events[] = {"open", "close", "enter", "result"};
const char *const routes[] = {"serial-ticket", "admin-ticket", "serial-upgrade", "admin-upgrade"};
/* Copy one immutable-ID-qualified row at a time; never hold a lock while printing.
* Concurrent overwrite/clear can omit rows, but cannot turn show into an endless stream. */
for (unsigned n = 0; n < count; ++n) {
uint64_t id = last - count + 1U + n;
trace_t row = {0};
portENTER_CRITICAL(&s_lock);
for (unsigned i = 0; i < DIAG_EVENTS; ++i)
if (s_events[i].id == id) { row = s_events[i]; break; }
portEXIT_CRITICAL(&s_lock);
if (!row.id) { printf("event=%" PRIu64 " no longer retained\n", id); continue; }
printf("event=%" PRIu64 " t_us=%" PRId64 " fd=%d conn=%" PRIu64 " %s %s rc=%d dt_us=%" PRId64
" occ=%u/%u/%u heap=%" PRIu32 "/%" PRIu32 ",%" PRIu32 "/%" PRIu32 ",%" PRIu32 "/%" PRIu32 " stack=%" PRIu32 "\n",
row.id, row.at_us, row.fd, row.seq, events[row.event],
row.event < ENTER ? "-" : routes[row.route], row.result, row.elapsed_us,
row.ordinary, row.serial, row.admin,
row.free_bytes[0], row.largest[0], row.free_bytes[1], row.largest[1],
row.free_bytes[2], row.largest[2], row.stack_bytes);
}
}
int web_diagnostics_command(const char *action)
{
if (strcmp(action, "show") == 0) { show(); return 0; }
bool enable = strcmp(action, "enable") == 0;
bool disable = strcmp(action, "disable") == 0;
bool clear = strcmp(action, "clear") == 0;
if (!enable && !disable && !clear) return 1;
portENTER_CRITICAL(&s_lock);
/* Never wrap identity or capture epochs; exhausting diagnostics cannot affect HTTPD. */
if (s_epoch != UINT64_MAX) {
++s_epoch;
if (enable || disable) s_enabled = enable;
} else s_enabled = false;
if (clear) {
memset(s_events, 0, sizeof(s_events));
s_count = s_next = 0;
s_overwritten = s_unmatched = s_lost = 0;
}
bool enabled = s_enabled;
portEXIT_CRITICAL(&s_lock);
printf("Web diagnostics %s%s; live identities retained.\n", enabled ? "enabled" : "disabled",
clear ? ", trace cleared" : "");
return 0;
}
+18
View File
@@ -0,0 +1,18 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_https_server.h"
typedef enum {
WEB_DIAG_SERIAL_TICKET,
WEB_DIAG_ADMIN_TICKET,
WEB_DIAG_SERIAL_UPGRADE,
WEB_DIAG_ADMIN_UPGRADE,
} web_diag_route_t;
/* Synchronous HTTPD-owner callbacks only; no socket/context ownership transfer. */
void web_diagnostics_tls(esp_https_server_user_cb_arg_t *arg);
esp_err_t web_diagnostics_handler(httpd_req_t *request, web_diag_route_t route,
esp_err_t (*handler)(httpd_req_t *));
/* Canonical console dispatcher only. No HTTPD calls or network waits. */
int web_diagnostics_command(const char *action);
+236
View File
@@ -0,0 +1,236 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_display_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "local_status_ui.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
enum { APPLY, SAVE, LOAD, DEFAULTS, RESET, ACTION_COUNT };
static const char *const s_actions[] = {"apply", "save", "load", "defaults", "reset"};
enum { IDLE, PENDING, OK, FAILED, CANCELLED, LOADED_DEFAULTS, CONFLICT };
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "loaded_defaults", "conflict"};
typedef struct {
uint32_t id;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
local_ui_config_t config;
uint32_t generation;
unsigned action, state;
} display_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static display_operation_t s_operation;
static uint32_t s_next_id;
/* Deliberately narrow flat JSON: ASCII names/enums, unsigned decimal integers,
* no escapes, nesting, duplicate/unknown fields, exponent or fractional values. */
static bool parse(const char *body, size_t length, display_operation_t *operation)
{
const char *keys[] = {"action", "generation", "dim_seconds", "off_seconds"};
unsigned seen = 0;
size_t pos = 0;
local_ui_config_defaults(&operation->config);
operation->action = ACTION_COUNT;
#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 < 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 < 4; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
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;
for (unsigned i = 0; i < ACTION_COUNT; ++i)
if (strlen(s_actions[i]) == pos - start && !memcmp(body + start, s_actions[i], pos - start)) operation->action = i;
if (operation->action == ACTION_COUNT) return false;
++pos;
} else {
uint32_t number = 0;
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
if (key == 1) operation->generation = number;
if (key == 2) operation->config.dim_timeout_seconds = number;
if (key == 3) operation->config.off_timeout_seconds = number;
}
seen |= 1U << key;
SPACE();
if (pos < length && body[pos] == '}') break;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length && seen == (operation->action == APPLY ? 15U : 3U) &&
operation->generation != 0 && local_ui_config_validate(&operation->config) == ESP_OK;
}
void web_display_settings_execute(uint32_t id)
{
display_operation_t operation;
taskENTER_CRITICAL(&s_lock);
operation = s_operation;
taskEXIT_CRITICAL(&s_lock);
if (!id || operation.id != id || operation.state != PENDING) {
secure_wipe(&operation, sizeof(operation));
return;
}
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
/* The owner checks the selected generation and reserves all config
* mutations, including CLI callers, across storage IO. Buttons only
* signal activity: they never replace configuration or own this gate. */
bool defaults = false;
static const local_ui_settings_action_t actions[] = {
LOCAL_UI_SETTINGS_APPLY, LOCAL_UI_SETTINGS_SAVE, LOCAL_UI_SETTINGS_LOAD,
LOCAL_UI_SETTINGS_DEFAULTS, LOCAL_UI_SETTINGS_RESET
};
error = local_status_ui_update_settings(actions[operation.action], operation.generation,
&operation.config, &defaults);
state = error == ESP_OK ? (defaults ? LOADED_DEFAULTS : OK) :
error == ESP_ERR_INVALID_STATE ? CONFLICT : FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
secure_wipe(&s_operation.config, sizeof(s_operation.config));
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
esp_err_t web_display_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
bool mutation = request->method == HTTP_POST;
esp_err_t error = mutation
? web_cookie_auth_require_json(request, 256, &view, &allowed)
: web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
display_operation_t operation = {0};
if (mutation) {
char type[40] = {0}, body[256];
size_t received = 0;
bool valid = request->content_len &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
/* Finite bytes and receive calls; timeout/error closes, never retry/drain. */
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_display_request\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id;
s_operation = operation;
}
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_display_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
secure_wipe(&operation, sizeof(operation));
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id;
operation.action = s_operation.action;
operation.state = s_operation.state;
}
taskEXIT_CRITICAL(&s_lock);
}
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
secure_wipe(&operation, sizeof(operation));
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_display_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
local_ui_config_t config;
uint32_t generation;
error = local_status_ui_get_settings(&config, &generation);
if (error != ESP_OK) {
error = respond(request, "503 Service Unavailable", "{\"error\":\"display_unavailable\"}");
goto done;
}
char response[128];
int written = snprintf(response, sizeof(response),
"{\"generation\":%" PRIu32 ",\"dim_seconds\":%" PRIu32 ",\"off_seconds\":%" PRIu32 "}",
generation, config.dim_timeout_seconds, config.off_timeout_seconds);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, "200 OK", response);
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+9
View File
@@ -0,0 +1,9 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* Optional admin-only RAM snapshot and typed dispatcher admission/results. */
esp_err_t web_display_settings_handler(httpd_req_t *request);
esp_err_t web_display_operation_handler(httpd_req_t *request);
void web_display_settings_execute(uint32_t id);
+239
View File
@@ -0,0 +1,239 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Deliberately isolated dependency on the installed IDF HTTPD layout. */
#include "web_httpd_adapter.h"
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/select.h>
#include <sys/socket.h>
#include "esp_idf_version.h"
#include "esp_httpd_priv.h"
#include "secure_random.h"
#if ESP_IDF_VERSION != ESP_IDF_VERSION_VAL(5, 5, 0)
#error "Reaudit HTTPD headers, upgrade, idle cleanup and WS/TLS send contracts for this IDF"
#endif
/* IDF 5.5.0 httpd_sess_process increments lru_counter only AFTER successful
* req_new + req_delete (handler, response, leftover-body purge and cleanup).
* Work callbacks run between sessions, never inside synchronous parse/TLS/send.
* This counter is an observation marker, NOT permission to enable LRU purge. */
void web_httpd_idle_sweep(httpd_handle_t server,
web_httpd_idle_row_t rows[WEB_HTTPD_IDLE_SOCKETS], int64_t now)
{
struct httpd_data *hd = server;
if (!hd || !rows || hd->config.max_open_sockets > WEB_HTTPD_IDLE_SOCKETS ||
httpd_os_thread_handle() != hd->hd_td.handle || hd->hd_req_aux.sd) return;
for (unsigned i = 0; i < hd->config.max_open_sockets; ++i) {
struct sock_db *sd = &hd->hd_sd[i];
web_httpd_idle_row_t *row = &rows[i];
/* Actual SDK classification, not delayed diagnostic route metadata. */
if (sd->fd < 0 || sd->for_async_req || sd->ws_handshake_done || sd->ws_close) {
memset(row, 0, sizeof(*row));
continue;
}
if (!row->observed || row->fd != sd->fd || row->completed != sd->lru_counter) {
*row = (web_httpd_idle_row_t){.fd = sd->fd, .completed = sd->lru_counter,
.idle_since_us = now, .observed = true};
continue;
}
if (row->shutdown_sent) continue;
/* Control work precedes data processing in httpd_main. Do not expire a
* connection whose next request is buffered in HTTPD, TLS or TCP. Zero
* timeout select does not consume bytes or change TLS receive ownership.
* Errors are conservative too; normal HTTPD owns error cleanup. */
fd_set ready;
FD_ZERO(&ready);
if (sd->fd >= FD_SETSIZE) { row->idle_since_us = now; continue; }
FD_SET(sd->fd, &ready);
struct timeval timeout = {0};
if (sd->pending_len || (sd->pending_fn && sd->pending_fn(hd, sd->fd) != 0) ||
select(sd->fd + 1, &ready, NULL, NULL, &timeout) != 0) {
row->idle_since_us = now;
continue;
}
if (now - row->idle_since_us < WEB_HTTPD_IDLE_TIMEOUT_US) continue;
/* Still the current fd on its owner; no queued sock_db pointer can later
* target a replacement. HTTPD performs normal TLS/session destruction
* on the next read. A failed shutdown retries on the next probe. */
if (shutdown(sd->fd, SHUT_RDWR) == 0) row->shutdown_sent = true;
}
}
static int web_httpd_aborted_send(httpd_handle_t server, int fd,
const char *buffer, size_t length, int flags)
{
(void)server; (void)fd; (void)buffer; (void)length; (void)flags;
return HTTPD_SOCK_ERR_FAIL;
}
esp_err_t web_httpd_ws_send_binary(httpd_handle_t server, int fd,
const void *expected_context,
const uint8_t *payload, size_t length)
{
struct httpd_data *hd = server;
if (!hd || fd < 0 || !expected_context || (!payload && length) ||
length > WEB_HTTPD_WS_BINARY_MAX_PAYLOAD) return ESP_ERR_INVALID_ARG;
if (httpd_os_thread_handle() != hd->hd_td.handle || hd->hd_req_aux.sd)
return ESP_ERR_INVALID_STATE;
struct sock_db *sd = httpd_sess_get(hd, fd);
if (!sd || sd->ctx != expected_context || !sd->ws_handshake_done ||
sd->ws_close || sd->for_async_req || !sd->send_fn)
return ESP_ERR_INVALID_STATE;
/* IDF 5.5.0 httpd_ws_send_frame_async emits header/payload separately.
* Keep the existing send override (HTTPS -> esp_tls_conn_write), not raw
* socket IO. Owner-local scratch lives through the synchronous call only. */
uint8_t wire[WEB_HTTPD_WS_BINARY_MAX_PAYLOAD + 4U];
size_t header = length <= 125U ? 2U : 4U;
wire[0] = 0x82; /* FIN, binary; server frames are never masked. */
wire[1] = header == 2U ? (uint8_t)length : 126U;
if (header == 4U) {
wire[2] = (uint8_t)(length >> 8U);
wire[3] = (uint8_t)length;
}
if (length) memcpy(wire + header, payload, length);
size_t total = header + length;
int sent = sd->send_fn(hd, fd, (const char *)wire, total, 0);
if (sent == (int)total) return ESP_OK;
/* Owner retains this validated session across the synchronous send. TLS
* may hold pending output after short/zero/WANT/error: never retry it with
* different arguments. Deferred close alone permits SDK automatic PONG or
* CLOSE first. Shutdown alone cannot block buffered-input TLS calls either.
* Reject all sends before shutdown, even if shutdown fails. ws_close also
* skips SDK request processing; normal HTTPD still owns TLS destruction. */
sd->send_fn = web_httpd_aborted_send;
sd->ws_close = true;
(void)shutdown(fd, SHUT_RDWR);
return ESP_FAIL;
}
bool web_httpd_headers_valid(httpd_req_t *request)
{
if (!request || !request->aux) return false;
const struct httpd_req_aux *aux = request->aux;
const char *start = aux->scratch;
if (!start || aux->scratch_cur_size > 1024U) return false;
const char *end = start + aux->scratch_cur_size;
const char *line = start;
for (unsigned i = 0; i < aux->req_hdrs_count; ++i) {
if (line >= end) return false;
while (line < end && !*line) ++line;
const char *stop = memchr(line, 0, (size_t)(end - line));
if (!stop) return false;
const char *colon = memchr(line, ':', (size_t)(stop - line));
if (!colon || colon == line) return false;
size_t length = (size_t)(colon - line);
for (const char *p = line; p < colon; ++p) {
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') ||
(*p >= '0' && *p <= '9') || strchr("!#$%&'*+-.^_`|~", *p))) return false;
}
for (const char *p = colon + 1; p < stop; ++p) {
if ((unsigned char)*p < 32U || (unsigned char)*p == 127U) return false;
}
/* Reject transfer coding and Expect rather than draining an unbounded
* body after an authentication failure. No application route uses them. */
if ((length == 17U && !strncasecmp(line, "Transfer-Encoding", length)) ||
(length == 6U && !strncasecmp(line, "Expect", length))) return false;
const char *previous = start;
for (unsigned j = 0; j < i; ++j) {
while (previous < line && !*previous) ++previous;
const char *previous_end = memchr(previous, 0, (size_t)(line - previous));
if (!previous_end) return false;
const char *previous_colon = memchr(previous, ':', (size_t)(previous_end - previous));
if (!previous_colon) return false;
if ((size_t)(previous_colon - previous) == length &&
!strncasecmp(previous, line, length)) return false;
previous = previous_end + 1;
}
line = stop + 1;
}
return true;
}
bool web_httpd_upgrade_requested(httpd_req_t *request)
{
const struct httpd_req_aux *aux = request->aux;
if (!aux || !aux->sd || !aux->ws_handshake_detect || aux->sd->ws_handshake_done)
return false;
char version[3], key[25];
if (httpd_req_get_hdr_value_len(request, "Sec-WebSocket-Version") != 2U ||
httpd_req_get_hdr_value_str(request, "Sec-WebSocket-Version", version, sizeof(version)) != ESP_OK ||
strcmp(version, "13") || httpd_req_get_hdr_value_len(request, "Sec-WebSocket-Key") != 24U ||
httpd_req_get_hdr_value_str(request, "Sec-WebSocket-Key", key, sizeof(key)) != ESP_OK ||
key[22] != '=' || key[23] != '=' || !strchr("AQgw", key[21])) return false;
for (unsigned i = 0; i < 21; ++i)
if (!strchr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", key[i])) return false;
return true;
}
esp_err_t web_httpd_upgrade(httpd_req_t *request,
esp_err_t (*handler)(httpd_req_t *))
{
if (!web_httpd_upgrade_requested(request)) return ESP_ERR_INVALID_STATE;
esp_err_t error = httpd_ws_respond_server_handshake(request, NULL);
if (error == ESP_OK) {
struct httpd_req_aux *aux = request->aux;
aux->sd->ws_handshake_done = true;
aux->sd->ws_handler = handler;
aux->sd->ws_control_frames = false;
aux->sd->ws_user_ctx = NULL;
}
return error;
}
void web_httpd_wipe_request(httpd_req_t *request, bool closing)
{
struct httpd_req_aux *aux = request->aux;
if (!aux) return;
if (aux->scratch) secure_wipe(aux->scratch, aux->scratch_cur_size);
aux->req_hdrs_count = 0;
if (aux->sd) {
size_t keep = closing ? 0 : aux->sd->pending_len;
/* httpd_unrecv()/httpd_recv_pending() right-align unread bytes. */
if (keep <= sizeof(aux->sd->pending_data))
secure_wipe(aux->sd->pending_data, sizeof(aux->sd->pending_data) - keep);
}
}
bool web_httpd_unread_body(httpd_req_t *request)
{
const struct httpd_req_aux *aux = request->aux;
return aux && aux->remaining_len != 0;
}
esp_err_t web_httpd_register_optional(httpd_handle_t server, const httpd_uri_t *uri)
{
struct httpd_data *hd = server;
if (!hd || !uri || !uri->uri || !uri->handler ||
(uri->method != HTTP_GET && uri->method != HTTP_POST) ||
uri->is_websocket || uri->supported_subprotocol || hd->config.uri_match_fn)
return ESP_ERR_INVALID_ARG;
size_t length = 0;
while (length < 128 && uri->uri[length]) ++length;
if (!length || length == 128) return ESP_ERR_INVALID_ARG;
int slot = -1;
for (unsigned i = 0; i < hd->config.max_uri_handlers; ++i) {
if (!hd->hd_calls[i]) { if (slot < 0) slot = (int)i; }
else if (!strcmp(hd->hd_calls[i]->uri, uri->uri) && hd->hd_calls[i]->method == uri->method)
return ESP_ERR_INVALID_STATE;
}
if (slot < 0) return ESP_ERR_NO_MEM;
/* IDF 5.5.0 publishes its descriptor before strdup; strdup failure leaves a
* freed hd_calls entry. Optional registration must leave the table intact. */
httpd_uri_t *copy = malloc(sizeof(*copy));
if (!copy) return ESP_ERR_NO_MEM;
char *name = malloc(length + 1);
if (!name) { free(copy); return ESP_ERR_NO_MEM; }
memcpy(name, uri->uri, length + 1);
*copy = *uri;
copy->uri = name;
hd->hd_calls[slot] = copy;
return ESP_OK;
}
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri)
{
if (!uri || uri->method != HTTP_GET) return ESP_ERR_INVALID_ARG;
return web_httpd_register_optional(server, uri);
}
+42
View File
@@ -0,0 +1,42 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_http_server.h"
#include <stdint.h>
#define WEB_HTTPD_IDLE_SOCKETS 6U
#define WEB_HTTPD_IDLE_TIMEOUT_US INT64_C(15000000)
typedef struct {
uint64_t completed;
int64_t idle_since_us;
int fd;
bool observed, shutdown_sent;
} web_httpd_idle_row_t;
/* HTTPD-owner work boundary only. TLS create must invalidate reused fd rows. */
void web_httpd_idle_sweep(httpd_handle_t server,
web_httpd_idle_row_t rows[WEB_HTTPD_IDLE_SOCKETS], int64_t now);
/* HTTPD-owner only, before body reads or any response. Reject duplicate lines,
* including Cookie, rather than trusting first-match public getters. */
bool web_httpd_headers_valid(httpd_req_t *request);
bool web_httpd_upgrade_requested(httpd_req_t *request);
bool web_httpd_unread_body(httpd_req_t *request);
/* After the final response/lookup: preserve only unread pipelined data on a
* keepalive connection. Closing requests may discard pending data entirely. */
void web_httpd_wipe_request(httpd_req_t *request, bool closing);
esp_err_t web_httpd_upgrade(httpd_req_t *request,
esp_err_t (*handler)(httpd_req_t *));
#define WEB_HTTPD_WS_BINARY_MAX_PAYLOAD 512U
/* HTTPD-owner work only; caller retains generation/lifecycle and one-work-slot
* ownership. Synchronous FIN/binary/unmasked send through the session override.
* Non-full writes synchronously block further session sends and abort the socket;
* caller must retain normal failure/cleanup accounting, never replay the frame. */
esp_err_t web_httpd_ws_send_binary(httpd_handle_t server, int fd,
const void *expected_context,
const uint8_t *payload, size_t length);
/* Serialized server startup only, exact-match ordinary GET, URI <= 127 bytes.
* Stage both allocations before publication; HTTPD owns/frees them on success. */
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri);
/* Same staged startup ownership for ordinary exact GET or POST. */
esp_err_t web_httpd_register_optional(httpd_handle_t server, const httpd_uri_t *uri);
+137
View File
@@ -0,0 +1,137 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_httpd_idle.h"
#include "web_httpd_adapter.h"
#include <stdint.h>
#include <string.h>
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define IDLE_POLL_US INT64_C(1000000)
#define IDLE_FENCE_US INT64_C(1000000)
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static httpd_handle_t s_server;
static esp_timer_handle_t s_timer;
static uintptr_t s_generation;
static bool s_accepting, s_queued, s_submitting;
/* Only HTTPD touches rows while alive; prepare runs before SSL startup. */
static web_httpd_idle_row_t s_rows[WEB_HTTPD_IDLE_SOCKETS];
static void idle_work(void *argument)
{
uintptr_t generation = (uintptr_t)argument;
taskENTER_CRITICAL(&s_lock);
bool current = s_queued && generation == s_generation;
httpd_handle_t server = current && s_accepting ? s_server : NULL;
taskEXIT_CRITICAL(&s_lock);
if (server) web_httpd_idle_sweep(server, s_rows, esp_timer_get_time());
taskENTER_CRITICAL(&s_lock);
if (current && generation == s_generation) s_queued = false;
taskEXIT_CRITICAL(&s_lock);
}
static void idle_timer(void *argument)
{
(void)argument;
taskENTER_CRITICAL(&s_lock);
httpd_handle_t server = NULL;
uintptr_t generation = s_generation;
if (s_accepting && !s_queued && !s_submitting) {
server = s_server;
s_queued = s_submitting = true;
}
taskEXIT_CRITICAL(&s_lock);
if (!server) return;
esp_err_t error = httpd_queue_work(server, idle_work, (void *)generation);
taskENTER_CRITICAL(&s_lock);
/* A callback may finish before queue_work returns. Keep the submission
* reservation until here so it cannot clear a newer probe's queued flag. */
if (error != ESP_OK) s_queued = false;
s_submitting = false;
taskEXIT_CRITICAL(&s_lock);
}
esp_err_t web_httpd_idle_prepare(void)
{
#if defined(CONFIG_HTTPD_QUEUE_WORK_BLOCKING) && CONFIG_HTTPD_QUEUE_WORK_BLOCKING
return ESP_ERR_NOT_SUPPORTED;
#else
taskENTER_CRITICAL(&s_lock);
bool allowed = !s_server && !s_queued && !s_submitting && s_generation != UINTPTR_MAX;
taskEXIT_CRITICAL(&s_lock);
if (!allowed) return ESP_ERR_INVALID_STATE;
if (!s_timer) {
esp_timer_handle_t timer = NULL;
const esp_timer_create_args_t args = {
.callback = idle_timer, .name = "web_idle", .skip_unhandled_events = true,
};
esp_err_t error = esp_timer_create(&args, &timer);
if (error == ESP_OK) error = esp_timer_start_periodic(timer, IDLE_POLL_US);
if (error != ESP_OK) {
if (timer) (void)esp_timer_delete(timer);
return error;
}
s_timer = timer;
}
memset(s_rows, 0, sizeof(s_rows));
return ESP_OK;
#endif
}
esp_err_t web_httpd_idle_attach(httpd_handle_t server)
{
taskENTER_CRITICAL(&s_lock);
bool allowed = server && s_timer && !s_server && !s_queued && !s_submitting &&
s_generation != UINTPTR_MAX;
if (allowed) {
++s_generation; /* Never reused, including when HTTPD's handle is reused. */
s_server = server;
s_accepting = true;
}
taskEXIT_CRITICAL(&s_lock);
return allowed ? ESP_OK : ESP_ERR_INVALID_STATE;
}
esp_err_t web_httpd_idle_detach(httpd_handle_t server)
{
taskENTER_CRITICAL(&s_lock);
bool owned = server && s_server == server;
bool absent = !s_server;
if (owned) s_accepting = false;
taskEXIT_CRITICAL(&s_lock);
/* Partial startup may never have attached. */
if (!owned) return absent ? ESP_OK : ESP_ERR_INVALID_STATE;
int64_t deadline = esp_timer_get_time() + IDLE_FENCE_US;
for (;;) {
taskENTER_CRITICAL(&s_lock);
bool submitting = s_submitting;
taskEXIT_CRITICAL(&s_lock);
if (!submitting) return ESP_OK;
if (esp_timer_get_time() >= deadline) return ESP_ERR_TIMEOUT;
vTaskDelay(1);
}
}
void web_httpd_idle_stopped(httpd_handle_t server)
{
taskENTER_CRITICAL(&s_lock);
if (server && s_server == server && !s_accepting && !s_submitting) {
s_server = NULL;
s_queued = false; /* Successful HTTPD stop joined owner and destroyed queue. */
}
taskEXIT_CRITICAL(&s_lock);
}
void web_httpd_idle_tls(esp_https_server_user_cb_arg_t *arg)
{
if (!arg || !arg->tls || arg->user_cb_state != HTTPD_SSL_USER_CB_SESS_CREATE) return;
int fd = -1;
if (esp_tls_get_conn_sockfd(arg->tls, &fd) != ESP_OK || fd < 0) {
/* Identity unavailable: conservatively restart every idle observation. */
memset(s_rows, 0, sizeof(s_rows));
return;
}
for (unsigned i = 0; i < WEB_HTTPD_IDLE_SOCKETS; ++i)
if (s_rows[i].fd == fd) memset(&s_rows[i], 0, sizeof(s_rows[i]));
}
+12
View File
@@ -0,0 +1,12 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_https_server.h"
/* Serialized web_server lifecycle. Prepare before SSL start; stop must fence
* submissions before destroying HTTPD, and retire only after successful stop. */
esp_err_t web_httpd_idle_prepare(void);
esp_err_t web_httpd_idle_attach(httpd_handle_t server);
esp_err_t web_httpd_idle_detach(httpd_handle_t server);
void web_httpd_idle_stopped(httpd_handle_t server);
/* Synchronous HTTPD-owner TLS callback, composed with diagnostics by server. */
void web_httpd_idle_tls(esp_https_server_user_cb_arg_t *arg);
+283
View File
@@ -0,0 +1,283 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_lifecycle_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#include "web_server.h"
#include "web_security.h"
#if CONFIG_HTTPD_QUEUE_WORK_BLOCKING
#error "Lifecycle ACK handoff requires nonblocking HTTPD work submission"
#endif
enum { IDLE, PENDING, EXECUTING, OK, FAILED, CANCELLED };
static const char *const s_states[] = {"idle", "pending", "pending", "ok", "failed", "cancelled"};
static const char *const s_actions[] = {"stop", "restart", "reboot", "rotate"};
typedef struct {
uint32_t id, generation, identity_generation;
web_session_id_t session;
user_principal_t principal;
int64_t ack_deadline, deadline;
unsigned action, state;
bool queued;
} lifecycle_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static lifecycle_operation_t s_operation;
static uint32_t s_next_id, s_ack_id;
/* Comparison only; never dereferenced outside the invoking HTTPD handler. */
static httpd_handle_t s_ack_server;
static void cancel_locked(void)
{
s_operation.state = CANCELLED;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
}
static void expire_locked(int64_t now)
{
if (s_operation.state == PENDING &&
now >= (s_operation.queued ? s_operation.deadline : s_operation.ack_deadline))
cancel_locked();
}
/* Exactly action + service generation, plus identity generation only for rotate.
* No escapes, duplicates, coercions or extra fields. */
static bool parse(const char *body, size_t length, lifecycle_operation_t *operation)
{
const char *keys[] = {"action", "generation", "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) {
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)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 3 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
if (key == 0) {
TAKE('"'); start = pos;
while (pos < length && body[pos] != '"') ++pos;
if (pos == length) return false;
unsigned action = 0;
for (; action < 4; ++action)
if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break;
if (action == 4) return false;
operation->action = action; ++pos;
} else {
uint32_t number = 0; start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
if (key == 1) operation->generation = number;
else operation->identity_generation = number;
}
seen |= 1U << key;
SPACE();
if (pos < length && body[pos] == '}') break;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length && seen == (operation->action == 3 ? 7U : 3U) &&
operation->generation && operation->generation != UINT32_MAX &&
(operation->action != 3 || (operation->identity_generation && operation->identity_generation != UINT32_MAX));
}
/* Runs on HTTPD after its synchronous response handler returns. No socket IO,
* wait, authorization or lifecycle call here. A duplicate/late ID is inert. */
static void ack_handoff(void *argument)
{
uint32_t id = (uint32_t)(uintptr_t)argument;
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
bool submit = id && s_ack_id == id;
if (submit) {
s_ack_id = 0; s_ack_server = NULL;
expire_locked(now);
submit = s_operation.id == id && s_operation.state == PENDING;
if (submit) s_operation.queued = true;
}
taskEXIT_CRITICAL(&s_lock);
if (submit && admin_ssh_console_submit_lifecycle_settings(id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) cancel_locked();
taskEXIT_CRITICAL(&s_lock);
}
}
void web_lifecycle_settings_stopped(httpd_handle_t server)
{
taskENTER_CRITICAL(&s_lock);
if (server && s_ack_server == server) { s_ack_id = 0; s_ack_server = NULL; }
/* Successful shutdown invalidates all old logins. Executing work owns its
* slot until return, including its deliberately session-invalidating stop. */
if (s_operation.state == PENDING) cancel_locked();
taskEXIT_CRITICAL(&s_lock);
}
void web_lifecycle_settings_execute(uint32_t id)
{
lifecycle_operation_t operation = {0};
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
bool execute = id && s_operation.id == id && s_operation.state == PENDING && s_operation.queued;
if (execute) {
s_operation.state = EXECUTING;
operation = s_operation;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
}
taskEXIT_CRITICAL(&s_lock);
if (!execute) return;
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
bool committed = false;
error = operation.action == 0 ? web_server_stop_current(operation.generation) :
operation.action == 1 ? web_server_restart_current(operation.generation) :
operation.action == 2 ? web_server_reboot_current(operation.generation) :
web_server_replace_identity(operation.generation, operation.identity_generation, false, &committed);
/* Even INVALID_STATE can be a detach failure after stop admission. */
state = error == ESP_OK ? OK : FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == EXECUTING) s_operation.state = state;
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
esp_err_t web_lifecycle_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
lifecycle_operation_t operation = {0};
bool allowed = false, mutation = request->method == HTTP_POST;
esp_err_t error = mutation
? web_cookie_auth_require_json(request, 256, &view, &allowed)
: web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done;
}
if (mutation) {
char type[40] = {0}, body[256];
size_t received = 0;
bool valid = request->content_len && request->content_len <= sizeof(body) &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) { error = respond(request, "400 Bad Request", "{\"error\":\"invalid_lifecycle_request\"}"); goto done; }
operation.session = view.id; operation.principal = view.principal;
int64_t now = esp_timer_get_time();
operation.ack_deadline = now + 2000000LL;
operation.deadline = now + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
bool busy = s_ack_id || s_operation.state == PENDING || s_operation.state == EXECUTING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id; s_operation = operation;
s_ack_id = operation.id; s_ack_server = request->handle;
}
taskEXIT_CRITICAL(&s_lock);
if (busy) { error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}"); goto done; }
} else {
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
if (s_operation.session == view.id) {
operation.id = s_operation.id; operation.state = s_operation.state; operation.action = s_operation.action;
}
taskEXIT_CRITICAL(&s_lock);
}
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
if (mutation) {
/* No lifecycle can run before this send returns and HTTPD hands off.
* Successful send is not peer receipt. Never retry queue submission. */
if (error != ESP_OK || httpd_queue_work(request->handle, ack_handoff, (void *)(uintptr_t)operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (s_ack_id == operation.id) { s_ack_id = 0; s_ack_server = NULL; }
if (s_operation.id == operation.id && s_operation.state == PENDING) cancel_locked();
taskEXIT_CRITICAL(&s_lock);
}
}
done:
secure_wipe(&operation, sizeof(operation));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_lifecycle_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done;
}
web_server_management_snapshot_t snapshot;
if (web_server_get_management_snapshot(&snapshot) != ESP_OK) {
error = respond(request, "503 Service Unavailable", "{\"error\":\"lifecycle_unavailable\"}"); goto done;
}
web_security_identity_snapshot_t identity = {0};
bool available = web_security_get_identity_snapshot(&identity) == ESP_OK;
char fingerprint[65] = {0};
if (available) {
for (size_t i = 0; i < sizeof(identity.fingerprint); ++i)
snprintf(fingerprint + i * 2, 3, "%02x", identity.fingerprint[i]);
}
char response[320];
int written = snprintf(response, sizeof(response),
"{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,\"controllable\":%s,"
"\"identity_generation\":%" PRIu32 ",\"fingerprint\":\"%s\",\"rotatable\":%s}",
snapshot.generation, snapshot.running ? "true" : "false",
snapshot.transitioning ? "true" : "false", snapshot.controllable ? "true" : "false",
available ? identity.generation : 0, fingerprint,
available && snapshot.controllable && !identity.busy && identity.generation &&
identity.generation != UINT32_MAX ? "true" : "false");
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response);
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+11
View File
@@ -0,0 +1,11 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
esp_err_t web_lifecycle_settings_handler(httpd_req_t *request);
esp_err_t web_lifecycle_operation_handler(httpd_req_t *request);
/* Existing dispatcher only; callbacks submit IDs, never execute lifecycle work. */
void web_lifecycle_settings_execute(uint32_t id);
/* Only after successful HTTPD destruction, before another server can start. */
void web_lifecycle_settings_stopped(httpd_handle_t server);
+161
View File
@@ -0,0 +1,161 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_login_ui.h"
#include <stddef.h>
/* Authored standalone page; no dependency on protected or generated assets. */
static const char s_login_html[] =
"<!doctype html>\n<html lang=\"en\">\n<head>\n"
"<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
"<title>Sign in - ESP32 Serial Console</title>\n"
"<style>\n"
":root{color-scheme:dark;font:16px/1.5 system-ui,sans-serif;background:#090d14;color:#e8eef8}\n"
"*{box-sizing:border-box}body{margin:0;padding:2rem 1rem}main{max-width:28rem;margin:3vh auto;"
"padding:1.5rem;background:#111824;border:1px solid #29364a;border-radius:14px}\n"
"h1{font-size:1.5rem}label{display:block;margin-top:1rem}input,button{font:inherit;"
"width:100%;padding:.65rem;border:1px solid #91a0b5;border-radius:6px}"
"input{background:#090d14;color:inherit}button{margin-top:1.25rem;background:#55c2ff;"
"color:#090d14;cursor:pointer}button:disabled{opacity:.6;cursor:wait}"
"a{color:#55c2ff}:focus-visible{outline:3px solid #ffc857;outline-offset:3px}"
"#message{min-height:3em}small{display:block;color:#b6c2d4}\n"
"</style>\n</head>\n<body>\n<main>\n"
"<h1>ESP32 Serial Console</h1>\n"
"<p>Sign in to access the serial terminal.</p>\n"
"<form id=\"login\" method=\"post\" action=\"/api/login\">\n"
"<label for=\"username\">Username</label>\n"
"<input id=\"username\" name=\"username\" autocomplete=\"username\" "
"autocapitalize=\"none\" spellcheck=\"false\" maxlength=\"16\" required>\n"
"<label for=\"password\">Password</label>\n"
"<input id=\"password\" name=\"password\" type=\"password\" "
"autocomplete=\"current-password\" maxlength=\"64\" required>\n"
"<button id=\"submit\" type=\"submit\" disabled>Sign in</button>\n"
"</form>\n"
"<p id=\"message\" role=\"status\" aria-live=\"polite\">Ready to sign in.</p>\n"
"<noscript><p>JavaScript is required to sign in securely.</p></noscript>\n"
"<p><a href=\"/\">Return to console</a></p>\n"
"<small>Sessions expire after one hour, including active serial connections. "
"To switch accounts, return to the console and sign out first.</small>\n"
"</main>\n<script>\n"
"(() => {\n"
" 'use strict';\n"
" const form = document.getElementById('login');\n"
" const username = document.getElementById('username');\n"
" const password = document.getElementById('password');\n"
" const submit = document.getElementById('submit');\n"
" const message = document.getElementById('message');\n"
" const encoder = new TextEncoder();\n"
" let busy = false, generation = 0, controller = null, retryAt = 0;\n"
" function reset() {\n"
" ++generation;\n"
" if (controller) controller.abort();\n"
" controller = null; busy = false; password.value = '';\n"
" submit.disabled = false; username.disabled = false; password.disabled = false; form.setAttribute('aria-busy', 'false');\n"
" }\n"
" window.addEventListener('pagehide', reset);\n"
" window.addEventListener('pageshow', event => {\n"
" if (event.persisted) { reset(); message.textContent = 'Ready to sign in.'; }\n"
" });\n"
" async function readJSON(response) {\n"
" if (!response.body) throw new Error('response');\n"
" const reader = response.body.getReader();\n"
" const bytes = new Uint8Array(512);\n"
" let length = 0;\n"
" try {\n"
" for (;;) {\n"
" const part = await reader.read();\n"
" if (part.done) break;\n"
" if (part.value.length > bytes.length - length) throw new Error('response');\n"
" bytes.set(part.value, length); length += part.value.length;\n"
" }\n"
" return JSON.parse(new TextDecoder('utf-8', {fatal:true}).decode(bytes.subarray(0, length)));\n"
" } finally { await reader.cancel(); reader.releaseLock(); }\n"
" }\n"
" function report(response, stage) {\n"
" if (response.status === 429 || response.status === 503) {\n"
" const raw = response.headers.get('Retry-After') || '';\n"
" const seconds = /^[0-9]{1,3}$/.test(raw) ? Math.max(1, Math.min(120, Number(raw))) : 5;\n"
" retryAt = Date.now() + seconds * 1000;\n"
" message.textContent = (response.status === 429 ? 'Too many sign-in attempts.' : 'Sign-in capacity is busy.') +\n"
" ' Wait ' + seconds + ' seconds, then try again.';\n"
" } else if (response.status === 401 && stage === 'login') {\n"
" message.textContent = 'Username or password is incorrect. Please try again.';\n"
" } else if (response.status === 403) {\n"
" message.textContent = 'The sign-in challenge expired or the request was rejected. Please try again.';\n"
" } else if (response.status === 409) {\n"
" message.textContent = 'Already signed in. Return to the console; sign out there to switch accounts.';\n"
" } else if ([400, 413, 415].includes(response.status)) {\n"
" message.textContent = 'The sign-in request was not accepted. Check your input and try again.';\n"
" } else {\n"
" message.textContent = 'The device could not complete sign-in. Please try again.';\n"
" }\n"
" }\n"
" form.addEventListener('submit', async event => {\n"
" event.preventDefault();\n"
" if (busy) { password.value = ''; return; }\n"
" if (Date.now() < retryAt) {\n"
" password.value = '';\n"
" message.textContent = 'Please wait ' + Math.ceil((retryAt - Date.now()) / 1000) + ' seconds before retrying.';\n"
" return;\n"
" }\n"
" let body = JSON.stringify({username:username.value, password:password.value});\n"
" const valid = username.value.length && password.value.length &&\n"
" encoder.encode(username.value).length <= 16 && encoder.encode(password.value).length <= 64 &&\n"
" !username.value.includes('\\0') && !password.value.includes('\\0') && encoder.encode(body).length <= 512;\n"
" password.value = '';\n"
" if (!valid) { body = ''; message.textContent = 'Enter a username (up to 16 UTF-8 bytes) and password (up to 64 UTF-8 bytes).'; return; }\n"
" busy = true; submit.disabled = true; username.disabled = true; password.disabled = true; form.setAttribute('aria-busy', 'true');\n"
" message.textContent = 'Signing in...';\n"
" const current = ++generation;\n"
" const abort = new AbortController(); controller = abort;\n"
" const timeout = setTimeout(() => abort.abort(), 15000);\n"
" let csrf = '';\n"
/* CORS mode preserves Origin under no-referrer; CSP still limits connections to self. */
" const options = {credentials:'same-origin', mode:'cors', cache:'no-store', redirect:'error', signal:abort.signal};\n"
" try {\n"
" const challenge = await fetch('/api/login-challenge', {...options, headers:{'X-Login-Bootstrap':'1'}});\n"
" if (current !== generation) return;\n"
" if (challenge.status !== 200) { report(challenge, 'challenge'); return; }\n"
" const data = await readJSON(challenge);\n"
" if (current !== generation) return;\n"
" if (!data || typeof data.csrf !== 'string' || !/^[0-9a-f]{64}$/.test(data.csrf) ||\n"
" !Number.isInteger(data.expires_in) || data.expires_in < 1 || data.expires_in > 120) throw new Error('challenge');\n"
" csrf = data.csrf;\n"
" const response = await fetch('/api/login', {...options, method:'POST',\n"
" headers:{'Content-Type':'application/json', 'X-CSRF-Token':csrf}, body});\n"
" body = ''; csrf = '';\n"
" if (current !== generation) return;\n"
" if (response.status !== 200) { report(response, 'login'); return; }\n"
" const result = await readJSON(response);\n"
" if (current !== generation) return;\n"
" if (!result || result.authenticated !== true) throw new Error('login');\n"
" window.location.replace('/');\n"
" } catch (_) {\n"
" if (current === generation) message.textContent = 'Could not confirm sign-in. Check the connection, then return to the console or try again.';\n"
" } finally {\n"
" abort.abort(); clearTimeout(timeout); body = ''; csrf = '';\n"
" if (current === generation) {\n"
" controller = null; busy = false; password.value = '';\n"
" submit.disabled = false; username.disabled = false; password.disabled = false; form.setAttribute('aria-busy', 'false');\n"
" }\n"
" }\n"
" });\n"
" submit.disabled = false;\n"
"})();\n"
"</script>\n</body>\n</html>\n";
esp_err_t web_login_ui_send_response(httpd_req_t *request)
{
if (request == NULL) return ESP_ERR_INVALID_ARG;
esp_err_t error = httpd_resp_set_type(request, "text/html; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Frame-Options", "DENY");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Content-Security-Policy",
"default-src 'none'; script-src 'sha256-eZO4pMDQx6SIaa5AFlMnuf0CD5JdGSWyi8lNVmCNPBQ='; "
"style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; "
"form-action 'none'; frame-ancestors 'none'");
if (error == ESP_OK) error = httpd_resp_send(request, s_login_html, sizeof(s_login_html) - 1U);
return error;
}
+9
View File
@@ -0,0 +1,9 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_err.h"
#include "esp_http_server.h"
/* Standalone public login document. Rendering only: authentication, route
* registration and bounded challenge allocation belong to web_cookie_auth. */
esp_err_t web_login_ui_send_response(httpd_req_t *request);
+446
View File
@@ -0,0 +1,446 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_network_settings.h"
#include <inttypes.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "mdns_service.h"
#include "secure_random.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#include "wifi_manager.h"
enum { WIFI_PATCH, PROFILE_PATCH, WIFI_SAVE, WIFI_LOAD, START, STOP, RECONNECT,
NEXT_PROFILE, MDNS_SET, MDNS_SAVE, MDNS_LOAD, MDNS_DEFAULTS, ACTION_COUNT };
static const char *const s_actions[] = {"wifi-patch", "profile-patch", "wifi-save", "wifi-load",
"start", "stop", "reconnect", "next-profile", "mdns-set", "mdns-save", "mdns-load", "mdns-defaults"};
enum { IDLE, PENDING, ACCEPTED, OK, FAILED, CANCELLED, STALE, INVALID,
LOADED_DEFAULTS, APPLIED_NOT_QUEUED };
static const char *const s_states[] = {"idle", "pending", "accepted", "ok", "failed", "cancelled",
"stale", "invalid", "loaded_defaults", "applied_not_queued"};
typedef struct {
uint32_t id, generation;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
unsigned action, state;
esp_err_t error;
bool executing;
wifi_manager_patch_t patch;
mdns_config_t mdns;
} network_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static network_operation_t s_operation;
static uint32_t s_next_id;
static esp_timer_handle_t s_secret_timer;
static bool s_secret_timer_started;
static void wipe_input(network_operation_t *operation)
{
secure_wipe(&operation->principal, sizeof(operation->principal));
secure_wipe(&operation->patch, sizeof(operation->patch));
secure_wipe(&operation->mdns, sizeof(operation->mdns));
operation->generation = 0;
}
static void expire_input(void *unused)
{
(void)unused;
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
if (s_operation.state == PENDING && !s_operation.executing && now >= s_operation.deadline) {
s_operation.state = CANCELLED;
wipe_input(&s_operation);
}
taskEXIT_CRITICAL(&s_lock);
}
static bool ensure_secret_timer(void)
{
/* Sole HTTPD admission owner; one firmware-lifetime timer, no extra task.
* Periodic inspection avoids an old captured expiry cancelling a newer ID. */
if (!s_secret_timer) {
const esp_timer_create_args_t args = {.callback = expire_input, .name = "network-input"};
if (esp_timer_create(&args, &s_secret_timer) != ESP_OK) return false;
}
if (!s_secret_timer_started) {
if (esp_timer_start_periodic(s_secret_timer, 1000000ULL) != ESP_OK) return false;
s_secret_timer_started = true;
}
return true;
}
typedef struct { const char *body; size_t size, pos; } parser_t;
static void space(parser_t *p)
{
while (p->pos < p->size && (p->body[p->pos] == ' ' || p->body[p->pos] == '\r' ||
p->body[p->pos] == '\n' || p->body[p->pos] == '\t')) ++p->pos;
}
static bool take(parser_t *p, char c)
{
space(p);
return p->pos < p->size && p->body[p->pos++] == c;
}
static int hex_digit(unsigned char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
/* Bounded byte-string decoder, deliberately not Unicode-to-UTF8 conversion.
* See the public contract: \\u00ff is exactly one SSID byte, not two. */
static bool byte_string(parser_t *p, uint8_t *out, size_t capacity, size_t *length)
{
*length = 0;
if (!take(p, '"')) return false;
while (p->pos < p->size) {
unsigned char c = (unsigned char)p->body[p->pos++];
if (c == '"') return true;
if (c < 0x20 || c > 0x7e || *length == capacity) return false;
if (c == '\\') {
if (p->pos == p->size) return false;
c = (unsigned char)p->body[p->pos++];
switch (c) {
case '"': case '\\': case '/': break;
case 'b': c = '\b'; break;
case 'f': c = '\f'; break;
case 'n': c = '\n'; break;
case 'r': c = '\r'; break;
case 't': c = '\t'; break;
case 'u': {
if (p->size - p->pos < 4 || p->body[p->pos] != '0' || p->body[p->pos + 1] != '0') return false;
int high = hex_digit(p->body[p->pos + 2]), low = hex_digit(p->body[p->pos + 3]);
if (high < 0 || low < 0) return false;
c = (unsigned char)(high * 16 + low); p->pos += 4; break;
}
default: return false;
}
}
out[(*length)++] = c;
}
return false;
}
static bool number(parser_t *p, uint32_t *out)
{
space(p); size_t start = p->pos; *out = 0;
while (p->pos < p->size && p->body[p->pos] >= '0' && p->body[p->pos] <= '9') {
unsigned digit = (unsigned)(p->body[p->pos++] - '0');
if (*out > (UINT32_MAX - digit) / 10) return false;
*out = *out * 10 + digit;
}
return p->pos > start && (p->pos - start == 1 || p->body[start] != '0');
}
static bool boolean(parser_t *p, uint32_t *out)
{
space(p);
if (p->size - p->pos >= 4 && !memcmp(p->body + p->pos, "true", 4)) { p->pos += 4; *out = 1; return true; }
if (p->size - p->pos >= 5 && !memcmp(p->body + p->pos, "false", 5)) { p->pos += 5; *out = 0; return true; }
return false;
}
static bool parse_request(const char *body, size_t length, network_operation_t *operation)
{
enum { ACTION, GENERATION, PROFILE, ENABLED, PRIORITY, SECURITY, SSID, PASSWORD,
CLEAR_PASSWORD, BOOT, POLICY, CHANNEL, SUFFIX, KEY_COUNT };
static const char *const keys[] = {"action", "generation", "profile", "enabled", "priority", "security",
"ssid", "password", "clear_password", "enabled_at_boot", "ap_policy", "channel", "suffix"};
parser_t p = {.body = body, .size = length};
uint32_t seen = 0;
operation->action = ACTION_COUNT;
operation->patch.profile = -1;
if (!take(&p, '{')) return false;
for (unsigned field = 0; field < KEY_COUNT; ++field) {
uint8_t key_text[20] = {0}; size_t n;
if ((field && !take(&p, ',')) || !byte_string(&p, key_text, sizeof(key_text), &n)) return false;
unsigned key = 0;
for (; key < KEY_COUNT; ++key) if (strlen(keys[key]) == n && !memcmp(keys[key], key_text, n)) break;
if (key == KEY_COUNT || (seen & (1U << key)) || !take(&p, ':')) return false;
seen |= 1U << key;
uint32_t value = 0;
uint8_t text[64] = {0};
bool valid;
if (key == GENERATION || key == PROFILE || key == PRIORITY || key == CHANNEL) valid = number(&p, &value);
else if (key == ENABLED || key == CLEAR_PASSWORD || key == BOOT) valid = boolean(&p, &value);
else valid = byte_string(&p, text, sizeof(text) - 1, &n);
if (!valid) { secure_wipe(text, sizeof(text)); return false; }
switch (key) {
case ACTION:
for (unsigned i = 0; i < ACTION_COUNT; ++i)
if (strlen(s_actions[i]) == n && !memcmp(s_actions[i], text, n)) operation->action = i;
valid = operation->action != ACTION_COUNT; break;
case GENERATION: operation->generation = value; valid = value != 0; break;
case PROFILE: valid = value < WIFI_CONFIG_STA_PROFILE_COUNT; operation->patch.profile = (int8_t)value; break;
case ENABLED: operation->patch.enabled = value; operation->patch.fields |= WIFI_PATCH_ENABLED; break;
case PRIORITY: valid = value <= UINT8_MAX; operation->patch.priority = value; operation->patch.fields |= WIFI_PATCH_PRIORITY; break;
case SECURITY:
valid = !memchr(text, 0, n) && wifi_config_parse_security((char *)text, &operation->patch.security);
operation->patch.fields |= WIFI_PATCH_SECURITY; break;
case SSID:
valid = n <= WIFI_CONFIG_SSID_MAX_LEN;
if (valid) { memcpy(operation->patch.ssid, text, n); operation->patch.ssid_len = n; }
operation->patch.fields |= WIFI_PATCH_SSID; break;
case PASSWORD:
valid = n >= WIFI_CONFIG_PSK_MIN_LEN && n <= WIFI_CONFIG_PSK_MAX_LEN;
for (size_t i = 0; valid && i < n; ++i) valid = text[i] >= 0x20 && text[i] <= 0x7e;
if (valid) { memcpy(operation->patch.password, text, n); operation->patch.password_len = n; }
operation->patch.fields |= WIFI_PATCH_PASSWORD; break;
case CLEAR_PASSWORD: valid = value == 1; operation->patch.fields |= WIFI_PATCH_PASSWORD; break;
case BOOT: operation->patch.enabled_at_boot = value; operation->patch.fields |= WIFI_PATCH_BOOT; break;
case POLICY:
valid = !memchr(text, 0, n) && wifi_config_parse_ap_policy((char *)text, &operation->patch.ap_policy);
operation->patch.fields |= WIFI_PATCH_POLICY; break;
case CHANNEL: valid = value >= WIFI_CONFIG_AP_CHANNEL_MIN && value <= WIFI_CONFIG_AP_CHANNEL_MAX;
operation->patch.ap_channel = value; operation->patch.fields |= WIFI_PATCH_CHANNEL; break;
case SUFFIX:
valid = n <= MDNS_CONFIG_SUFFIX_MAX_LEN;
if (valid) {
operation->mdns.schema_version = MDNS_CONFIG_SCHEMA_VERSION;
operation->mdns.blob_size = MDNS_CONFIG_BLOB_SIZE;
operation->mdns.suffix_len = n; memcpy(operation->mdns.suffix, text, n);
valid = mdns_config_validate(&operation->mdns) == ESP_OK;
}
break;
}
secure_wipe(text, sizeof(text));
if (!valid) return false;
space(&p);
if (p.pos < p.size && p.body[p.pos] == '}') break;
}
if (!take(&p, '}')) return false;
space(&p);
if (p.pos != p.size || !(seen & 1U) ||
((seen & (1U << PASSWORD)) && (seen & (1U << CLEAR_PASSWORD)))) return false;
uint32_t required = 1U, allowed = 1U;
if (operation->action == WIFI_PATCH || operation->action == PROFILE_PATCH) {
required |= 1U << GENERATION;
allowed = required | (1U << SSID) | (1U << PASSWORD) | (1U << CLEAR_PASSWORD);
if (operation->action == PROFILE_PATCH) {
required |= 1U << PROFILE;
allowed |= (1U << PROFILE) | (1U << ENABLED) | (1U << PRIORITY) | (1U << SECURITY);
} else allowed |= (1U << BOOT) | (1U << POLICY) | (1U << CHANNEL);
if (!operation->patch.fields) return false;
} else if (operation->action == WIFI_SAVE || operation->action == WIFI_LOAD || operation->action >= MDNS_SET) {
required |= 1U << GENERATION;
if (operation->action == MDNS_SET) required |= 1U << SUFFIX;
allowed = required;
}
return operation->action < ACTION_COUNT && (seen & required) == required && !(seen & ~allowed);
}
void web_network_settings_execute(uint32_t id)
{
network_operation_t operation = {0};
taskENTER_CRITICAL(&s_lock);
bool claimed = id && s_operation.id == id && s_operation.state == PENDING && !s_operation.executing;
if (claimed) {
s_operation.executing = true;
operation = s_operation;
wipe_input(&s_operation);
}
taskEXIT_CRITICAL(&s_lock);
if (!claimed) return;
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
state = ACCEPTED;
switch (operation.action) {
case WIFI_PATCH: case PROFILE_PATCH: error = wifi_manager_patch_current(operation.generation, &operation.patch); break;
case WIFI_SAVE: error = wifi_manager_save_current(operation.generation); state = OK; break;
case WIFI_LOAD: error = wifi_manager_load_current(operation.generation); break;
case START: error = wifi_manager_start(); break;
case STOP: error = wifi_manager_stop(); break;
case RECONNECT: error = wifi_manager_reconnect(); break;
case NEXT_PROFILE: error = wifi_manager_next_profile(); break;
default: {
bool stored = true;
mdns_settings_action_t action = operation.action == MDNS_SET ? MDNS_SETTINGS_SET :
operation.action == MDNS_SAVE ? MDNS_SETTINGS_SAVE :
operation.action == MDNS_LOAD ? MDNS_SETTINGS_LOAD : MDNS_SETTINGS_DEFAULTS;
error = mdns_service_update_current(operation.generation, action, &operation.mdns, &stored);
if (error == ESP_OK) {
if (action == MDNS_SETTINGS_SAVE) state = OK;
else {
error = wifi_manager_mdns_reannounce();
state = error != ESP_OK ? APPLIED_NOT_QUEUED : stored ? ACCEPTED : LOADED_DEFAULTS;
}
}
break;
}
}
if (error != ESP_OK && state != APPLIED_NOT_QUEUED)
state = error == ESP_ERR_NOT_FOUND ? STALE : error == ESP_ERR_INVALID_ARG ? INVALID : FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
s_operation.error = error;
s_operation.executing = false;
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
static esp_err_t authorize(httpd_req_t *request, bool mutation, web_session_view_t *view, bool *allowed)
{
esp_err_t error = mutation ? web_cookie_auth_require_json(request, WEB_NETWORK_REQUEST_MAX, view, allowed) :
web_cookie_auth_require(request, false, false, view, allowed);
if (error == ESP_OK && *allowed && view->principal.role != USER_ROLE_ADMIN) {
*allowed = false;
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
}
return error;
}
static bool append(char *out, size_t capacity, size_t *used, const char *format, ...)
{
va_list args; va_start(args, format);
int count = vsnprintf(out + *used, capacity - *used, format, args);
va_end(args);
if (count < 0 || (size_t)count >= capacity - *used) return false;
*used += (size_t)count; return true;
}
static bool append_ssid(char *out, size_t capacity, size_t *used, const uint8_t *ssid, size_t length)
{
if (length > WIFI_CONFIG_SSID_MAX_LEN || !append(out, capacity, used, "\"")) return false;
for (size_t i = 0; i < length; ++i) {
unsigned c = ssid[i];
if (c >= 0x20 && c <= 0x7e && c != '"' && c != '\\') {
if (!append(out, capacity, used, "%c", c)) return false;
} else if (!append(out, capacity, used, "\\u%04x", c)) return false;
}
return append(out, capacity, used, "\"");
}
static const char *json_bool(bool value) { return value ? "true" : "false"; }
static esp_err_t snapshot_response(httpd_req_t *request)
{
wifi_manager_settings_t wifi;
mdns_service_snapshot_t mdns;
/* No blocking config getters, driver/NVS calls or secret-bearing copies on HTTPD. */
if (wifi_manager_get_settings(&wifi) != ESP_OK || mdns_service_get_settings(&mdns) != ESP_OK)
return respond(request, "503 Service Unavailable", "{\"error\":\"snapshot_unavailable\"}");
char response[WEB_NETWORK_SNAPSHOT_MAX]; size_t used = 0;
#define ADD(...) do { if (!append(response, sizeof(response), &used, __VA_ARGS__)) return ESP_FAIL; } while (0)
#define SSID(data, length) do { if (!append_ssid(response, sizeof(response), &used, data, length)) return ESP_FAIL; } while (0)
ADD("{\"wifi\":{\"generation\":%" PRIu32 ",\"enabled_at_boot\":%s,\"ap\":{\"policy\":\"%s\",\"channel\":%u,\"ssid\":",
wifi.runtime.config_generation, json_bool(wifi.enabled_at_boot),
wifi_config_ap_policy_to_string(wifi.ap_policy), (unsigned)wifi.ap_channel);
SSID(wifi.ap_ssid, wifi.ap_ssid_len);
ADD(",\"password_configured\":%s},\"profiles\":[", json_bool(wifi.ap_password_configured));
for (unsigned i = 0; i < WIFI_CONFIG_STA_PROFILE_COUNT; ++i) {
const wifi_manager_profile_settings_t *p = &wifi.profiles[i];
ADD("%s{\"index\":%u,\"enabled\":%s,\"priority\":%u,\"security\":\"%s\",\"ssid\":",
i ? "," : "", i, json_bool(p->enabled), (unsigned)p->priority, wifi_config_security_to_string(p->security));
SSID(p->ssid, p->ssid_len);
ADD(",\"password_configured\":%s}", json_bool(p->password_configured));
}
const wifi_manager_snapshot_t *r = &wifi.runtime;
/* IPv4 bytes are already in network order, independent of host endianness. */
const uint8_t *ip = (const uint8_t *)&r->ip;
ADD("]},\"runtime\":{\"started\":%s,\"state\":\"%s\",\"active_profile\":%d,\"ip\":\"%u.%u.%u.%u\","
"\"ap_running\":%s,\"ap_clients\":%u,\"last_error\":%d},",
json_bool(r->started), wifi_manager_state_to_string(r->state), (int)r->active_profile,
ip[0], ip[1], ip[2], ip[3], json_bool(r->ap_running), (unsigned)r->ap_client_count, (int)r->last_error);
ADD("\"mdns\":{\"generation\":%" PRIu32 ",\"suffix\":\"%s\",\"hostname\":\"%s\",\"announced\":%s,\"last_error\":%d}}",
mdns.config_generation, mdns.suffix, mdns.hostname, json_bool(mdns.announced), (int)mdns.last_error);
#undef SSID
#undef ADD
return respond(request, "200 OK", response);
}
esp_err_t web_network_snapshot_handler(httpd_req_t *request)
{
web_session_view_t view = {0}; bool allowed = false;
esp_err_t error = authorize(request, false, &view, &allowed);
if (error == ESP_OK && allowed) {
error = request->method == HTTP_GET ? snapshot_response(request) :
respond(request, "405 Method Not Allowed", "{\"error\":\"method\"}");
}
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_network_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0}; bool allowed = false;
network_operation_t operation = {0};
bool mutation = request->method == HTTP_POST;
esp_err_t error = authorize(request, mutation, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (request->method != HTTP_GET && !mutation) {
error = respond(request, "405 Method Not Allowed", "{\"error\":\"method\"}"); goto done;
}
if (mutation) {
char type[40] = {0}, body[WEB_NETWORK_REQUEST_MAX]; size_t received = 0;
bool valid = request->content_len > 0 && request->content_len <= sizeof(body) &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse_request(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
wipe_input(&operation);
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_network_request\"}"); goto done;
}
if (!ensure_secret_timer()) {
wipe_input(&operation);
error = respond(request, "503 Service Unavailable", "{\"error\":\"timer_unavailable\"}"); goto done;
}
operation.session = view.id; operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL; operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) { operation.id = ++s_next_id; s_operation = operation; }
taskEXIT_CRITICAL(&s_lock);
wipe_input(&operation);
if (busy || admin_ssh_console_submit_network_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id; operation.action = s_operation.action;
operation.state = s_operation.state; operation.error = s_operation.error;
}
taskEXIT_CRITICAL(&s_lock);
}
/* Input is not needed for formatting or potentially blocking socket IO. */
wipe_input(&operation);
char response[128];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\",\"error\":%d}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state], (int)operation.error);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
done:
secure_wipe(&operation, sizeof(operation));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+32
View File
@@ -0,0 +1,32 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
#define WEB_NETWORK_REQUEST_MAX 768U
#define WEB_NETWORK_SNAPSHOT_MAX 2048U
/* Integration: optional exact GET /api/settings/network -> snapshot_handler;
* exact GET and POST /api/settings/network-operation -> operation_handler.
* All require current admin cookie, same Origin; POST additionally CSRF/JSON.
* No query strings. No changes to browser-shell command authorization.
*
* One session-bound replaceable result, no durable history/idempotency. Only an
* ID enters the existing dispatcher. A periodic one-second ESP timer wipes and
* cancels non-executing input at 30 seconds plus scheduling latency. Executing
* locals wipe on return; already-admitted work can finish after session loss.
* 'accepted' means RAM/owner queue admission, NEVER association or DHCP success.
*
* SSID JSON is a BYTE string: raw printable ASCII, standard single-character
* JSON escapes, and \\u00HH only; each decoded codepoint maps to one byte. NUL and
* non-UTF-8 bytes round-trip. No raw non-ASCII, other Unicode or surrogates. UI
* must encode UTF-8 text into bytes before encoding this field, and retain a
* reversible byte editor for existing arbitrary SSIDs. Length limit: 32 bytes.
* No saved PSK/length is returned, only password_configured. Omitted password
* preserves current bytes; clear_password:true is distinct from replacement.
* Enabled STA requires a PSK; AP clear/open is always rejected, even policy off.
* Wi-Fi Load is stored-only, no generated-default/reset/secret-delivery route.
*/
esp_err_t web_network_snapshot_handler(httpd_req_t *request);
esp_err_t web_network_operation_handler(httpd_req_t *request);
void web_network_settings_execute(uint32_t id);
+172 -183
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Canonical NVS storage for HTTPS identity and legacy recovery credentials. */
/* Canonical NVS storage for HTTPS identity with private v1 storage compatibility. */
#include "web_security.h"
@@ -20,12 +20,10 @@
#include "nvs.h"
#include "secure_random.h"
#define WEB_SECURITY_SCHEMA_VERSION 1U
#define WEB_SECURITY_BLOB_SIZE 1392U
#define WEB_SECURITY_SCHEMA_VERSION 2U
#define WEB_SECURITY_BLOB_SIZE 1340U
#define LEGACY_BLOB_SIZE 1392U
static const uint8_t s_admin_username[] = "admin";
static const char s_password_alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
static const uint8_t s_ap_ipv4_address[4] = {192U, 168U, 4U, 1U};
typedef struct {
@@ -33,31 +31,28 @@ typedef struct {
uint16_t blob_size;
uint16_t reserved_header;
uint32_t generation;
uint8_t username_length;
uint8_t password_length;
uint16_t private_key_length;
uint16_t certificate_length;
uint16_t reserved_lengths;
uint8_t username[WEB_SECURITY_USERNAME_CAPACITY];
uint8_t password[WEB_SECURITY_PASSWORD_CAPACITY];
uint8_t private_key_der[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY];
uint8_t certificate_der[WEB_SECURITY_CERTIFICATE_DER_CAPACITY];
uint8_t certificate_fingerprint[WEB_SECURITY_SHA256_LENGTH];
uint8_t reserved[12];
} web_security_blob_t;
_Static_assert(offsetof(web_security_blob_t, username) == 20U,
"web security schema offsets changed");
_Static_assert(offsetof(web_security_blob_t, private_key_der) == 68U,
_Static_assert(offsetof(web_security_blob_t, private_key_der) == 16U,
"web security key offset changed");
_Static_assert(offsetof(web_security_blob_t, certificate_der) == 324U,
_Static_assert(offsetof(web_security_blob_t, certificate_der) == 272U,
"web security certificate offset changed");
_Static_assert(offsetof(web_security_blob_t, certificate_fingerprint) == 1296U,
"web security fingerprint offset changed");
_Static_assert(sizeof(web_security_blob_t) == WEB_SECURITY_BLOB_SIZE,
"web security schema size changed");
static SemaphoreHandle_t s_security_mutex;
static web_security_blob_t s_material;
static bool s_material_ready;
static uint32_t s_identity_token, s_next_identity_token;
static bool s_identity_used;
static web_security_load_result_t s_load_result;
static bool bytes_are_zero(const uint8_t *data, size_t size)
@@ -116,28 +111,6 @@ static esp_err_t build_device_names(char *common_name, size_t common_name_size,
return ESP_OK;
}
static esp_err_t generate_credentials(web_security_blob_t *blob)
{
uint8_t random_bytes[WEB_SECURITY_PASSWORD_LENGTH] = {0};
memset(blob->username, 0, sizeof(blob->username));
memset(blob->password, 0, sizeof(blob->password));
memcpy(blob->username, s_admin_username, sizeof(s_admin_username) - 1U);
blob->username_length = sizeof(s_admin_username) - 1U;
blob->password_length = WEB_SECURITY_PASSWORD_LENGTH;
esp_err_t error = secure_random_fill(random_bytes, sizeof(random_bytes));
if (error == ESP_OK) {
/* Sixty-four symbols consume six random bits exactly, without modulo bias. */
for (size_t i = 0U; i < sizeof(random_bytes); ++i) {
blob->password[i] =
(uint8_t)s_password_alphabet[random_bytes[i] & 0x3fU];
}
}
secure_wipe(random_bytes, sizeof(random_bytes));
return error;
}
static esp_err_t normalize_der(unsigned char *buffer, size_t capacity,
int written, uint16_t *output_length)
{
@@ -396,6 +369,16 @@ cleanup:
return valid;
}
static bool der_is_exact_sequence(const uint8_t *der, size_t size)
{
unsigned char *cursor = (unsigned char *)der;
const unsigned char *end = der + size;
size_t length = 0U;
return mbedtls_asn1_get_tag(&cursor, end, &length,
MBEDTLS_ASN1_CONSTRUCTED | MBEDTLS_ASN1_SEQUENCE) == 0 &&
length == (size_t)(end - cursor);
}
static esp_err_t validate_certificate_and_key(const web_security_blob_t *blob)
{
char common_name[WEB_SECURITY_COMMON_NAME_CAPACITY] = {0};
@@ -419,6 +402,10 @@ static esp_err_t validate_certificate_and_key(const web_security_blob_t *blob)
sizeof(fingerprint))) {
goto cleanup;
}
if (!der_is_exact_sequence(blob->private_key_der, blob->private_key_length) ||
!der_is_exact_sequence(blob->certificate_der, blob->certificate_length)) {
goto cleanup;
}
if (mbedtls_pk_parse_key(&private_key,
blob->private_key_der, blob->private_key_length,
NULL, 0U, secure_random_mbedtls, NULL) != 0 ||
@@ -492,16 +479,7 @@ static esp_err_t validate_blob(const web_security_blob_t *blob)
return ESP_ERR_INVALID_VERSION;
}
if (blob->generation == 0U || blob->reserved_header != 0U ||
blob->reserved_lengths != 0U ||
!bytes_are_zero(blob->reserved, sizeof(blob->reserved)) ||
blob->username_length != sizeof(s_admin_username) - 1U ||
memcmp(blob->username, s_admin_username,
sizeof(s_admin_username) - 1U) != 0 ||
!unused_bytes_are_zero(blob->username, blob->username_length,
sizeof(blob->username)) ||
blob->password_length != WEB_SECURITY_PASSWORD_LENGTH ||
!unused_bytes_are_zero(blob->password, blob->password_length,
sizeof(blob->password)) ||
blob->private_key_length == 0U ||
blob->private_key_length > sizeof(blob->private_key_der) ||
!unused_bytes_are_zero(blob->private_key_der, blob->private_key_length,
@@ -513,16 +491,6 @@ static esp_err_t validate_blob(const web_security_blob_t *blob)
return ESP_ERR_INVALID_RESPONSE;
}
for (size_t i = 0U; i < blob->password_length; ++i) {
const uint8_t value = blob->password[i];
bool valid = (value >= 'A' && value <= 'Z') ||
(value >= 'a' && value <= 'z') ||
(value >= '0' && value <= '9') ||
value == '-' || value == '_';
if (!valid) {
return ESP_ERR_INVALID_RESPONSE;
}
}
return validate_certificate_and_key(blob);
}
@@ -533,10 +501,7 @@ static esp_err_t generate_all(web_security_blob_t *blob, uint32_t generation)
blob->blob_size = WEB_SECURITY_BLOB_SIZE;
blob->generation = generation;
esp_err_t error = generate_credentials(blob);
if (error == ESP_OK) {
error = generate_certificate(blob);
}
esp_err_t error = generate_certificate(blob);
if (error == ESP_OK) {
error = validate_blob(blob);
}
@@ -556,7 +521,7 @@ static esp_err_t save_blob(const web_security_blob_t *blob)
return error;
}
/* NVS append semantics retain the committed predecessor until commit succeeds. */
/* Publish only after commit. NVS replacement is not secure flash erasure. */
error = nvs_set_blob(handle, WEB_SECURITY_NVS_BLOB_KEY,
blob, sizeof(*blob));
if (error == ESP_OK) {
@@ -566,9 +531,57 @@ static esp_err_t save_blob(const web_security_blob_t *blob)
return error;
}
static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing)
/* The shipped ESP32 v1 wire layout is little-endian, independent of host ABI.
* Credentials exist only in this transient decoder input, never live state. */
static uint16_t legacy_u16(const uint8_t *p)
{
return (uint16_t)p[0] | (uint16_t)((uint16_t)p[1] << 8);
}
static uint32_t legacy_u32(const uint8_t *p)
{
return (uint32_t)legacy_u16(p) | ((uint32_t)legacy_u16(p + 2) << 16);
}
static esp_err_t decode_legacy(const uint8_t raw[LEGACY_BLOB_SIZE],
web_security_blob_t *blob)
{
if (legacy_u32(raw) != 1U || legacy_u16(raw + 4) != LEGACY_BLOB_SIZE) {
return ESP_ERR_INVALID_VERSION;
}
if (legacy_u16(raw + 6) != 0U || legacy_u16(raw + 18) != 0U ||
raw[12] != 5U || raw[13] != 24U ||
memcmp(raw + 20, "admin", 5U) != 0 ||
!bytes_are_zero(raw + 25, 11U) ||
!bytes_are_zero(raw + 60, 8U) ||
!bytes_are_zero(raw + 1380, 12U)) {
return ESP_ERR_INVALID_RESPONSE;
}
for (size_t i = 36U; i < 60U; ++i) {
uint8_t c = raw[i];
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_')) {
return ESP_ERR_INVALID_RESPONSE;
}
}
memset(blob, 0, sizeof(*blob));
blob->schema_version = WEB_SECURITY_SCHEMA_VERSION;
blob->blob_size = WEB_SECURITY_BLOB_SIZE;
blob->generation = legacy_u32(raw + 8);
blob->private_key_length = legacy_u16(raw + 14);
blob->certificate_length = legacy_u16(raw + 16);
memcpy(blob->private_key_der, raw + 68, sizeof(blob->private_key_der));
memcpy(blob->certificate_der, raw + 324, sizeof(blob->certificate_der));
memcpy(blob->certificate_fingerprint, raw + 1348,
sizeof(blob->certificate_fingerprint));
return validate_blob(blob);
}
static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing,
bool *migrated)
{
*missing = false;
*migrated = false;
nvs_handle_t handle;
esp_err_t error = nvs_open(WEB_SECURITY_NVS_NAMESPACE, NVS_READONLY, &handle);
if (error == ESP_ERR_NVS_NOT_FOUND) {
@@ -594,6 +607,21 @@ static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing)
nvs_close(handle);
return error;
}
if (size == LEGACY_BLOB_SIZE) {
uint8_t legacy[LEGACY_BLOB_SIZE] = {0};
error = nvs_get_blob(handle, WEB_SECURITY_NVS_BLOB_KEY, legacy, &size);
nvs_close(handle);
if (error == ESP_OK) {
error = size == LEGACY_BLOB_SIZE ? decode_legacy(legacy, blob)
: ESP_ERR_INVALID_VERSION;
}
secure_wipe(legacy, sizeof(legacy));
if (error == ESP_OK) {
error = save_blob(blob);
*migrated = error == ESP_OK;
}
return error == ESP_ERR_NVS_INVALID_LENGTH ? ESP_ERR_INVALID_VERSION : error;
}
if (size != sizeof(*blob)) {
nvs_close(handle);
return ESP_ERR_INVALID_VERSION;
@@ -608,7 +636,7 @@ static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing)
if (error != ESP_OK) {
return error;
}
return validate_blob(blob);
return size == sizeof(*blob) ? validate_blob(blob) : ESP_ERR_INVALID_VERSION;
}
esp_err_t web_security_init(web_security_load_result_t *load_result)
@@ -623,6 +651,10 @@ esp_err_t web_security_init(web_security_load_result_t *load_result)
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (s_identity_token) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
if (s_material_ready) {
if (load_result != NULL) {
*load_result = s_load_result;
@@ -633,7 +665,8 @@ esp_err_t web_security_init(web_security_load_result_t *load_result)
web_security_blob_t candidate;
bool missing = false;
error = load_stored_blob(&candidate, &missing);
bool migrated = false;
error = load_stored_blob(&candidate, &missing, &migrated);
if (error == ESP_OK && missing) {
error = generate_all(&candidate, 1U);
if (error == ESP_OK) {
@@ -644,7 +677,8 @@ esp_err_t web_security_init(web_security_load_result_t *load_result)
s_material = candidate;
s_material_ready = true;
s_load_result = missing ? WEB_SECURITY_LOAD_GENERATED_MISSING
: WEB_SECURITY_LOAD_STORED;
: migrated ? WEB_SECURITY_LOAD_MIGRATED_V1
: WEB_SECURITY_LOAD_STORED;
if (load_result != NULL) {
*load_result = s_load_result;
}
@@ -700,35 +734,6 @@ esp_err_t web_security_copy_tls_material(
}
static void copy_credentials_locked(web_security_credentials_t *credentials,
const web_security_blob_t *blob)
{
memset(credentials, 0, sizeof(*credentials));
credentials->username_length = blob->username_length;
credentials->password_length = blob->password_length;
memcpy(credentials->username, blob->username, blob->username_length);
memcpy(credentials->password, blob->password, blob->password_length);
}
esp_err_t web_security_show_credentials(web_security_credentials_t *credentials)
{
if (credentials == NULL) {
return ESP_ERR_INVALID_ARG;
}
if (s_security_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
esp_err_t error = ESP_ERR_INVALID_STATE;
if (s_material_ready) {
copy_credentials_locked(credentials, &s_material);
error = ESP_OK;
}
xSemaphoreGive(s_security_mutex);
return error;
}
esp_err_t web_security_get_certificate_metadata(
web_security_certificate_metadata_t *metadata)
{
@@ -764,14 +769,6 @@ esp_err_t web_security_get_certificate_metadata(
return error;
}
static esp_err_t increment_generation(web_security_blob_t *blob)
{
if (blob->generation == UINT32_MAX) {
return ESP_ERR_INVALID_STATE;
}
++blob->generation;
return ESP_OK;
}
static void install_committed_blob(const web_security_blob_t *candidate)
{
@@ -782,98 +779,90 @@ static void install_committed_blob(const web_security_blob_t *candidate)
s_load_result = WEB_SECURITY_LOAD_STORED;
}
esp_err_t web_security_rotate_credentials(web_security_credentials_t *new_credentials)
esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *snapshot)
{
if (s_security_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
if (!snapshot) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
esp_err_t error = s_material_ready ? ESP_OK : ESP_ERR_INVALID_STATE;
if (error == ESP_OK) {
snapshot->generation = s_material.generation;
memcpy(snapshot->fingerprint, s_material.certificate_fingerprint, sizeof(snapshot->fingerprint));
snapshot->busy = s_identity_token != 0 || s_next_identity_token == UINT32_MAX;
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
esp_err_t error = ESP_ERR_INVALID_STATE;
web_security_blob_t candidate;
memset(&candidate, 0, sizeof(candidate));
if (s_material_ready) {
candidate = s_material;
error = increment_generation(&candidate);
if (error == ESP_OK) {
error = generate_credentials(&candidate);
}
if (error == ESP_OK) {
error = save_blob(&candidate);
}
if (error == ESP_OK) {
install_committed_blob(&candidate);
if (new_credentials != NULL) {
copy_credentials_locked(new_credentials, &s_material);
}
}
}
secure_wipe(&candidate, sizeof(candidate));
xSemaphoreGive(s_security_mutex);
return error;
}
esp_err_t web_security_rotate_certificate(void)
esp_err_t web_security_reserve_identity(uint32_t expected_generation, bool reset, uint32_t *token)
{
if (s_security_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
if (!token || (reset && expected_generation)) return ESP_ERR_INVALID_ARG;
*token = 0;
if (!reset) {
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
bool ready = s_material_ready;
xSemaphoreGive(s_security_mutex);
if (!ready) return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
esp_err_t error = ESP_ERR_INVALID_STATE;
web_security_blob_t candidate;
memset(&candidate, 0, sizeof(candidate));
if (s_material_ready) {
candidate = s_material;
error = increment_generation(&candidate);
if (error == ESP_OK) {
error = generate_certificate(&candidate);
}
if (error == ESP_OK) {
error = save_blob(&candidate);
}
if (error == ESP_OK) {
install_committed_blob(&candidate);
}
}
secure_wipe(&candidate, sizeof(candidate));
xSemaphoreGive(s_security_mutex);
return error;
}
esp_err_t web_security_reset_all(web_security_credentials_t *new_credentials)
{
esp_err_t error = secure_random_init();
if (error != ESP_OK) {
return error;
}
error = ensure_security_mutex();
if (error != ESP_OK) {
return error;
if (error == ESP_OK) error = ensure_security_mutex();
if (error != ESP_OK) return error;
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
if (s_identity_token || s_next_identity_token == UINT32_MAX ||
(!s_material_ready && !reset) ||
(s_material_ready && s_material.generation == UINT32_MAX) ||
(expected_generation && expected_generation != s_material.generation)) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
*token = s_identity_token = ++s_next_identity_token;
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
web_security_blob_t candidate;
uint32_t generation = 1U;
if (s_material_ready) {
if (s_material.generation == UINT32_MAX) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
generation = s_material.generation + 1U;
}
error = generate_all(&candidate, generation);
if (error == ESP_OK) {
error = save_blob(&candidate);
}
if (error == ESP_OK) {
install_committed_blob(&candidate);
if (new_credentials != NULL) {
copy_credentials_locked(new_credentials, &s_material);
}
}
secure_wipe(&candidate, sizeof(candidate));
s_identity_used = false;
xSemaphoreGive(s_security_mutex);
return ESP_OK;
}
esp_err_t web_security_replace_reserved(uint32_t token)
{
if (!s_security_mutex || !token) return ESP_ERR_INVALID_STATE;
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (s_identity_token != token || s_identity_used) {
xSemaphoreGive(s_security_mutex);
return ESP_ERR_INVALID_STATE;
}
s_identity_used = true;
uint32_t generation = s_material_ready ? s_material.generation + 1U : 1U;
xSemaphoreGive(s_security_mutex);
/* The reservation, not a held mutex/spinlock, excludes all identity writers. */
web_security_blob_t candidate = {0};
esp_err_t error = generate_all(&candidate, generation);
if (error == ESP_OK) error = save_blob(&candidate);
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (error == ESP_OK) install_committed_blob(&candidate);
xSemaphoreGive(s_security_mutex);
secure_wipe(&candidate, sizeof(candidate));
return error;
}
void web_security_release_identity(uint32_t token)
{
if (!s_security_mutex || !token) return;
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
if (s_identity_token == token) s_identity_token = 0;
xSemaphoreGive(s_security_mutex);
}
static esp_err_t replace_identity(bool reset)
{
uint32_t token = 0;
esp_err_t error = web_security_reserve_identity(0, reset, &token);
if (error == ESP_OK) error = web_security_replace_reserved(token);
web_security_release_identity(token);
return error;
}
esp_err_t web_security_rotate_certificate(void) { return replace_identity(false); }
esp_err_t web_security_reset_all(void) { return replace_identity(true); }
+31 -20
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Persistent HTTPS identity and legacy migration/recovery credentials. */
/* Persistent HTTPS identity; authentication belongs to the user database. */
#pragma once
@@ -16,9 +16,7 @@ extern "C" {
#define WEB_SECURITY_NVS_NAMESPACE "web_sec"
#define WEB_SECURITY_NVS_BLOB_KEY "material"
#define WEB_SECURITY_USERNAME_CAPACITY 16U
#define WEB_SECURITY_PASSWORD_CAPACITY 32U
#define WEB_SECURITY_PASSWORD_LENGTH 24U
#define WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY 256U
#define WEB_SECURITY_CERTIFICATE_DER_CAPACITY 1024U
#define WEB_SECURITY_SHA256_LENGTH 32U
@@ -31,18 +29,9 @@ extern "C" {
typedef enum {
WEB_SECURITY_LOAD_STORED = 0,
WEB_SECURITY_LOAD_GENERATED_MISSING = 1,
WEB_SECURITY_LOAD_MIGRATED_V1 = 2,
} web_security_load_result_t;
/*
* This intentionally contains a displayable secret. UART callers should call
* secure_wipe() on it immediately after rendering the length-delimited fields.
*/
typedef struct {
size_t username_length;
size_t password_length;
char username[WEB_SECURITY_USERNAME_CAPACITY + 1U];
char password[WEB_SECURITY_PASSWORD_CAPACITY + 1U];
} web_security_credentials_t;
typedef struct {
uint32_t material_generation;
@@ -55,7 +44,11 @@ typedef struct {
} web_security_certificate_metadata_t;
/*
* NVS must already be initialized. Missing material is generated and saved;
* NVS must already be initialized. Missing TLS material is generated and saved.
* Valid v1 material is migrated to certificate-only v2 before publication,
* preserving exact TLS identity and generation. Replacement is logical NVS
* deletion of legacy fields, not secure flash erasure. Migration failure never
* triggers regeneration or fallback overwrite;
* an existing wrong-version blob returns ESP_ERR_INVALID_VERSION, while any
* malformed or cryptographically inconsistent blob returns
* ESP_ERR_INVALID_RESPONSE and is never overwritten. Call before radio startup
@@ -73,17 +66,35 @@ esp_err_t web_security_copy_tls_material(
uint8_t *private_key, size_t private_key_capacity,
size_t *private_key_length);
/* Explicit secret-bearing API intended for a physically attached UART CLI. */
esp_err_t web_security_show_credentials(web_security_credentials_t *credentials);
esp_err_t web_security_get_certificate_metadata(
web_security_certificate_metadata_t *metadata);
typedef struct {
uint32_t generation;
uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH];
bool busy;
} web_security_identity_snapshot_t;
/* Zero-wait public metadata only; never returns DER or private material. */
esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *snapshot);
/* Internal owner transaction shared with canonical mutations. Tokens never reuse.
* Reserve before crypto; retain through service restart. No lock stays held.
* Zero expected_generation selects canonical CLI semantics; reset permits recovery.
* Only the reserving owner may replace once and release its token. */
esp_err_t web_security_reserve_identity(uint32_t expected_generation, bool reset, uint32_t *token);
esp_err_t web_security_replace_reserved(uint32_t token);
void web_security_release_identity(uint32_t token);
/* Mutations become visible only after a complete blob has committed to NVS. */
esp_err_t web_security_rotate_credentials(web_security_credentials_t *new_credentials);
esp_err_t web_security_rotate_certificate(void);
/* Explicitly replaces missing, valid, or incompatible stored material. */
esp_err_t web_security_reset_all(web_security_credentials_t *new_credentials);
/* TLS ONLY: explicitly replaces missing, valid, or incompatible material.
* Generation increments from live state, or starts at one if unavailable.
* No user database mutation. */
esp_err_t web_security_reset_all(void);
#ifdef __cplusplus
}
+240
View File
@@ -0,0 +1,240 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_serial_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "serial_service.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
enum { APPLY, START, STOP, SAVE, LOAD, DEFAULTS, RESET, ACTION_COUNT };
static const char *const s_actions[] = {"apply", "start", "stop", "save", "load", "defaults", "reset"};
enum { IDLE, PENDING, OK, FAILED, CANCELLED, LOADED_DEFAULTS, ROLLBACK_FAILED };
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "loaded_defaults", "rollback_failed"};
typedef struct {
uint32_t id;
web_session_id_t session;
user_principal_t principal;
int64_t deadline;
serial_config_t config;
unsigned action, state;
} serial_operation_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static serial_operation_t s_operation;
static uint32_t s_next_id;
/* Deliberately narrow flat JSON: ASCII names/enums, unsigned decimal integers,
* no escapes, nesting, duplicate/unknown fields, exponent or fractional values. */
static bool parse(const char *body, size_t length, serial_operation_t *operation)
{
const char *keys[] = {"action", "baud", "data_bits", "parity", "stop_bits", "flow", "dtr", "rts_threshold"};
unsigned seen = 0;
size_t pos = 0;
serial_config_defaults(&operation->config);
operation->action = ACTION_COUNT;
#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 < 8; ++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 < 8; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
if (key == 8 || (seen & (1U << key))) return false;
++pos; TAKE(':'); SPACE();
uint32_t number = 0;
char value[16] = {0};
if (key == 1 || key == 7) {
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
if (number > 1000000U) return false;
number = number * 10 + (unsigned)(body[pos++] - '0');
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
} else {
TAKE('"'); start = pos;
while (pos < length && body[pos] != '"') {
if (body[pos] < ' ' || body[pos] > '~' || body[pos] == '\\' || pos - start >= sizeof(value) - 1) return false;
++pos;
}
if (pos == length) return false;
memcpy(value, body + start, pos - start); ++pos;
}
switch (key) {
case 0:
for (unsigned i = 0; i < ACTION_COUNT; ++i)
if (!strcmp(value, s_actions[i])) operation->action = i;
if (operation->action == ACTION_COUNT) return false;
break;
case 1: operation->config.baud_rate = number; break;
case 2: if (!serial_config_parse_data_bits(value, &operation->config.data_bits)) return false; break;
case 3: if (!serial_config_parse_parity(value, &operation->config.parity)) return false; break;
case 4: if (!serial_config_parse_stop_bits(value, &operation->config.stop_bits)) return false; break;
case 5: if (!serial_config_parse_flow_control(value, &operation->config.flow_control)) return false; break;
case 6: if (!serial_config_parse_dtr_behavior(value, &operation->config.dtr_behavior)) return false; break;
case 7: operation->config.rts_threshold = number; break;
}
seen |= 1U << key;
SPACE();
if (pos < length && body[pos] == '}') break;
}
TAKE('}'); SPACE();
#undef TAKE
#undef SPACE
return pos == length && seen == (operation->action == APPLY ? 255U : 1U) &&
serial_config_validate(&operation->config) == ESP_OK;
}
void web_serial_settings_execute(uint32_t id)
{
serial_operation_t operation;
taskENTER_CRITICAL(&s_lock);
operation = s_operation;
taskEXIT_CRITICAL(&s_lock);
if (!id || operation.id != id || operation.state != PENDING) {
secure_wipe(&operation, sizeof(operation));
return;
}
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
/* Operation-admission currentness, not cancellation of an admitted NVS
* commit. CLI commands cannot interleave on this single dispatcher. */
serial_config_t config, previous;
bool stored = true;
state = OK;
switch (operation.action) {
case APPLY: error = serial_service_apply_config(&operation.config); break;
case START: error = serial_service_start(); break;
case STOP: error = serial_service_stop(); break;
case SAVE:
error = serial_service_get_config(&config);
if (error == ESP_OK) error = serial_config_save(&config);
break;
case LOAD:
error = serial_config_load(&config, &stored);
if (error == ESP_OK) error = serial_service_apply_config(&config);
if (!stored) state = LOADED_DEFAULTS;
break;
case DEFAULTS:
serial_config_defaults(&config);
error = serial_service_apply_config(&config);
break;
case RESET:
serial_config_defaults(&config);
error = serial_service_get_config(&previous);
if (error == ESP_OK) error = serial_service_apply_config(&config);
if (error == ESP_OK) {
error = serial_config_reset_storage();
if (error != ESP_OK && serial_service_apply_config(&previous) != ESP_OK)
state = ROLLBACK_FAILED;
}
break;
default: error = ESP_ERR_INVALID_ARG; break;
}
if (error != ESP_OK && state != ROLLBACK_FAILED) state = FAILED;
}
taskENTER_CRITICAL(&s_lock);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
secure_wipe(&s_operation.config, sizeof(s_operation.config));
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
esp_err_t web_serial_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
bool mutation = request->method == HTTP_POST;
esp_err_t error = mutation
? web_cookie_auth_require_json(request, 256, &view, &allowed)
: web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
serial_operation_t operation = {0};
if (mutation) {
char type[40] = {0}, body[256];
size_t received = 0;
bool valid = request->content_len &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
/* Finite bytes and receive calls; timeout/error closes, never retry/drain. */
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_serial_request\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id;
s_operation = operation;
}
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_serial_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
secure_wipe(&operation, sizeof(operation));
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id;
operation.action = s_operation.action;
operation.state = s_operation.state;
}
taskEXIT_CRITICAL(&s_lock);
}
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
secure_wipe(&operation, sizeof(operation));
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+14
View File
@@ -0,0 +1,14 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* HTTPD owns requests/responses; the existing admin dispatcher alone executes.
* One global slot rejects mutations while pending (including execution). GET
* exposes only the caller's session result; a later admitted operation replaces
* that result, so this is not a durable history or an idempotent retry API.
* The 30-second deadline is checked when dequeued, not a completion deadline or
* a timer that frees the slot. Revocation/expiry cancels before operation
* admission; admitted serial/NVS work may finish after the session is gone. */
esp_err_t web_serial_settings_handler(httpd_req_t *request);
void web_serial_settings_execute(uint32_t id);
+298 -32
View File
@@ -14,6 +14,9 @@
#include "sdkconfig.h"
#include "secure_random.h"
#include "serial_service.h"
#include "web_auth_parse.h"
#include "web_httpd_adapter.h"
#include "web_admin_transport.h"
#if !defined(CONFIG_HTTPD_WS_SUPPORT) || !CONFIG_HTTPD_WS_SUPPORT
#error "web_serial_transport requires CONFIG_HTTPD_WS_SUPPORT"
@@ -49,6 +52,7 @@ typedef struct {
uint8_t digest[WEB_SERIAL_SHA256_BYTES];
int64_t expires_at_us;
user_principal_t principal;
web_session_id_t web_session_id;
bool active;
} web_serial_ticket_t;
@@ -61,6 +65,9 @@ typedef struct {
uint32_t generation;
} web_serial_work_t;
_Static_assert(WEB_SERIAL_TRANSPORT_TX_PAYLOAD_SIZE == WEB_HTTPD_WS_BINARY_MAX_PAYLOAD,
"Reaudit bounded binary WS send when changing serial payload size");
typedef struct web_serial_slot {
web_serial_slot_state_t state;
httpd_handle_t server;
@@ -68,6 +75,7 @@ typedef struct web_serial_slot {
uint32_t generation;
session_broker_client_id_t broker_client_id;
user_principal_t principal;
web_session_id_t web_session_id;
int64_t next_currentness_check_us;
bool writer;
bool hello_pending;
@@ -81,6 +89,10 @@ typedef struct web_serial_slot {
uint8_t rx_data[WEB_SERIAL_TRANSPORT_MAX_RX_PAYLOAD];
uint8_t tx_data[WEB_SERIAL_TRANSPORT_TX_PAYLOAD_SIZE];
web_serial_work_t work;
web_serial_performance_session_t performance;
uint32_t performance_epoch, completion_epoch;
int64_t queued_us, completed_us;
bool completion_waiting, completion_attempted;
} web_serial_slot_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
@@ -95,6 +107,97 @@ static web_serial_slot_t s_slots[WEB_SERIAL_TRANSPORT_MAX_SESSIONS];
static web_serial_transport_counters_t s_counters;
static uint32_t s_httpd_close_operations;
static uint32_t s_inflight_handlers;
/* Cancels ticket publication across revocation and server detach/re-attach.
* Never wraps: exhaustion disables minting for the remainder of the boot. */
static uint64_t s_ticket_epoch;
/* Atomic gate permits a true pre-lock callback-entry timestamp. Never reused. */
static uint32_t s_performance_gate, s_performance_epoch;
static uint32_t performance_gate(void)
{
return __atomic_load_n(&s_performance_gate, __ATOMIC_RELAXED);
}
static void performance_add(web_serial_slot_t *slot, uint64_t *value, uint64_t n)
{
if (UINT64_MAX - *value < n) {
*value = UINT64_MAX;
slot->performance.saturated = true;
} else *value += n;
}
static void performance_time(web_serial_slot_t *slot,
web_serial_performance_timing_t *value,
int64_t start, int64_t end)
{
uint64_t elapsed = end >= start ? (uint64_t)(end - start) : 0;
performance_add(slot, &value->count, 1);
performance_add(slot, &value->sum_us, elapsed);
if (elapsed > value->max_us) value->max_us = elapsed;
}
static esp_err_t performance_control(bool enabled, bool clear)
{
taskENTER_CRITICAL(&s_lock);
if (clear) enabled = performance_gate() != 0;
if (s_performance_epoch == UINT32_MAX) {
__atomic_store_n(&s_performance_gate, 0, __ATOMIC_RELAXED);
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
++s_performance_epoch;
__atomic_store_n(&s_performance_gate, enabled ? s_performance_epoch : 0,
__ATOMIC_RELAXED);
for (unsigned i = 0; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) {
s_slots[i].completion_waiting = false;
if (clear) memset(&s_slots[i].performance, 0, sizeof(s_slots[i].performance));
}
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t web_serial_performance_enable(bool enabled)
{
return performance_control(enabled, false);
}
esp_err_t web_serial_performance_clear(void)
{
return performance_control(false, true);
}
esp_err_t web_serial_performance_snapshot(web_serial_performance_snapshot_t *out)
{
if (!out) return ESP_ERR_INVALID_ARG;
taskENTER_CRITICAL(&s_lock);
uint32_t gate = performance_gate();
int64_t now = gate ? esp_timer_get_time() : 0;
*out = (web_serial_performance_snapshot_t){
.enabled = gate != 0, .epoch = s_performance_epoch,
.epoch_exhausted = s_performance_epoch == UINT32_MAX};
for (unsigned i = 0; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) {
web_serial_slot_t *slot = &s_slots[i];
web_serial_performance_session_t *row = &out->sessions[i];
*row = slot->performance;
row->active = slot->state == WEB_SERIAL_SLOT_ACTIVE;
row->socket_fd = slot->socket_fd;
row->generation = slot->generation;
row->broker_client_id = slot->broker_client_id;
row->pending = slot->work_pending;
row->measured_pending = row->pending && gate && slot->performance_epoch == gate;
row->executing = row->measured_pending && row->executing;
row->pending_age_us = row->measured_pending && now >= slot->queued_us ?
(uint64_t)(now - slot->queued_us) : 0;
}
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
static esp_err_t identity_is_current(const user_principal_t *principal,
web_session_id_t id, bool *current)
{
return web_session_store_check_principal(id, principal, current);
}
static TickType_t milliseconds_to_ticks(uint32_t milliseconds)
{
@@ -146,6 +249,7 @@ static void clear_ticket_locked(web_serial_ticket_t *ticket)
secure_wipe(ticket->digest, sizeof(ticket->digest));
secure_wipe(&ticket->principal, sizeof(ticket->principal));
ticket->expires_at_us = 0;
ticket->web_session_id = 0U;
ticket->active = false;
}
@@ -257,9 +361,6 @@ static esp_err_t validate_origin(httpd_req_t *request)
esp_err_t result = httpd_req_get_hdr_value_str(
request, "Origin", origin, sizeof(origin));
if (result == ESP_ERR_NOT_FOUND) {
return ESP_OK;
}
if (result != ESP_OK) {
return ESP_ERR_INVALID_ARG;
}
@@ -271,9 +372,7 @@ static esp_err_t validate_origin(httpd_req_t *request)
return ESP_ERR_INVALID_ARG;
}
int written = snprintf(expected, sizeof(expected), "https://%s", host);
bool matches = written > 0 && (size_t)written < sizeof(expected) &&
strcmp(origin, expected) == 0;
bool matches = web_auth_parse_origin(host, strlen(host), origin, strlen(origin), expected);
secure_wipe(origin, sizeof(origin));
secure_wipe(host, sizeof(host));
secure_wipe(expected, sizeof(expected));
@@ -281,6 +380,7 @@ static esp_err_t validate_origin(httpd_req_t *request)
}
static esp_err_t consume_ticket(const char *ticket,
web_session_id_t web_session_id,
user_principal_t *principal, bool *consumed)
{
uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0};
@@ -315,7 +415,8 @@ static esp_err_t consume_ticket(const char *ticket,
if (matching_count == 1U &&
matching_index < WEB_SERIAL_TRANSPORT_MAX_TICKETS) {
web_serial_ticket_t *entry = &s_tickets[matching_index];
if (entry->active && entry->expires_at_us > now_us) {
if (entry->active && entry->expires_at_us > now_us &&
entry->web_session_id == web_session_id) {
candidate = entry->principal;
clear_ticket_locked(entry);
ticket_found = true;
@@ -336,7 +437,7 @@ static esp_err_t consume_ticket(const char *ticket,
if (ticket_found) {
bool current = false;
result = user_database_principal_is_current(&candidate, &current);
result = identity_is_current(&candidate, web_session_id, &current);
if (result == ESP_OK && current) {
*principal = candidate;
*consumed = true;
@@ -365,12 +466,16 @@ static web_serial_slot_t *reserve_slot(httpd_handle_t server, int socket_fd,
if (slot->state != WEB_SERIAL_SLOT_FREE) {
continue;
}
memset(&slot->performance, 0, sizeof(slot->performance));
slot->performance_epoch = 0;
slot->completion_waiting = false;
slot->generation = next_generation(slot->generation);
slot->state = WEB_SERIAL_SLOT_RESERVED;
slot->server = server;
slot->socket_fd = socket_fd;
slot->broker_client_id = SESSION_BROKER_NO_CLIENT;
secure_wipe(&slot->principal, sizeof(slot->principal));
slot->web_session_id = 0U;
slot->next_currentness_check_us = 0;
slot->writer = false;
slot->hello_pending = false;
@@ -396,6 +501,7 @@ static void make_slot_free_locked(web_serial_slot_t *slot)
slot->socket_fd = -1;
slot->broker_client_id = SESSION_BROKER_NO_CLIENT;
secure_wipe(&slot->principal, sizeof(slot->principal));
slot->web_session_id = 0U;
slot->next_currentness_check_us = 0;
slot->writer = false;
slot->hello_pending = false;
@@ -512,7 +618,8 @@ static esp_err_t send_plain_bad_request(httpd_req_t *request)
return result;
}
static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd,
web_session_id_t web_session_id)
{
char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY] = {0};
uint32_t slot_generation = 0U;
@@ -535,7 +642,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
goto cleanup;
}
result = consume_ticket(ticket, &principal, &consumed);
result = consume_ticket(ticket, web_session_id, &principal, &consumed);
if (result != ESP_OK || !consumed) {
release_reserved_slot(slot, slot_generation);
result = ESP_FAIL;
@@ -547,6 +654,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
!slot->close_requested;
if (principal_staged) {
slot->principal = principal;
slot->web_session_id = web_session_id;
}
taskEXIT_CRITICAL(&s_lock);
if (!principal_staged) {
@@ -571,7 +679,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
}
bool principal_current = false;
result = user_database_principal_is_current(&principal, &principal_current);
result = identity_is_current(&principal, web_session_id, &principal_current);
if (result != ESP_OK || !principal_current) {
release_reserved_slot(slot, slot_generation);
result = ESP_FAIL;
@@ -602,7 +710,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
}
principal_current = false;
result = user_database_principal_is_current(&principal, &principal_current);
result = identity_is_current(&principal, web_session_id, &principal_current);
if (result != ESP_OK || !principal_current) {
close_unpublished_broker_session(slot, slot_generation, client_id);
result = ESP_FAIL;
@@ -625,7 +733,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
}
principal_current = false;
result = user_database_principal_is_current(&principal, &principal_current);
result = identity_is_current(&principal, web_session_id, &principal_current);
if (result != ESP_OK || !principal_current) {
close_unpublished_broker_session(slot, slot_generation, client_id);
result = ESP_FAIL;
@@ -633,6 +741,12 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
}
bool activated = false;
/* Ticket and principal admission must succeed before HTTP 101. */
result = web_httpd_upgrade(request, web_serial_transport_ws_handler);
if (result != ESP_OK) {
close_unpublished_broker_session(slot, slot_generation, client_id);
goto cleanup;
}
int64_t next_currentness_check_us =
monotonic_time_us() + WEB_SERIAL_CURRENTNESS_INTERVAL_US;
taskENTER_CRITICAL(&s_lock);
@@ -672,7 +786,8 @@ cleanup:
static bool capture_active_session(httpd_req_t *request, web_serial_slot_t **slot_out,
uint32_t *generation,
session_broker_client_id_t *client_id,
user_principal_t *principal)
user_principal_t *principal,
web_session_id_t *web_session_id)
{
web_serial_slot_t *slot = request->sess_ctx;
int socket_fd = httpd_req_to_sockfd(request);
@@ -692,6 +807,7 @@ static bool capture_active_session(httpd_req_t *request, web_serial_slot_t **slo
*generation = slot->generation;
*client_id = slot->broker_client_id;
*principal = slot->principal;
*web_session_id = slot->web_session_id;
}
taskEXIT_CRITICAL(&s_lock);
return valid;
@@ -803,8 +919,9 @@ static esp_err_t process_websocket_frame(httpd_req_t *request)
uint32_t generation = 0U;
session_broker_client_id_t client_id = SESSION_BROKER_NO_CLIENT;
user_principal_t principal = {0};
web_session_id_t web_session_id = 0U;
if (!capture_active_session(request, &slot, &generation, &client_id,
&principal)) {
&principal, &web_session_id)) {
add_counter(&s_counters.protocol_errors, 1U);
return ESP_FAIL;
}
@@ -839,7 +956,7 @@ static esp_err_t process_websocket_frame(httpd_req_t *request)
bool current = false;
esp_err_t currentness_result =
user_database_principal_is_current(&principal, &current);
identity_is_current(&principal, web_session_id, &current);
secure_wipe(&principal, sizeof(principal));
if (currentness_result != ESP_OK || !current) {
request_handler_close(slot, generation);
@@ -877,6 +994,8 @@ static void finish_closing_slot_locked(web_serial_slot_t *slot)
static void web_serial_send_work(void *argument)
{
uint32_t entry_epoch = performance_gate();
int64_t entry_us = entry_epoch ? esp_timer_get_time() : 0;
web_serial_work_t *work = argument;
web_serial_slot_t *slot = work != NULL ? work->slot : NULL;
if (!slot_pointer_valid(slot)) {
@@ -903,6 +1022,14 @@ static void web_serial_send_work(void *argument)
generation = work->generation;
type = slot->tx_type;
length = slot->tx_length;
uint32_t sample_epoch = owned_work && entry_epoch &&
entry_epoch == performance_gate() && slot->performance_epoch == entry_epoch &&
type == HTTPD_WS_TYPE_BINARY ? entry_epoch : 0;
if (sample_epoch) {
performance_time(slot, &slot->performance.queue_wait, slot->queued_us, entry_us);
slot->performance.executing = valid;
if (!valid) performance_add(slot, &slot->performance.retired, 1);
}
if (owned_work && !valid) {
slot->work_pending = false;
slot->tx_length = 0U;
@@ -918,6 +1045,8 @@ static void web_serial_send_work(void *argument)
}
esp_err_t result = ESP_FAIL;
int64_t send_start = 0, send_end = 0;
bool send_called = false;
void *current_context = httpd_sess_get_ctx(server, socket_fd);
if (current_context == slot &&
httpd_ws_get_fd_info(server, socket_fd) ==
@@ -929,7 +1058,12 @@ static void web_serial_send_work(void *argument)
.payload = slot->tx_data,
.len = length,
};
result = httpd_ws_send_frame_async(server, socket_fd, &frame);
send_called = true;
if (sample_epoch && performance_gate() == sample_epoch) send_start = esp_timer_get_time();
result = type == HTTPD_WS_TYPE_BINARY
? web_httpd_ws_send_binary(server, socket_fd, slot, slot->tx_data, length)
: httpd_ws_send_frame_async(server, socket_fd, &frame);
if (sample_epoch && performance_gate() == sample_epoch) send_end = esp_timer_get_time();
if (result == ESP_OK) {
(void)httpd_sess_update_lru_counter(server, socket_fd);
}
@@ -938,6 +1072,19 @@ static void web_serial_send_work(void *argument)
taskENTER_CRITICAL(&s_lock);
if (slot->work_pending && slot->generation == generation &&
work == &slot->work) {
if (sample_epoch && performance_gate() == sample_epoch &&
slot->performance_epoch == sample_epoch) {
slot->performance.executing = false;
if (send_called) performance_time(slot, &slot->performance.send_call, send_start, send_end);
if (result == ESP_OK) {
performance_add(slot, &slot->performance.sent_frames, 1);
performance_add(slot, &slot->performance.sent_bytes, length);
slot->completion_epoch = sample_epoch;
slot->completed_us = send_end;
slot->completion_waiting = true;
slot->completion_attempted = false;
} else performance_add(slot, &slot->performance.send_errors, 1);
}
slot->work_pending = false;
slot->tx_length = 0U;
if (result == ESP_OK) {
@@ -961,6 +1108,8 @@ static void web_serial_send_work(void *argument)
static esp_err_t queue_slot_frame(web_serial_slot_t *slot, uint32_t generation,
httpd_ws_type_t type, size_t length)
{
uint32_t epoch = performance_gate();
int64_t queued_us = epoch ? esp_timer_get_time() : 0;
httpd_handle_t server;
bool prepared = false;
@@ -969,6 +1118,13 @@ static esp_err_t queue_slot_frame(web_serial_slot_t *slot, uint32_t generation,
slot->generation == generation && !slot->work_pending &&
!slot->close_requested && slot->server == s_server &&
length <= sizeof(slot->tx_data)) {
slot->performance_epoch = type == HTTPD_WS_TYPE_BINARY && epoch == performance_gate() ? epoch : 0;
if (slot->performance_epoch) {
slot->queued_us = queued_us;
slot->performance.executing = false;
performance_add(slot, &slot->performance.queued_frames, 1);
performance_add(slot, &slot->performance.queued_bytes, length);
}
slot->tx_type = type;
slot->tx_length = length;
slot->work.slot = slot;
@@ -1000,6 +1156,8 @@ static esp_err_t queue_slot_frame(web_serial_slot_t *slot, uint32_t generation,
slot->tx_length = 0U;
slot->close_requested = true;
++s_counters.queue_failures;
if (epoch && performance_gate() == epoch && slot->performance_epoch == epoch)
performance_add(slot, &slot->performance.queue_errors, 1);
}
taskEXIT_CRITICAL(&s_lock);
notify_transport_task();
@@ -1143,9 +1301,29 @@ static bool prepare_writer_sync(web_serial_slot_t *slot, uint32_t generation,
static void drain_binary_output(web_serial_slot_t *slot, uint32_t generation,
session_broker_client_id_t client_id)
{
uint32_t epoch = performance_gate();
size_t received = 0U;
esp_err_t result = session_broker_read(client_id, slot->tx_data,
sizeof(slot->tx_data), &received);
int64_t drained_us = epoch && performance_gate() == epoch ? esp_timer_get_time() : 0;
if (epoch) {
taskENTER_CRITICAL(&s_lock);
if (performance_gate() == epoch && slot->completion_epoch == epoch &&
slot->generation == generation && slot->broker_client_id == client_id &&
slot->state == WEB_SERIAL_SLOT_ACTIVE && slot->completion_waiting) {
bool nonempty = result == ESP_OK && received > 0;
if (!slot->completion_attempted) {
performance_time(slot, &slot->performance.completion_attempt, slot->completed_us, drained_us);
if (nonempty) performance_time(slot, &slot->performance.completion_first_nonempty, slot->completed_us, drained_us);
}
slot->completion_attempted = true;
if (nonempty) {
performance_time(slot, &slot->performance.completion_nonempty, slot->completed_us, drained_us);
slot->completion_waiting = false;
}
}
taskEXIT_CRITICAL(&s_lock);
}
if (result == ESP_OK && received > 0U) {
(void)queue_slot_frame(slot, generation, HTTPD_WS_TYPE_BINARY, received);
} else if (result == ESP_ERR_NOT_FOUND) {
@@ -1253,6 +1431,7 @@ static void process_broker_disconnect(web_serial_slot_t *slot)
static void process_principal_currentness(web_serial_slot_t *slot)
{
user_principal_t principal = {0};
web_session_id_t web_session_id = 0U;
uint32_t generation = 0U;
bool check = false;
int64_t now_us = monotonic_time_us();
@@ -1262,6 +1441,7 @@ static void process_principal_currentness(web_serial_slot_t *slot)
slot->next_currentness_check_us <= now_us) {
generation = slot->generation;
principal = slot->principal;
web_session_id = slot->web_session_id;
slot->next_currentness_check_us =
now_us + WEB_SERIAL_CURRENTNESS_INTERVAL_US;
check = true;
@@ -1272,7 +1452,7 @@ static void process_principal_currentness(web_serial_slot_t *slot)
}
bool current = false;
esp_err_t result = user_database_principal_is_current(&principal, &current);
esp_err_t result = identity_is_current(&principal, web_session_id, &current);
secure_wipe(&principal, sizeof(principal));
if (result == ESP_OK && current) {
return;
@@ -1417,6 +1597,9 @@ esp_err_t web_serial_transport_attach_server(httpd_handle_t server)
} else {
clear_all_tickets_locked();
s_server = server;
if (s_ticket_epoch != UINT64_MAX) {
++s_ticket_epoch;
}
}
taskEXIT_CRITICAL(&s_lock);
if (result == ESP_OK) {
@@ -1443,6 +1626,9 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server)
* observes the cleared server and disconnects unpublished broker state.
*/
s_server = NULL;
if (s_ticket_epoch != UINT64_MAX) {
++s_ticket_epoch;
}
clear_all_tickets_locked();
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS;
++index) {
@@ -1524,6 +1710,7 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server)
}
esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
web_session_id_t web_session_id,
char *ticket, size_t capacity)
{
if (principal == NULL || ticket == NULL ||
@@ -1532,8 +1719,12 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
}
ticket[0] = '\0';
taskENTER_CRITICAL(&s_lock);
uint64_t epoch = s_ticket_epoch;
taskEXIT_CRITICAL(&s_lock);
bool current = false;
esp_err_t result = user_database_principal_is_current(principal, &current);
esp_err_t result = identity_is_current(principal, web_session_id, &current);
if (result != ESP_OK) {
return result;
}
@@ -1541,6 +1732,26 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
return ESP_ERR_INVALID_STATE;
}
/* Reclaim stale identities without database calls under the transport lock.
* A late result must not clear a ticket published into the same array slot. */
for (size_t i = 0; i < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++i) {
taskENTER_CRITICAL(&s_lock);
web_serial_ticket_t candidate = s_tickets[i];
taskEXIT_CRITICAL(&s_lock);
bool live = false;
if (candidate.active &&
(identity_is_current(&candidate.principal, candidate.web_session_id, &live) != ESP_OK || !live)) {
taskENTER_CRITICAL(&s_lock);
web_serial_ticket_t *entry = &s_tickets[i];
if (entry->active && entry->web_session_id == candidate.web_session_id &&
entry->expires_at_us == candidate.expires_at_us &&
constant_time_equal(entry->digest, candidate.digest, sizeof(entry->digest)))
clear_ticket_locked(entry);
taskEXIT_CRITICAL(&s_lock);
}
secure_wipe(&candidate, sizeof(candidate));
}
uint8_t random_bytes[WEB_SERIAL_RANDOM_BYTES] = {0};
uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0};
result = secure_random_fill(random_bytes, sizeof(random_bytes));
@@ -1553,13 +1764,20 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
goto cleanup;
}
current = false;
result = identity_is_current(principal, web_session_id, &current);
if (result != ESP_OK || !current) {
result = ESP_ERR_INVALID_STATE;
ticket[0] = '\0';
goto cleanup;
}
int64_t now_us = monotonic_time_us();
bool stored = false;
taskENTER_CRITICAL(&s_lock);
if (s_initialized && s_server != NULL) {
if (s_initialized && s_server != NULL && epoch == s_ticket_epoch &&
epoch != UINT64_MAX) {
purge_tickets_locked(now_us);
size_t selected = WEB_SERIAL_TRANSPORT_MAX_TICKETS;
int64_t oldest_expiry = INT64_MAX;
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS;
++index) {
web_serial_ticket_t *entry = &s_tickets[index];
@@ -1567,10 +1785,6 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
selected = index;
break;
}
if (entry->expires_at_us < oldest_expiry) {
oldest_expiry = entry->expires_at_us;
selected = index;
}
}
if (selected < WEB_SERIAL_TRANSPORT_MAX_TICKETS) {
web_serial_ticket_t *entry = &s_tickets[selected];
@@ -1580,6 +1794,7 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
now_us + (int64_t)WEB_SERIAL_TRANSPORT_TICKET_LIFETIME_SECONDS *
1000000LL;
entry->principal = *principal;
entry->web_session_id = web_session_id;
entry->active = true;
++s_counters.tickets_issued;
stored = true;
@@ -1599,7 +1814,8 @@ cleanup:
}
esp_err_t web_serial_transport_handle_authenticated_ticket_request(
httpd_req_t *request, const user_principal_t *principal)
httpd_req_t *request, const user_principal_t *principal,
web_session_id_t web_session_id)
{
if (request == NULL || principal == NULL) {
return ESP_ERR_INVALID_ARG;
@@ -1620,7 +1836,7 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY] = {0};
char response[WEB_SERIAL_TICKET_RESPONSE_CAPACITY];
esp_err_t result = web_serial_transport_mint_ticket(
principal, ticket, sizeof(ticket));
principal, web_session_id, ticket, sizeof(ticket));
if (result != ESP_OK) {
secure_wipe(ticket, sizeof(ticket));
return result;
@@ -1643,6 +1859,9 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
if (result == ESP_OK) {
result = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
}
if (result == ESP_OK) {
result = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
}
if (result == ESP_OK) {
result = httpd_resp_send(request, response, written);
}
@@ -1652,6 +1871,12 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
}
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request)
{
return web_serial_transport_session_ws_handler(request, 0U);
}
esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request,
web_session_id_t web_session_id)
{
if (request == NULL || request->handle == NULL) {
return ESP_ERR_INVALID_ARG;
@@ -1664,12 +1889,14 @@ esp_err_t web_serial_transport_ws_handler(httpd_req_t *request)
httpd_ws_client_info_t info =
httpd_ws_get_fd_info(request->handle, socket_fd);
if (info == HTTPD_WS_CLIENT_HTTP) {
bool opening = request->sess_ctx == NULL && web_session_id != 0U &&
request->method == HTTP_GET && web_httpd_upgrade_requested(request);
if (info == HTTPD_WS_CLIENT_HTTP && !opening) {
(void)send_plain_bad_request(request);
add_counter(&s_counters.protocol_errors, 1U);
return ESP_FAIL;
}
if (info != HTTPD_WS_CLIENT_WEBSOCKET) {
if (info != HTTPD_WS_CLIENT_WEBSOCKET && !opening) {
return ESP_FAIL;
}
@@ -1685,8 +1912,8 @@ esp_err_t web_serial_transport_ws_handler(httpd_req_t *request)
esp_err_t result;
if (request->sess_ctx == NULL) {
/* IDF has already sent 101; authentication failures must only close. */
result = connect_websocket(request, socket_fd);
/* The registered HTTP route defers 101 until admission. */
result = connect_websocket(request, socket_fd, web_session_id);
} else {
result = process_websocket_frame(request);
}
@@ -1769,8 +1996,13 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
return ESP_ERR_INVALID_ARG;
}
web_session_store_invalidate_username(username, username_length);
web_admin_transport_revoke(0, username, username_length);
bool notify = false;
taskENTER_CRITICAL(&s_lock);
if (s_ticket_epoch != UINT64_MAX) {
++s_ticket_epoch;
}
if (!s_initialized) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
@@ -1801,7 +2033,12 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
esp_err_t web_serial_transport_revoke_sessions(void)
{
web_session_store_invalidate_username(NULL, 0U);
web_admin_transport_revoke(0, NULL, 0);
taskENTER_CRITICAL(&s_lock);
if (s_ticket_epoch != UINT64_MAX) {
++s_ticket_epoch;
}
if (!s_initialized) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
@@ -1810,7 +2047,36 @@ esp_err_t web_serial_transport_revoke_sessions(void)
clear_all_tickets_locked();
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
web_serial_slot_t *slot = &s_slots[index];
if (slot->state == WEB_SERIAL_SLOT_ACTIVE) {
if (slot->state == WEB_SERIAL_SLOT_ACTIVE ||
slot->state == WEB_SERIAL_SLOT_RESERVED) {
slot->close_requested = true;
}
}
taskEXIT_CRITICAL(&s_lock);
notify_transport_task();
return ESP_OK;
}
esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id)
{
if (id == 0U) {
return ESP_ERR_INVALID_ARG;
}
web_session_store_invalidate(id);
web_admin_transport_revoke(id, NULL, 0);
taskENTER_CRITICAL(&s_lock);
if (s_ticket_epoch != UINT64_MAX) {
++s_ticket_epoch;
}
for (size_t i = 0U; i < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++i) {
if (s_tickets[i].active && s_tickets[i].web_session_id == id) {
clear_ticket_locked(&s_tickets[i]);
}
}
for (size_t i = 0U; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) {
web_serial_slot_t *slot = &s_slots[i];
if ((slot->state == WEB_SERIAL_SLOT_RESERVED ||
slot->state == WEB_SERIAL_SLOT_ACTIVE) && slot->web_session_id == id) {
slot->close_requested = true;
}
}
+59 -7
View File
@@ -11,6 +11,7 @@
#include "esp_http_server.h"
#include "session_broker.h"
#include "user_database.h"
#include "web_session_store.h"
#ifdef __cplusplus
extern "C" {
@@ -98,35 +99,86 @@ esp_err_t web_serial_transport_attach_server(httpd_handle_t server);
esp_err_t web_serial_transport_detach_server(httpd_handle_t server);
/*
* Mint a one-time bearer ticket bound to a current authenticated principal. The
* Mint a one-time bearer ticket bound to a current authenticated principal and
* nonzero originating web-session ID. The
* principal is copied; the output is exactly 32 Base64URL characters plus a
* terminator and expires after 30 monotonic seconds. Never log or persist it.
*/
esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
web_session_id_t web_session_id,
char *ticket, size_t capacity);
/*
* Convenience POST response helper for /api/ws-ticket. Authentication is
* intentionally outside this module: pass the principal returned by successful
* Basic authentication. Register it as HTTP_POST, not as a public handler.
* authentication, and its session ID. Callers must also
* enforce CSRF/Origin policy. Register as HTTP_POST, not as a public handler.
*/
esp_err_t web_serial_transport_handle_authenticated_ticket_request(
httpd_req_t *request, const user_principal_t *principal);
httpd_req_t *request, const user_principal_t *principal,
web_session_id_t web_session_id);
/*
* Handler for /ws/serial. Register as HTTP_GET with is_websocket=true and
* handle_ws_control_frames=false. The initial upgraded GET authenticates the
* ticket; later invocations process one complete data frame.
* Frame callback installed by the HTTPD adapter after authorized admission.
* Do not register directly: the initial HTTP GET must pass cookie/Origin policy
* and call the session handler below before any 101 response.
*/
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request);
/* Trusted cookie-authorized upgrade caller; validate cookie/Origin first.
* Zero is invalid for initial admission; no Basic fallback exists. */
esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request,
web_session_id_t web_session_id);
/* Invalidates the store first, then marks only matching tickets/slots for owner
* cleanup. Safe to repeat after either store or transport slot reuse. */
esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id);
esp_err_t web_serial_transport_get_snapshot(
web_serial_transport_snapshot_t *snapshot);
typedef struct {
uint64_t count, sum_us, max_us;
} web_serial_performance_timing_t;
typedef struct {
bool active, pending, measured_pending, executing, saturated;
int socket_fd;
uint32_t generation;
session_broker_client_id_t broker_client_id;
uint64_t pending_age_us;
uint64_t queued_frames, queued_bytes, queue_errors;
uint64_t sent_frames, sent_bytes, send_errors, retired;
web_serial_performance_timing_t queue_wait, send_call;
web_serial_performance_timing_t completion_attempt, completion_nonempty;
web_serial_performance_timing_t completion_first_nonempty;
} web_serial_performance_session_t;
typedef struct {
bool enabled, epoch_exhausted;
uint32_t epoch;
web_serial_performance_session_t sessions[WEB_SERIAL_TRANSPORT_MAX_SESSIONS];
} web_serial_performance_snapshot_t;
/* Binary TX only; no broker/HTTPD queries, heap scans, or sensitive identities.
* queued_* counts transport reservations, including reported queue failures;
* sent_* counts successful send-call returns. send_errors includes owner-context
* rejection; send_call timings count only actual API calls. queue_wait includes
* owned callbacks retired without sending. Pending age is since reservation-path
* entry, including an executing send; unavailable epochs are explicitly marked.
* All timing endpoints are local monotonic estimates, never peer acknowledgments.
* Disable freezes aggregates; enable resumes them. Every toggle/clear fences
* in-flight samples. Clear preserves enabled state. Epoch exhaustion fails closed.
* Nonempty completion intervals include idle; first_nonempty requires the first
* subsequent read to return data (not proof of backlog at send completion). */
esp_err_t web_serial_performance_enable(bool enabled);
esp_err_t web_serial_performance_clear(void);
esp_err_t web_serial_performance_snapshot(web_serial_performance_snapshot_t *out);
/* Clearing counters does not alter tickets, sessions, ownership, or queued data. */
esp_err_t web_serial_transport_clear_counters(void);
/* Invalidate tickets/sessions for one account, or all authenticated sessions. */
/* Invalidate cookie records and tickets/sockets for one username (also after
* deletion), or all accounts. Store invalidation occurs even if serial init
* failed. Authoritative store/principal checks supplement notifications. */
esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
size_t username_length);
esp_err_t web_serial_transport_revoke_sessions(void);
+459 -252
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* TLS-only HTTP server with bounded Basic authentication and status output. */
/* TLS-only HTTP server with bounded cookie authentication and status output. */
#include "web_server.h"
@@ -10,12 +10,12 @@
#include "esp_http_server.h"
#include "esp_https_server.h"
#include "esp_log.h"
#include "esp_netif_ip_addr.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "mbedtls/base64.h"
#include "mbedtls/md.h"
#include "secure_random.h"
#include "serial_config.h"
#include "serial_service.h"
@@ -24,43 +24,39 @@
#include "user_database.h"
#include "web_security.h"
#include "web_serial_transport.h"
#include "web_serial_settings.h"
#include "web_account_settings.h"
#include "web_network_settings.h"
#include "web_display_settings.h"
#include "web_broker_settings.h"
#include "web_ssh_settings.h"
#include "web_lifecycle_settings.h"
#include "web_admin_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#include "web_httpd_idle.h"
#include "web_diagnostics.h"
#include "web_ui.h"
#include "wifi_manager.h"
#define WEB_SERVER_PORT 443U
#define WEB_SERVER_MAX_BASIC_DECODED \
(USER_DATABASE_USERNAME_CAPACITY + 1U + USER_DATABASE_PASSWORD_CAPACITY)
#define WEB_SERVER_MAX_BASIC_ENCODED \
(((WEB_SERVER_MAX_BASIC_DECODED + 2U) / 3U) * 4U)
#define WEB_SERVER_MAX_AUTHORIZATION \
((sizeof("Basic ") - 1U) + WEB_SERVER_MAX_BASIC_ENCODED + 1U)
#define WEB_SERVER_STATUS_JSON_CAPACITY 3072U
#define WEB_SERVER_AUTH_CACHE_ENTRIES 4U
#define WEB_SERVER_AUTH_CACHE_KEY_LENGTH 32U
#define WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH 32U
#define WEB_SERVER_AUTH_CACHE_TTL_US 300000000LL
typedef struct {
bool active;
int64_t expires_at_us;
uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH];
user_principal_t principal;
} web_server_auth_cache_entry_t;
static SemaphoreHandle_t s_server_mutex;
static httpd_handle_t s_server;
static bool s_initialized;
static bool s_transitioning;
/* Firmware-lifetime lifecycle fence, independent of counters and handle reuse. */
static uint32_t s_generation = 1U;
static bool s_serial_transport_init_attempted;
static bool s_serial_transport_initialized;
static bool s_serial_transport_attached;
/* Retained across failed stop so queued admin work cannot outlive its server. */
static bool s_admin_transport_owned;
static esp_err_t s_last_error = ESP_ERR_INVALID_STATE;
static esp_err_t s_serial_transport_error = ESP_ERR_INVALID_STATE;
static web_server_counters_t s_counters;
static bool s_auth_cache_ready;
static uint8_t s_auth_cache_key[WEB_SERVER_AUTH_CACHE_KEY_LENGTH];
static web_server_auth_cache_entry_t
s_auth_cache[WEB_SERVER_AUTH_CACHE_ENTRIES];
static esp_err_t ensure_mutex(void)
{
@@ -110,188 +106,19 @@ static esp_err_t send_plain_error(httpd_req_t *request,
return error;
}
static esp_err_t send_authentication_required(httpd_req_t *request)
{
esp_err_t error = httpd_resp_set_hdr(
request, "WWW-Authenticate",
"Basic realm=\"ESP32-SAK\", charset=\"UTF-8\"");
if (error != ESP_OK) {
increment_counter(&s_counters.response_errors);
return error;
}
return send_plain_error(request, "401 Unauthorized", "Authentication required.\n");
}
static bool constant_time_equal(const uint8_t *left, const uint8_t *right,
size_t length)
{
uint8_t difference = 0U;
for (size_t index = 0U; index < length; ++index) {
difference |= left[index] ^ right[index];
}
return difference == 0U;
}
static esp_err_t calculate_auth_cache_digest(
const char *authorization, size_t authorization_length,
uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH])
{
if (!s_auth_cache_ready) {
return ESP_ERR_INVALID_STATE;
}
const mbedtls_md_info_t *info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
if (info == NULL ||
mbedtls_md_hmac(info, s_auth_cache_key, sizeof(s_auth_cache_key),
(const uint8_t *)authorization, authorization_length,
digest) != 0) {
return ESP_FAIL;
}
return ESP_OK;
}
static bool authenticate_from_cache(
const uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH],
user_principal_t *principal)
{
int64_t now = esp_timer_get_time();
for (size_t index = 0U; index < WEB_SERVER_AUTH_CACHE_ENTRIES; ++index) {
web_server_auth_cache_entry_t *entry = &s_auth_cache[index];
if (!entry->active || entry->expires_at_us <= now ||
!constant_time_equal(entry->digest, digest, sizeof(entry->digest))) {
if (entry->active && entry->expires_at_us <= now) {
secure_wipe(entry, sizeof(*entry));
}
continue;
}
bool current = false;
if (user_database_principal_is_current(&entry->principal, &current) == ESP_OK &&
current) {
*principal = entry->principal;
entry->expires_at_us = now + WEB_SERVER_AUTH_CACHE_TTL_US;
return true;
}
secure_wipe(entry, sizeof(*entry));
return false;
}
return false;
}
static void store_authenticated_request(
const uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH],
const user_principal_t *principal)
{
int64_t now = esp_timer_get_time();
size_t selected = 0U;
int64_t earliest_expiry = INT64_MAX;
for (size_t index = 0U; index < WEB_SERVER_AUTH_CACHE_ENTRIES; ++index) {
web_server_auth_cache_entry_t *entry = &s_auth_cache[index];
if (entry->active &&
constant_time_equal(entry->digest, digest, sizeof(entry->digest))) {
selected = index;
break;
}
if (!entry->active || entry->expires_at_us <= now) {
selected = index;
earliest_expiry = INT64_MIN;
} else if (earliest_expiry != INT64_MIN &&
entry->expires_at_us < earliest_expiry) {
selected = index;
earliest_expiry = entry->expires_at_us;
}
}
web_server_auth_cache_entry_t *entry = &s_auth_cache[selected];
secure_wipe(entry, sizeof(*entry));
entry->active = true;
entry->expires_at_us = now + WEB_SERVER_AUTH_CACHE_TTL_US;
memcpy(entry->digest, digest, sizeof(entry->digest));
entry->principal = *principal;
}
static esp_err_t authenticate_request(httpd_req_t *request,
user_principal_t *principal,
bool *authenticated)
{
char authorization[WEB_SERVER_MAX_AUTHORIZATION] = {0};
uint8_t decoded[WEB_SERVER_MAX_BASIC_DECODED] = {0};
size_t decoded_length = 0U;
uint8_t cache_digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH] = {0};
bool cache_digest_valid = false;
esp_err_t result = ESP_OK;
memset(principal, 0, sizeof(*principal));
*authenticated = false;
increment_counter(&s_counters.requests);
size_t header_length = httpd_req_get_hdr_value_len(request, "Authorization");
if (header_length == 0U || header_length >= sizeof(authorization)) {
goto cleanup;
}
if (httpd_req_get_hdr_value_str(request, "Authorization",
authorization, sizeof(authorization)) != ESP_OK ||
header_length < 7U || strncasecmp(authorization, "Basic ", 6U) != 0) {
goto cleanup;
}
result = calculate_auth_cache_digest(authorization, header_length, cache_digest);
if (result != ESP_OK) {
goto cleanup;
}
cache_digest_valid = true;
if (authenticate_from_cache(cache_digest, principal)) {
*authenticated = true;
goto cleanup;
}
int decode_result = mbedtls_base64_decode(
decoded, sizeof(decoded), &decoded_length,
(const unsigned char *)authorization + 6U, header_length - 6U);
if (decode_result != 0 || decoded_length == 0U) {
goto cleanup;
}
uint8_t *separator = memchr(decoded, ':', decoded_length);
if (separator == NULL) {
goto cleanup;
}
size_t username_length = (size_t)(separator - decoded);
size_t password_length = decoded_length - username_length - 1U;
result = user_database_authenticate_password(
decoded, username_length, separator + 1U, password_length,
principal, authenticated);
if (result == ESP_OK && *authenticated && cache_digest_valid) {
store_authenticated_request(cache_digest, principal);
}
cleanup:
secure_wipe(authorization, sizeof(authorization));
secure_wipe(decoded, sizeof(decoded));
secure_wipe(cache_digest, sizeof(cache_digest));
if (result != ESP_OK) {
memset(principal, 0, sizeof(*principal));
return result;
}
if (*authenticated) {
increment_counter(&s_counters.authenticated_requests);
} else {
memset(principal, 0, sizeof(*principal));
increment_counter(&s_counters.authentication_failures);
}
return ESP_OK;
}
static esp_err_t authorize_or_respond(httpd_req_t *request,
user_principal_t *principal,
bool *authorized)
{
*authorized = false;
esp_err_t error = authenticate_request(request, principal, authorized);
if (error != ESP_OK) {
*authorized = false;
return send_plain_error(request, "503 Service Unavailable",
"Authentication service unavailable.\n");
}
return *authorized ? ESP_OK : send_authentication_required(request);
web_session_view_t view = {0};
increment_counter(&s_counters.requests);
esp_err_t error = web_cookie_auth_require(request, false, false, &view, authorized);
web_httpd_wipe_request(request, web_httpd_unread_body(request));
*principal = view.principal;
secure_wipe(&view, sizeof(view));
increment_counter(*authorized ? &s_counters.authenticated_requests :
&s_counters.authentication_failures);
return error;
}
static esp_err_t send_authenticated_ui(httpd_req_t *request,
@@ -329,23 +156,21 @@ static esp_err_t asset_handler(httpd_req_t *request)
static esp_err_t ticket_handler(httpd_req_t *request)
{
user_principal_t principal = {0};
web_session_view_t view = {0};
bool authorized = false;
esp_err_t error = authorize_or_respond(request, &principal, &authorized);
increment_counter(&s_counters.requests);
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &authorized);
increment_counter(authorized ? &s_counters.authenticated_requests : &s_counters.authentication_failures);
if (error != ESP_OK || !authorized) {
secure_wipe(&principal, sizeof(principal));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
increment_counter(&s_counters.ticket_requests);
if (request->content_len != 0U) {
secure_wipe(&principal, sizeof(principal));
return send_plain_error(request, "400 Bad Request",
"Ticket requests must have an empty body.\n");
}
error = web_serial_transport_handle_authenticated_ticket_request(
request, &principal);
secure_wipe(&principal, sizeof(principal));
request, &view.principal, view.id);
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
if (error == ESP_OK) {
return ESP_OK;
}
@@ -353,9 +178,15 @@ static esp_err_t ticket_handler(httpd_req_t *request)
return send_plain_error(request, "400 Bad Request",
"Invalid web-terminal ticket request.\n");
}
if (error == ESP_ERR_NO_MEM &&
httpd_resp_set_hdr(request, "Retry-After", "5") == ESP_OK) {
return send_plain_error(request, "503 Service Unavailable", "{\"error\":\"capacity\"}");
}
increment_counter(&s_counters.response_errors);
return send_plain_error(request, "503 Service Unavailable",
"Web terminal transport unavailable.\n");
if (error == ESP_ERR_INVALID_STATE)
return send_plain_error(request, "503 Service Unavailable",
"Web terminal transport unavailable.\n");
return error; /* A failed/partial send must close, not send a second response. */
}
static const char *safe_string(const char *value)
@@ -404,7 +235,7 @@ static esp_err_t status_handler(httpd_req_t *request)
increment_counter(&s_counters.status_requests);
wifi_manager_snapshot_t wifi = {0};
serial_config_t serial_config = {0};
serial_service_snapshot_t serial_snapshot = {0};
serial_service_counters_t serial_counters = {0};
session_broker_global_snapshot_t broker = {0};
usb_cdc_transport_snapshot_t usb = {0};
@@ -416,7 +247,8 @@ static esp_err_t status_handler(httpd_req_t *request)
char response[WEB_SERVER_STATUS_JSON_CAPACITY];
bool wifi_available = wifi_manager_get_snapshot(&wifi) == ESP_OK;
bool serial_config_available = serial_service_get_config(&serial_config) == ESP_OK;
bool serial_config_available = serial_service_get_snapshot(&serial_snapshot) == ESP_OK;
serial_config_t serial_config = serial_snapshot.config;
serial_service_get_counters(&serial_counters);
bool broker_available = session_broker_get_global_snapshot(&broker) == ESP_OK;
bool usb_available = usb_cdc_transport_get_snapshot(&usb) == ESP_OK;
@@ -460,7 +292,7 @@ static esp_err_t status_handler(httpd_req_t *request)
wifi_available ? (unsigned int)wifi.sta_channel : 0U,
wifi_available && wifi.ap_running ? "true" : "false",
wifi_available ? (unsigned int)wifi.ap_client_count : 0U,
serial_service_is_running() ? "true" : "false",
serial_config_available ? (serial_snapshot.running ? "true" : "false") : "null",
serial_config_available ? "true" : "false",
serial_config_available ? serial_config.baud_rate : 0U,
serial_config_available ? safe_string(serial_config_data_bits_to_string(serial_config.data_bits)) : "unknown",
@@ -510,6 +342,127 @@ static esp_err_t status_handler(httpd_req_t *request)
return error;
}
static esp_err_t serial_settings_handler(httpd_req_t *request)
{
user_principal_t principal = {0};
bool authorized = false;
esp_err_t error = authorize_or_respond(request, &principal, &authorized);
bool admin = authorized && principal.role == USER_ROLE_ADMIN;
secure_wipe(&principal, sizeof(principal));
if (error != ESP_OK || !authorized) return error;
if (!admin)
return send_plain_error(request, "403 Forbidden", "Administrator access required.\n");
serial_service_snapshot_t snapshot = {0};
if (serial_service_get_snapshot(&snapshot) != ESP_OK) {
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error != ESP_OK) return error;
return send_plain_error(request, "503 Service Unavailable", "Serial snapshot unavailable.\n");
}
const serial_config_t *config = &snapshot.config;
char response[256];
/* Only firmware-owned enum names and numeric values, never CLI output. */
int written = snprintf(response, sizeof(response),
"{\"running\":%s,\"baud\":%" PRIu32 ",\"data_bits\":\"%s\","
"\"parity\":\"%s\",\"stop_bits\":\"%s\",\"flow\":\"%s\","
"\"dtr\":\"%s\",\"rts_threshold\":%" PRIu32 "}",
snapshot.running ? "true" : "false", config->baud_rate,
safe_string(serial_config_data_bits_to_string(config->data_bits)),
safe_string(serial_config_parity_to_string(config->parity)),
safe_string(serial_config_stop_bits_to_string(config->stop_bits)),
safe_string(serial_config_flow_control_to_string(config->flow_control)),
safe_string(serial_config_dtr_behavior_to_string(config->dtr_behavior)),
config->rts_threshold);
if (written < 0 || (size_t)written >= sizeof(response))
return send_plain_error(request, "500 Internal Server Error", "Serial response overflow.\n");
error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = set_common_headers(request);
if (error == ESP_OK) error = httpd_resp_send(request, response, (ssize_t)written);
if (error != ESP_OK) increment_counter(&s_counters.response_errors);
return error;
}
static const httpd_uri_t s_serial_settings_uri = {
.uri = "/api/settings/serial",
.method = HTTP_GET,
.handler = serial_settings_handler,
};
static const httpd_uri_t s_serial_operation_get_uri = {
.uri = "/api/settings/serial-operation",
.method = HTTP_GET,
.handler = web_serial_settings_handler,
};
static const httpd_uri_t s_serial_operation_post_uri = {
.uri = "/api/settings/serial-operation",
.method = HTTP_POST,
.handler = web_serial_settings_handler,
};
static const httpd_uri_t s_accounts_uri = {
.uri = "/api/settings/accounts", .method = HTTP_GET, .handler = web_account_settings_handler,
};
static const httpd_uri_t s_account_operation_get_uri = {
.uri = "/api/settings/account-operation", .method = HTTP_GET, .handler = web_account_settings_handler,
};
static const httpd_uri_t s_account_operation_post_uri = {
.uri = "/api/settings/account-operation", .method = HTTP_POST, .handler = web_account_settings_handler,
};
static const httpd_uri_t s_account_keys_uri = {
.uri = "/api/settings/accounts/keys", .method = HTTP_POST,
.handler = web_account_keys_handler,
};
static const httpd_uri_t s_account_generate_password_uri = {
.uri = "/api/settings/accounts/generate-password", .method = HTTP_POST,
.handler = web_account_generate_password_handler,
};
static const httpd_uri_t s_network_uri = {
.uri = "/api/settings/network", .method = HTTP_GET, .handler = web_network_snapshot_handler,
};
static const httpd_uri_t s_lifecycle_settings_uri = {
.uri = "/api/settings/lifecycle", .method = HTTP_GET, .handler = web_lifecycle_settings_handler,
};
static const httpd_uri_t s_lifecycle_operation_get_uri = {
.uri = "/api/settings/lifecycle-operation", .method = HTTP_GET, .handler = web_lifecycle_operation_handler,
};
static const httpd_uri_t s_lifecycle_operation_post_uri = {
.uri = "/api/settings/lifecycle-operation", .method = HTTP_POST, .handler = web_lifecycle_operation_handler,
};
static const httpd_uri_t s_ssh_settings_uri = {
.uri = "/api/settings/ssh", .method = HTTP_GET, .handler = web_ssh_settings_handler,
};
static const httpd_uri_t s_ssh_operation_get_uri = {
.uri = "/api/settings/ssh-operation", .method = HTTP_GET, .handler = web_ssh_operation_handler,
};
static const httpd_uri_t s_ssh_operation_post_uri = {
.uri = "/api/settings/ssh-operation", .method = HTTP_POST, .handler = web_ssh_operation_handler,
};
static const httpd_uri_t s_broker_uri = {
.uri = "/api/settings/broker", .method = HTTP_GET, .handler = web_broker_settings_handler,
};
static const httpd_uri_t s_broker_operation_get_uri = {
.uri = "/api/settings/broker-operation", .method = HTTP_GET, .handler = web_broker_operation_handler,
};
static const httpd_uri_t s_broker_operation_post_uri = {
.uri = "/api/settings/broker-operation", .method = HTTP_POST, .handler = web_broker_operation_handler,
};
static const httpd_uri_t s_display_uri = {
.uri = "/api/settings/display", .method = HTTP_GET, .handler = web_display_settings_handler,
};
static const httpd_uri_t s_display_operation_get_uri = {
.uri = "/api/settings/display-operation", .method = HTTP_GET, .handler = web_display_operation_handler,
};
static const httpd_uri_t s_display_operation_post_uri = {
.uri = "/api/settings/display-operation", .method = HTTP_POST, .handler = web_display_operation_handler,
};
static const httpd_uri_t s_network_operation_get_uri = {
.uri = "/api/settings/network-operation", .method = HTTP_GET, .handler = web_network_operation_handler,
};
static const httpd_uri_t s_network_operation_post_uri = {
.uri = "/api/settings/network-operation", .method = HTTP_POST, .handler = web_network_operation_handler,
};
static const httpd_uri_t s_root_uri = {
.uri = "/",
.method = HTTP_GET,
@@ -524,22 +477,67 @@ static const httpd_uri_t s_status_uri = {
.user_ctx = NULL,
};
static esp_err_t traced_ticket_handler(httpd_req_t *request)
{
return web_diagnostics_handler(request, WEB_DIAG_SERIAL_TICKET, ticket_handler);
}
static const httpd_uri_t s_ticket_uri = {
.uri = WEB_SERIAL_TRANSPORT_TICKET_URI,
.method = HTTP_POST,
.handler = ticket_handler,
.handler = traced_ticket_handler,
.user_ctx = NULL,
};
static esp_err_t websocket_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, false, true, &view, &allowed);
if (allowed) error = web_serial_transport_session_ws_handler(request, view.id);
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
static esp_err_t traced_websocket_handler(httpd_req_t *request)
{
return web_diagnostics_handler(request, WEB_DIAG_SERIAL_UPGRADE, websocket_handler);
}
static esp_err_t traced_admin_ticket_handler(httpd_req_t *request)
{
return web_diagnostics_handler(request, WEB_DIAG_ADMIN_TICKET, web_admin_transport_ticket_handler);
}
static esp_err_t traced_admin_upgrade_handler(httpd_req_t *request)
{
return web_diagnostics_handler(request, WEB_DIAG_ADMIN_UPGRADE, web_admin_transport_upgrade_handler);
}
static const httpd_uri_t s_websocket_uri = {
.uri = WEB_SERIAL_TRANSPORT_WS_URI,
.method = HTTP_GET,
.handler = web_serial_transport_ws_handler,
.handler = traced_websocket_handler,
.user_ctx = NULL,
.is_websocket = true,
/* Authorize and admit before the adapter sends 101, not IDF's pre-handler path. */
.is_websocket = false,
.handle_ws_control_frames = false,
};
static const httpd_uri_t s_admin_ticket_uri = {
.uri = WEB_ADMIN_TICKET_URI,
.method = HTTP_POST,
.handler = traced_admin_ticket_handler,
};
static const httpd_uri_t s_admin_websocket_uri = {
.uri = WEB_ADMIN_WS_URI,
.method = HTTP_GET,
.handler = traced_admin_upgrade_handler,
.is_websocket = false, /* Cookie/Origin/ticket/console admission precedes 101. */
};
static const httpd_uri_t s_xterm_js_uri = {
.uri = "/assets/xterm.js",
.method = HTTP_GET,
@@ -587,6 +585,28 @@ static const httpd_uri_t *const s_uri_handlers[] = {
&s_logo_uri,
};
static const httpd_uri_t s_auth_uris[] = {
{.uri = "/login", .method = HTTP_GET, .handler = web_cookie_auth_handler},
{.uri = "/api/login-challenge", .method = HTTP_GET, .handler = web_cookie_auth_handler},
{.uri = "/api/login", .method = HTTP_POST, .handler = web_cookie_auth_handler},
{.uri = "/api/session", .method = HTTP_GET, .handler = web_cookie_auth_handler},
{.uri = "/api/logout", .method = HTTP_POST, .handler = web_cookie_auth_handler},
};
static esp_err_t route_error_handler(httpd_req_t *request, httpd_err_code_t code)
{
(void)send_plain_error(request,
code == HTTPD_405_METHOD_NOT_ALLOWED ? "405 Method Not Allowed" : "404 Not Found",
"Unsupported route or method.\n");
return ESP_FAIL; /* Do not drain a rejected request body on keepalive. */
}
static void tls_session_callback(esp_https_server_user_cb_arg_t *arg)
{
web_httpd_idle_tls(arg);
web_diagnostics_tls(arg);
}
esp_err_t web_server_init(void)
{
esp_err_t error = ensure_mutex();
@@ -594,19 +614,6 @@ esp_err_t web_server_init(void)
return error;
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (!s_auth_cache_ready) {
error = secure_random_fill(s_auth_cache_key, sizeof(s_auth_cache_key));
if (error == ESP_OK) {
secure_wipe(s_auth_cache, sizeof(s_auth_cache));
s_auth_cache_ready = true;
}
}
xSemaphoreGive(s_server_mutex);
if (error != ESP_OK) {
return error;
}
bool initialize_serial_transport = false;
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (!s_serial_transport_init_attempted) {
@@ -625,49 +632,52 @@ esp_err_t web_server_init(void)
s_serial_transport_error = serial_transport_error;
s_serial_transport_initialized = serial_transport_error == ESP_OK;
}
s_initialized = true;
if (s_last_error == ESP_ERR_INVALID_STATE) {
if (!s_initialized && s_last_error == ESP_ERR_INVALID_STATE) {
s_last_error = ESP_OK;
}
s_initialized = true;
xSemaphoreGive(s_server_mutex);
/* The Phase 5A HTTPS recovery surface remains available if WebSocket setup fails. */
return ESP_OK;
}
esp_err_t web_server_start(void)
static esp_err_t start_server(bool reserved)
{
esp_err_t error = web_server_init();
if (error != ESP_OK) {
return error;
}
esp_err_t error;
bool serial_transport_ready;
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (s_server != NULL || s_transitioning) {
if (s_server != NULL || s_transitioning != reserved) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
s_transitioning = true;
if (s_generation != UINT32_MAX) ++s_generation;
serial_transport_ready = s_serial_transport_initialized;
xSemaphoreGive(s_server_mutex);
/* Initialize only after lifecycle admission; failure gates all HTTPS auth. */
error = web_cookie_auth_start();
if (error == ESP_OK) error = web_httpd_idle_prepare();
uint8_t certificate[WEB_SECURITY_CERTIFICATE_DER_CAPACITY] = {0};
uint8_t private_key[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY] = {0};
size_t certificate_length = 0U;
size_t private_key_length = 0U;
httpd_handle_t server = NULL;
error = web_security_copy_tls_material(
if (error == ESP_OK) error = web_security_copy_tls_material(
certificate, sizeof(certificate), &certificate_length,
private_key, sizeof(private_key), &private_key_length);
if (error == ESP_OK) {
httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT();
/* Two browser terminals retain room for parallel assets and status fetches. */
/* Two serial + one admin socket leave three slots for HTTPS requests. */
config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]);
config.httpd.lru_purge_enable = true;
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 25U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
config.httpd.send_wait_timeout = 1;
config.servercert = certificate;
@@ -676,6 +686,8 @@ esp_err_t web_server_start(void)
config.prvtkey_len = private_key_length;
config.port_secure = WEB_SERVER_PORT;
config.tls_handshake_timeout_ms = 5000U;
/* Public synchronous identity reset/observation; HTTPS retains cleanup. */
config.user_cb = tls_session_callback;
error = httpd_ssl_start(&server, &config);
}
secure_wipe(certificate, sizeof(certificate));
@@ -689,14 +701,70 @@ esp_err_t web_server_start(void)
}
bool serial_transport_attached = false;
for (size_t i = 0; error == ESP_OK && i < sizeof(s_auth_uris) / sizeof(s_auth_uris[0]); ++i)
error = httpd_register_uri_handler(server, &s_auth_uris[i]);
if (error == ESP_OK)
error = httpd_register_err_handler(server, HTTPD_404_NOT_FOUND, route_error_handler);
if (error == ESP_OK)
error = httpd_register_err_handler(server, HTTPD_405_METHOD_NOT_ALLOWED, route_error_handler);
if (error == ESP_OK) error = web_httpd_idle_attach(server);
esp_err_t attach_error = s_serial_transport_error;
if (error == ESP_OK && serial_transport_ready) {
attach_error = web_serial_transport_attach_server(server);
serial_transport_attached = attach_error == ESP_OK;
}
bool admin_transport_owned = false;
if (error == ESP_OK) {
/* Even optional route allocation failure must leave M1 available. */
esp_err_t admin_error = httpd_register_uri_handler(server, &s_admin_ticket_uri);
bool ticket_registered = admin_error == ESP_OK;
if (admin_error == ESP_OK)
admin_error = httpd_register_uri_handler(server, &s_admin_websocket_uri);
if (admin_error != ESP_OK && ticket_registered)
(void)httpd_unregister_uri_handler(server, WEB_ADMIN_TICKET_URI, HTTP_POST);
if (admin_error == ESP_OK && web_admin_transport_init() == ESP_OK)
admin_transport_owned = web_admin_transport_attach(server) == ESP_OK;
/* Optional settings allocation failure must not disable either terminal. */
(void)web_httpd_register_optional_get(server, &s_serial_settings_uri);
if (web_httpd_register_optional(server, &s_serial_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_serial_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_serial_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_accounts_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_account_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_account_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_account_operation_get_uri.uri, HTTP_GET);
(void)web_httpd_register_optional(server, &s_account_generate_password_uri);
(void)web_httpd_register_optional(server, &s_account_keys_uri);
if (web_httpd_register_optional_get(server, &s_network_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_network_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_network_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_network_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_display_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_display_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_display_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_display_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_broker_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_broker_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_broker_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_broker_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_ssh_settings_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_ssh_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_ssh_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_ssh_operation_get_uri.uri, HTTP_GET);
if (web_httpd_register_optional_get(server, &s_lifecycle_settings_uri) == ESP_OK &&
web_httpd_register_optional_get(server, &s_lifecycle_operation_get_uri) == ESP_OK &&
web_httpd_register_optional(server, &s_lifecycle_operation_post_uri) != ESP_OK)
(void)httpd_unregister_uri_handler(server, s_lifecycle_operation_get_uri.uri, HTTP_GET);
}
if (error != ESP_OK) {
web_cookie_auth_stop();
}
if (error != ESP_OK && server != NULL) {
esp_err_t cleanup_error = httpd_ssl_stop(server);
esp_err_t cleanup_error = web_httpd_idle_detach(server);
if (cleanup_error == ESP_OK) cleanup_error = httpd_ssl_stop(server);
if (cleanup_error == ESP_OK) {
web_httpd_idle_stopped(server);
web_lifecycle_settings_stopped(server);
server = NULL;
} else {
/* Retain ownership so stop can retry and start cannot allocate a second server. */
@@ -709,6 +777,7 @@ esp_err_t web_server_start(void)
s_last_error = error;
s_serial_transport_error = attach_error;
s_serial_transport_attached = serial_transport_attached;
s_admin_transport_owned = admin_transport_owned;
if (error == ESP_OK) {
s_server = server;
++s_counters.starts;
@@ -721,23 +790,56 @@ esp_err_t web_server_start(void)
return error;
}
esp_err_t web_server_stop(void)
esp_err_t web_server_start(void)
{
esp_err_t error = web_server_init();
return error == ESP_OK ? start_server(false) : error;
}
static esp_err_t stop_server(uint32_t expected_generation, bool restart, bool reserved)
{
if (s_server_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (s_server == NULL || s_transitioning) {
if (xSemaphoreTake(s_server_mutex, expected_generation ? 0U : portMAX_DELAY) != pdTRUE)
return ESP_ERR_TIMEOUT;
if (s_server == NULL || s_transitioning != reserved ||
(expected_generation && (expected_generation != s_generation ||
s_generation == UINT32_MAX || s_last_error != ESP_OK))) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
httpd_handle_t server = s_server;
bool serial_transport_attached = s_serial_transport_attached;
bool admin_transport_owned = s_admin_transport_owned;
esp_err_t serial_transport_error = s_serial_transport_error;
s_transitioning = true;
if (s_generation != UINT32_MAX) ++s_generation;
xSemaphoreGive(s_server_mutex);
web_cookie_auth_stop();
esp_err_t idle_error = web_httpd_idle_detach(server);
if (idle_error != ESP_OK) {
/* Never destroy HTTPD while a timer submission still holds its handle. */
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
s_last_error = idle_error;
xSemaphoreGive(s_server_mutex);
return idle_error;
}
if (admin_transport_owned) {
esp_err_t detach_error = web_admin_transport_detach(server);
if (detach_error != ESP_OK) {
/* Unlike serial's broker timeout, an admin submission timeout must
* retain HTTPD until detach can fence all queue submitters. */
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
s_last_error = detach_error;
xSemaphoreGive(s_server_mutex);
return detach_error;
}
}
if (serial_transport_attached) {
esp_err_t detach_error = web_serial_transport_detach_server(server);
if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) {
@@ -752,24 +854,128 @@ esp_err_t web_server_stop(void)
}
esp_err_t error = httpd_ssl_stop(server);
if (error == ESP_OK) {
web_httpd_idle_stopped(server);
web_lifecycle_settings_stopped(server);
}
if (error == ESP_OK && admin_transport_owned) web_admin_transport_stopped(server);
if (error != ESP_OK && serial_transport_attached) {
/* Stay detached: old HTTPD work may still be reading static TX storage. */
serial_transport_error = ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
/* Do not expose a stopped/unreserved gap to another lifecycle caller. */
s_transitioning = error == ESP_OK && restart;
s_last_error = error;
s_serial_transport_error = serial_transport_error;
s_serial_transport_attached = false;
if (error == ESP_OK) {
s_server = NULL;
s_admin_transport_owned = false;
++s_counters.stops;
}
xSemaphoreGive(s_server_mutex);
return error == ESP_OK && restart ? start_server(true) : error;
}
esp_err_t web_server_replace_identity(uint32_t expected_service_generation,
uint32_t expected_identity_generation, bool reset, bool *committed)
{
if (!committed || (!!expected_service_generation != !!expected_identity_generation) ||
(reset && expected_service_generation)) return ESP_ERR_INVALID_ARG;
*committed = false;
/* Conditional dispatcher admission must not wait in the legacy initializer. */
esp_err_t error = expected_service_generation
? (s_server_mutex ? ESP_OK : ESP_ERR_INVALID_STATE) : web_server_init();
if (error != ESP_OK) return error;
if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
if (s_transitioning || (expected_service_generation &&
(!s_server || s_last_error != ESP_OK || s_generation == UINT32_MAX ||
expected_service_generation != s_generation))) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
bool running = s_server != NULL;
s_transitioning = true;
xSemaphoreGive(s_server_mutex);
uint32_t token = 0;
error = web_security_reserve_identity(expected_identity_generation, reset, &token);
if (error == ESP_OK) {
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (s_generation != UINT32_MAX) ++s_generation;
xSemaphoreGive(s_server_mutex);
error = web_security_replace_reserved(token);
}
if (error == ESP_OK) {
*committed = true;
if (running) error = stop_server(0, true, true);
else if (reset) error = start_server(true);
else {
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
xSemaphoreGive(s_server_mutex);
}
} else {
/* No identity publication: leave HTTPD and its logins untouched. */
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
xSemaphoreGive(s_server_mutex);
}
web_security_release_identity(token);
return error;
}
esp_err_t web_server_stop(void)
{
return stop_server(0U, false, false);
}
esp_err_t web_server_stop_current(uint32_t expected_generation)
{
if (!expected_generation) return ESP_ERR_INVALID_ARG;
return stop_server(expected_generation, false, false);
}
esp_err_t web_server_restart_current(uint32_t expected_generation)
{
if (!expected_generation) return ESP_ERR_INVALID_ARG;
return stop_server(expected_generation, true, false);
}
esp_err_t web_server_reboot_current(uint32_t expected_generation)
{
if (!expected_generation) return ESP_ERR_INVALID_ARG;
if (s_server_mutex == NULL) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
if (s_server == NULL || s_transitioning || s_last_error != ESP_OK ||
s_generation == UINT32_MAX || expected_generation != s_generation) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
s_transitioning = true;
++s_generation;
xSemaphoreGive(s_server_mutex);
esp_restart();
return ESP_FAIL; /* Defensive only: reset normally never returns. */
}
esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *snapshot)
{
if (snapshot == NULL) return ESP_ERR_INVALID_ARG;
memset(snapshot, 0, sizeof(*snapshot));
if (s_server_mutex == NULL) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
snapshot->generation = s_generation;
snapshot->running = s_server != NULL;
snapshot->transitioning = s_transitioning;
snapshot->controllable = s_initialized && s_server != NULL && !s_transitioning &&
s_last_error == ESP_OK && s_generation != UINT32_MAX;
xSemaphoreGive(s_server_mutex);
return ESP_OK;
}
esp_err_t web_server_get_snapshot(web_server_snapshot_t *snapshot)
{
if (snapshot == NULL) {
@@ -800,5 +1006,6 @@ esp_err_t web_server_clear_counters(void)
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
memset(&s_counters, 0, sizeof(s_counters));
xSemaphoreGive(s_server_mutex);
web_cookie_auth_clear_counters();
return ESP_OK;
}
+33 -1
View File
@@ -39,7 +39,39 @@ typedef struct {
/* Initialize runtime state without requiring valid certificate material. */
esp_err_t web_server_init(void);
/* Start one TLS-only server on all active network interfaces. */
typedef struct {
uint32_t generation;
bool running;
bool transitioning;
bool controllable;
} web_server_management_snapshot_t;
/* Zero-wait, secret-free projection; controllable excludes failed cleanup and
* exhausted generations. No HTTPD work or owner wait is performed. */
esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *snapshot);
/* Conditional lifecycle admission under the canonical server mutex. Call only
* off HTTPD, after caller-owned authorization and bounded ACK handoff. These
* APIs do not authenticate, acknowledge, cancel on revocation, or bound HTTPD
* shutdown time. Restart reserves the lifecycle through stop and start; a failed
* stop never starts another server. Stale/exhausted/unclean state rejects without
* side effects. Canonical stop/start below remain the recovery path. */
esp_err_t web_server_stop_current(uint32_t expected_generation);
esp_err_t web_server_restart_current(uint32_t expected_generation);
/* Reserves this HTTPS generation before canonical whole-device esp_restart().
* Admission cannot be cancelled; normally does not return. Same caller rules. */
esp_err_t web_server_reboot_current(uint32_t expected_generation);
/* Combined identity/service owner operation, off HTTPD only. Nonzero expected
* generations select healthy running conditional rotation; both zero select CLI.
* reset is CLI-only and starts a stopped service; ordinary rotation leaves it stopped.
* committed reports irreversible NVS publication even when stop/start later fails.
* Reservation covers generation checks, crypto/commit and canonical stop/start. */
esp_err_t web_server_replace_identity(uint32_t expected_service_generation,
uint32_t expected_identity_generation, bool reset, bool *committed);
/* Start one TLS-only server on all active network interfaces.
* Start/stop may wait for HTTPD; never call from its task or queued callbacks. */
esp_err_t web_server_start(void);
esp_err_t web_server_stop(void);
+468
View File
@@ -0,0 +1,468 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_session_store.h"
#include <limits.h>
#include <string.h>
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "mbedtls/sha256.h"
#include "secure_random.h"
typedef struct {
web_session_id_t id;
int64_t expires_at_us;
user_principal_t principal;
uint8_t token_digest[WEB_SESSION_STORE_SECRET_BYTES];
uint8_t origin_digest[WEB_SESSION_STORE_SECRET_BYTES];
uint8_t csrf[WEB_SESSION_STORE_SECRET_BYTES];
} session_entry_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static struct {
session_entry_t entries[WEB_SESSION_STORE_CAPACITY];
uint64_t next_id;
uint64_t epoch;
bool ready;
bool initializing;
uint32_t issued;
uint32_t capacity_rejections;
uint32_t expired;
uint32_t invalidated;
uint32_t lookup_rejections;
uint32_t init_failures;
} s_state;
static bool equal_bytes(const uint8_t *a, const uint8_t *b, size_t length)
{
uint8_t difference = 0U;
for (size_t i = 0U; i < length; ++i) {
difference |= a[i] ^ b[i];
}
return difference == 0U;
}
static void encode_hex(const uint8_t *bytes, char *text)
{
static const char hex[] = "0123456789abcdef";
for (size_t i = 0U; i < WEB_SESSION_STORE_SECRET_BYTES; ++i) {
text[2U * i] = hex[bytes[i] >> 4U];
text[2U * i + 1U] = hex[bytes[i] & 15U];
}
text[WEB_SESSION_STORE_TOKEN_LENGTH] = '\0';
}
static esp_err_t digest(const void *input, size_t length, uint8_t *output)
{
return mbedtls_sha256(input, length, output, 0) == 0 ? ESP_OK : ESP_FAIL;
}
static esp_err_t origin_digest(const char *origin, size_t length, uint8_t *output)
{
if (origin == NULL || length <= 8U ||
length > WEB_SESSION_STORE_ORIGIN_MAX_LENGTH ||
memcmp(origin, "https://", 8U) != 0 || memchr(origin, '\0', length) != NULL) {
return ESP_ERR_INVALID_ARG;
}
return digest(origin, length, output);
}
static session_entry_t *find_locked(web_session_id_t id)
{
if (id != 0U) {
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
if (s_state.entries[i].id == id) {
return &s_state.entries[i];
}
}
}
return NULL;
}
static void retire_locked(session_entry_t *entry, bool expired)
{
if (entry->id != 0U) {
if (expired) {
++s_state.expired;
} else {
++s_state.invalidated;
}
secure_wipe(entry, sizeof(*entry));
}
}
static void expire_locked(int64_t now)
{
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
session_entry_t *entry = &s_state.entries[i];
if (entry->id != 0U && entry->expires_at_us <= now) {
retire_locked(entry, true);
}
}
}
/* Also cancels issuance already outside the lock, even if no record matched.
* Exhaustion is fail-closed rather than allowing an epoch/identity ABA. */
static void advance_epoch_locked(void)
{
if (s_state.epoch != UINT64_MAX) {
++s_state.epoch;
} else {
s_state.ready = false;
}
}
static void export_view(const session_entry_t *entry, web_session_view_t *view)
{
view->id = entry->id;
view->expires_at_us = entry->expires_at_us;
view->principal = entry->principal;
encode_hex(entry->csrf, view->csrf);
}
esp_err_t web_session_store_init(void)
{
taskENTER_CRITICAL(&s_lock);
if (s_state.ready) {
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
if (s_state.initializing || s_state.epoch == UINT64_MAX) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
s_state.initializing = true;
uint64_t epoch = s_state.epoch;
taskEXIT_CRITICAL(&s_lock);
uint8_t probe[WEB_SESSION_STORE_SECRET_BYTES] = {0};
esp_err_t error = secure_random_fill(probe, sizeof(probe));
secure_wipe(probe, sizeof(probe));
taskENTER_CRITICAL(&s_lock);
if (epoch != s_state.epoch) {
error = ESP_ERR_INVALID_STATE;
}
s_state.initializing = false;
s_state.ready = error == ESP_OK;
if (error != ESP_OK) {
++s_state.init_failures;
}
taskEXIT_CRITICAL(&s_lock);
return error;
}
void web_session_store_stop(void)
{
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
s_state.ready = false;
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
retire_locked(&s_state.entries[i], false);
}
taskEXIT_CRITICAL(&s_lock);
}
/* Never enter the database while holding our lock. On return, re-find the
* non-reused ID and deadline: logout/stop/slot reuse may have raced the call. */
static esp_err_t resolve(web_session_id_t id, web_session_view_t *view,
const user_principal_t *expected)
{
session_entry_t candidate = {0};
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
session_entry_t *entry = find_locked(id);
esp_err_t error = s_state.ready ? ESP_ERR_NOT_FOUND : ESP_ERR_INVALID_STATE;
if (s_state.ready && entry != NULL) {
candidate = *entry;
error = ESP_OK;
}
taskEXIT_CRITICAL(&s_lock);
if (error == ESP_OK && expected != NULL &&
(candidate.principal.user_id != expected->user_id ||
candidate.principal.auth_generation != expected->auth_generation ||
candidate.principal.role != expected->role ||
candidate.principal.method != expected->method ||
candidate.principal.username_length != expected->username_length ||
memcmp(candidate.principal.username, expected->username,
candidate.principal.username_length) != 0)) {
error = ESP_ERR_NOT_FOUND;
}
if (error == ESP_OK) {
bool current = false;
error = user_database_principal_is_current(&candidate.principal, &current);
now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
entry = find_locked(id);
if (!s_state.ready || entry == NULL) {
error = ESP_ERR_NOT_FOUND;
} else if (error != ESP_OK || !current) {
retire_locked(entry, false);
if (error == ESP_OK) {
error = ESP_ERR_NOT_FOUND;
}
}
taskEXIT_CRITICAL(&s_lock);
}
if (error == ESP_OK && view != NULL) {
export_view(&candidate, view);
}
if (error != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
++s_state.lookup_rejections;
taskEXIT_CRITICAL(&s_lock);
}
secure_wipe(&candidate, sizeof(candidate));
return error;
}
void web_session_store_prune(void)
{
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
taskENTER_CRITICAL(&s_lock);
web_session_id_t id = s_state.entries[i].id;
taskEXIT_CRITICAL(&s_lock);
if (id != 0U) {
(void)resolve(id, NULL, NULL);
}
}
}
esp_err_t web_session_store_issue(
const user_principal_t *principal, const char *origin, size_t origin_length,
char token[WEB_SESSION_STORE_TOKEN_LENGTH + 1U], web_session_view_t *view)
{
if (token != NULL) {
secure_wipe(token, WEB_SESSION_STORE_TOKEN_LENGTH + 1U);
}
if (view != NULL) {
secure_wipe(view, sizeof(*view));
}
if (principal == NULL || token == NULL || view == NULL ||
principal->user_id == 0U || principal->auth_generation == 0U ||
principal->method != USER_AUTH_METHOD_PASSWORD ||
(principal->role != USER_ROLE_USER && principal->role != USER_ROLE_ADMIN) ||
principal->username_length == 0U ||
principal->username_length > USER_DATABASE_USERNAME_CAPACITY ||
principal->username[principal->username_length] != '\0') {
return ESP_ERR_INVALID_ARG;
}
taskENTER_CRITICAL(&s_lock);
bool ready = s_state.ready;
uint64_t epoch = s_state.epoch;
taskEXIT_CRITICAL(&s_lock);
if (!ready) {
return ESP_ERR_INVALID_STATE;
}
web_session_store_prune();
session_entry_t candidate = {0};
uint8_t random[2U * WEB_SESSION_STORE_SECRET_BYTES] = {0};
esp_err_t error = origin_digest(origin, origin_length, candidate.origin_digest);
if (error == ESP_OK) {
error = secure_random_fill(random, sizeof(random));
}
if (error == ESP_OK) {
encode_hex(random, token);
memcpy(candidate.csrf, random + WEB_SESSION_STORE_SECRET_BYTES,
sizeof(candidate.csrf));
error = digest(token, WEB_SESSION_STORE_TOKEN_LENGTH, candidate.token_digest);
}
bool current = false;
if (error == ESP_OK) {
candidate.principal = *principal;
error = user_database_principal_is_current(&candidate.principal, &current);
if (error == ESP_OK && !current) {
error = ESP_ERR_NOT_FOUND;
}
}
if (error == ESP_OK) {
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
session_entry_t *available = NULL;
bool duplicate = false;
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
session_entry_t *entry = &s_state.entries[i];
if (entry->id == 0U) {
if (available == NULL) {
available = entry;
}
} else if (equal_bytes(entry->token_digest, candidate.token_digest,
sizeof(entry->token_digest))) {
duplicate = true;
}
}
if (!s_state.ready || epoch != s_state.epoch ||
s_state.next_id == UINT64_MAX || now < 0 ||
now > INT64_MAX - WEB_SESSION_STORE_LIFETIME_US) {
error = ESP_ERR_INVALID_STATE;
} else if (duplicate) {
error = ESP_FAIL;
} else if (available == NULL) {
++s_state.capacity_rejections;
error = ESP_ERR_NO_MEM;
} else {
candidate.id = ++s_state.next_id;
candidate.expires_at_us = now + WEB_SESSION_STORE_LIFETIME_US;
*available = candidate;
++s_state.issued;
}
taskEXIT_CRITICAL(&s_lock);
}
if (error == ESP_OK) {
export_view(&candidate, view);
} else {
secure_wipe(token, WEB_SESSION_STORE_TOKEN_LENGTH + 1U);
}
secure_wipe(random, sizeof(random));
secure_wipe(&candidate, sizeof(candidate));
return error;
}
esp_err_t web_session_store_lookup(
const char *token, size_t token_length, const char *origin,
size_t origin_length, web_session_view_t *view)
{
if (view == NULL) {
return ESP_ERR_INVALID_ARG;
}
secure_wipe(view, sizeof(*view));
if (token == NULL || token_length != WEB_SESSION_STORE_TOKEN_LENGTH) {
return ESP_ERR_INVALID_ARG;
}
for (size_t i = 0U; i < token_length; ++i) {
if (!((token[i] >= '0' && token[i] <= '9') ||
(token[i] >= 'a' && token[i] <= 'f'))) {
return ESP_ERR_INVALID_ARG;
}
}
uint8_t token_hash[WEB_SESSION_STORE_SECRET_BYTES] = {0};
uint8_t origin_hash[WEB_SESSION_STORE_SECRET_BYTES] = {0};
esp_err_t error = origin_digest(origin, origin_length, origin_hash);
if (error == ESP_OK) {
error = digest(token, token_length, token_hash);
}
if (error == ESP_OK) {
web_session_id_t id = 0U;
taskENTER_CRITICAL(&s_lock);
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
const session_entry_t *entry = &s_state.entries[i];
bool matches = equal_bytes(entry->token_digest, token_hash, sizeof(token_hash));
matches &= equal_bytes(entry->origin_digest, origin_hash, sizeof(origin_hash));
if (entry->id != 0U && matches) {
id = entry->id;
}
}
taskEXIT_CRITICAL(&s_lock);
error = resolve(id, view, NULL);
}
secure_wipe(token_hash, sizeof(token_hash));
secure_wipe(origin_hash, sizeof(origin_hash));
return error;
}
esp_err_t web_session_store_is_current(web_session_id_t id, bool *current)
{
if (current == NULL) {
return ESP_ERR_INVALID_ARG;
}
*current = false;
esp_err_t error = resolve(id, NULL, NULL);
if (error == ESP_OK) {
*current = true;
}
return error;
}
esp_err_t web_session_store_check_principal(web_session_id_t id,
const user_principal_t *principal,
bool *current)
{
if (current == NULL) {
return ESP_ERR_INVALID_ARG;
}
*current = false;
if (principal == NULL) {
return ESP_ERR_INVALID_ARG;
}
esp_err_t error = resolve(id, NULL, principal);
*current = error == ESP_OK;
return error;
}
void web_session_store_invalidate_username(const uint8_t *username, size_t length)
{
if ((username == NULL && length != 0U) ||
(username != NULL && !user_database_username_valid(username, length))) {
return;
}
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
session_entry_t *entry = &s_state.entries[i];
if (username == NULL ||
(entry->principal.username_length == length &&
memcmp(entry->principal.username, username, length) == 0)) {
retire_locked(entry, false);
}
}
taskEXIT_CRITICAL(&s_lock);
}
void web_session_store_invalidate(web_session_id_t id)
{
if (id == 0U) {
return;
}
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
session_entry_t *entry = find_locked(id);
if (entry != NULL) {
retire_locked(entry, false);
}
taskEXIT_CRITICAL(&s_lock);
}
void web_session_store_invalidate_user(uint32_t user_id)
{
if (user_id == 0U) {
return;
}
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
if (s_state.entries[i].principal.user_id == user_id) {
retire_locked(&s_state.entries[i], false);
}
}
taskEXIT_CRITICAL(&s_lock);
}
esp_err_t web_session_store_get_snapshot(web_session_store_snapshot_t *snapshot)
{
if (snapshot == NULL) {
return ESP_ERR_INVALID_ARG;
}
memset(snapshot, 0, sizeof(*snapshot));
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
snapshot->initialized = s_state.ready;
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
snapshot->active += s_state.entries[i].id != 0U;
}
snapshot->issued = s_state.issued;
snapshot->capacity_rejections = s_state.capacity_rejections;
snapshot->expired = s_state.expired;
snapshot->invalidated = s_state.invalidated;
snapshot->lookup_rejections = s_state.lookup_rejections;
snapshot->init_failures = s_state.init_failures;
snapshot->storage_bytes = sizeof(s_state) + sizeof(s_lock);
snapshot->slot_bytes = sizeof(session_entry_t);
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
+76
View File
@@ -0,0 +1,76 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Internal session primitives; no HTTP authorization is enabled by this module. */
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#include "user_database.h"
#define WEB_SESSION_STORE_CAPACITY 4U
#define WEB_SESSION_STORE_SECRET_BYTES 32U
#define WEB_SESSION_STORE_TOKEN_LENGTH 64U
#define WEB_SESSION_STORE_ORIGIN_MAX_LENGTH 128U
#define WEB_SESSION_STORE_LIFETIME_US 3600000000LL
typedef uint64_t web_session_id_t;
/* Sensitive request-local result, NOT a routine snapshot. Wipe after use. */
typedef struct {
web_session_id_t id;
int64_t expires_at_us;
user_principal_t principal;
char csrf[WEB_SESSION_STORE_TOKEN_LENGTH + 1U];
} web_session_view_t;
typedef struct {
bool initialized;
uint32_t active;
uint32_t issued;
uint32_t capacity_rejections;
uint32_t expired;
uint32_t invalidated;
uint32_t lookup_rejections;
uint32_t init_failures;
size_t storage_bytes;
size_t slot_bytes;
} web_session_store_snapshot_t;
/* Idempotent; probes the already-seeded shared RNG, never seeds it here.
* Stop cancels in-flight initialization/issuance and wipes all records. IDs and
* invalidation epochs never reset within a boot, even across stop/init. */
esp_err_t web_session_store_init(void);
void web_session_store_stop(void);
/* Trusted callers only. principal must be a current password-authenticated
* principal. origin is the canonical, already HTTP-policy-validated HTTPS
* origin, not an unchecked Host header; this module only binds its digest.
* No live eviction. ESP_ERR_NO_MEM means fixed session capacity exhausted.
* Raw token is returned only by issue; both outputs must be wiped by caller.
* Output buffers must not alias inputs or each other. */
esp_err_t web_session_store_issue(
const user_principal_t *principal, const char *origin, size_t origin_length,
char token[WEB_SESSION_STORE_TOKEN_LENGTH + 1U], web_session_view_t *view);
esp_err_t web_session_store_lookup(
const char *token, size_t token_length, const char *origin,
size_t origin_length, web_session_view_t *view);
/* Trusted transport identity check, not a replacement for HTTP cookie/origin
* authorization. Every successful lookup/check revalidates the principal.
* No API result is a lease: recheck at later sensitive boundaries. */
esp_err_t web_session_store_is_current(web_session_id_t id, bool *current);
/* Also verifies that the transport's copied principal belongs to this ID. */
esp_err_t web_session_store_check_principal(web_session_id_t id,
const user_principal_t *principal,
bool *current);
void web_session_store_invalidate(web_session_id_t id);
void web_session_store_invalidate_user(uint32_t user_id);
/* Command notifications use names, including after account deletion. NULL/0
* invalidates all records without disabling the store. */
void web_session_store_invalidate_username(const uint8_t *username, size_t length);
void web_session_store_prune(void);
/* Counts only: never token/digest/CSRF/principal material. Expired records are
* reclaimed here; stale principals are reclaimed by prune or lookup/check. */
esp_err_t web_session_store_get_snapshot(web_session_store_snapshot_t *snapshot);
+271
View File
@@ -0,0 +1,271 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_ssh_settings.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "admin_ssh_console.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "secure_random.h"
#include "ssh_transport.h"
#include "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"};
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, 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;
/* 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", "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 < 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 < 4; ++key)
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
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 < 4; ++action)
if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break;
if (action == 4) return false;
operation->action = action;
++pos;
} else {
uint32_t number = 0;
start = pos;
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
unsigned digit = (unsigned)(body[pos++] - '0');
if (number > (UINT32_MAX - digit) / 10U) return false;
number = number * 10U + digit;
}
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
if (key == 1) operation->generation = 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 &&
(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));
}
void web_ssh_settings_execute(uint32_t id)
{
ssh_operation_t operation;
taskENTER_CRITICAL(&s_lock);
operation = s_operation;
taskEXIT_CRITICAL(&s_lock);
if (!id || operation.id != id || operation.state != PENDING) {
secure_wipe(&operation, sizeof(operation));
return;
}
bool current = false;
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, &current);
unsigned state = CANCELLED;
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
esp_timer_get_time() < operation.deadline) {
bool committed = false;
error = operation.action == 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);
if (s_operation.id == id && s_operation.state == PENDING) {
s_operation.state = state;
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
}
taskEXIT_CRITICAL(&s_lock);
secure_wipe(&operation, sizeof(operation));
}
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
esp_err_t web_ssh_operation_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
bool mutation = request->method == HTTP_POST;
esp_err_t error = mutation
? web_cookie_auth_require_json(request, 256, &view, &allowed)
: web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
ssh_operation_t operation = {0};
if (mutation) {
char type[40] = {0}, body[256];
size_t received = 0;
bool valid = request->content_len && request->content_len <= sizeof(body) &&
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
int count = httpd_req_recv(request, body + received, request->content_len - received);
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
else received += (size_t)count;
}
valid = valid && received == request->content_len && parse(body, received, &operation);
secure_wipe(body, sizeof(body));
if (!valid) {
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_ssh_request\"}");
goto done;
}
operation.session = view.id;
operation.principal = view.principal;
operation.deadline = esp_timer_get_time() + 30000000LL;
operation.state = PENDING;
taskENTER_CRITICAL(&s_lock);
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
if (!busy) {
operation.id = ++s_next_id;
s_operation = operation;
}
taskEXIT_CRITICAL(&s_lock);
if (busy || admin_ssh_console_submit_ssh_settings(operation.id) != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
taskEXIT_CRITICAL(&s_lock);
error = httpd_resp_set_hdr(request, "Retry-After", "1");
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
secure_wipe(&operation, sizeof(operation));
goto done;
}
} else {
taskENTER_CRITICAL(&s_lock);
if (s_operation.session == view.id) {
operation.id = s_operation.id;
operation.state = s_operation.state;
operation.action = s_operation.action;
}
taskEXIT_CRITICAL(&s_lock);
}
char response[96];
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
respond(request, mutation ? "202 Accepted" : "200 OK", response);
secure_wipe(&operation, sizeof(operation));
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
esp_err_t web_ssh_settings_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
if (view.principal.role != USER_ROLE_ADMIN) {
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto done;
}
ssh_transport_management_snapshot_t snapshot;
error = ssh_transport_get_management_snapshot(&snapshot);
if (error != ESP_OK) {
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,"
"\"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;
for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
const ssh_transport_session_snapshot_t *session = &snapshot.sessions[i];
if (!session->active) continue;
char name[USER_DATABASE_USERNAME_CAPACITY * 2 + 1];
static const char hex[] = "0123456789abcdef";
size_t n = 0;
for (; session->principal_valid && n < USER_DATABASE_USERNAME_CAPACITY && session->username[n]; ++n) {
unsigned byte = (unsigned char)session->username[n];
name[n * 2] = hex[byte >> 4]; name[n * 2 + 1] = hex[byte & 15];
}
name[n * 2] = 0;
written = snprintf(response + used, sizeof(response) - used,
"%s{\"id\":%" PRIu32 ",\"state\":%u,\"route\":%u,\"name_hex\":\"%s\",\"closing\":%s}",
count++ ? "," : "", session->session_id, (unsigned)session->state,
(unsigned)session->route, name, session->close_requested ? "true" : "false");
if (written < 0 || (size_t)written >= sizeof(response) - used) { error = ESP_FAIL; goto done; }
used += (size_t)written;
}
written = snprintf(response + used, sizeof(response) - used, "]}");
error = written < 0 || (size_t)written >= sizeof(response) - used ? ESP_FAIL :
respond(request, "200 OK", response);
done:
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
+9
View File
@@ -0,0 +1,9 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <stdint.h>
#include "esp_http_server.h"
/* Optional admin-only SSH status and login-isolated ordinary controls. */
esp_err_t web_ssh_settings_handler(httpd_req_t *request);
esp_err_t web_ssh_operation_handler(httpd_req_t *request);
void web_ssh_settings_execute(uint32_t id);
+1724 -84
View File
File diff suppressed because it is too large Load Diff
+133 -19
View File
@@ -18,6 +18,7 @@
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "mdns_service.h"
#include "nvs.h"
#define WIFI_MANAGER_QUEUE_LENGTH 16U
#define WIFI_MANAGER_TASK_STACK_SIZE 6144U
@@ -1414,36 +1415,147 @@ esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config)
return ESP_OK;
}
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
/* Caller holds s_mutex; publication and owner admission are one transaction. */
static esp_err_t apply_config_locked(const wifi_app_config_t *config)
{
esp_err_t error = wifi_config_validate(config);
if (error != ESP_OK) {
return error;
}
if (s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
lock_shared();
if (error != ESP_OK) return error;
if (s_shared.snapshot.config_generation == UINT32_MAX) return ESP_ERR_INVALID_STATE;
bool restart_radio = config_requires_radio_restart(&s_shared.config, config);
if (restart_radio) {
manager_message_t message = {.type = MESSAGE_COMMAND_APPLY};
if (!enqueue_message(&message)) {
unlock_shared();
return ESP_ERR_TIMEOUT;
}
if (!enqueue_message(&message)) return ESP_ERR_TIMEOUT;
}
s_shared.config = *config;
++s_shared.snapshot.config_generation;
if (s_shared.snapshot.config_generation == 0U) {
s_shared.snapshot.config_generation = 1U;
}
s_shared.snapshot.ap_policy = config->ap_policy;
++s_shared.snapshot.counters.applies;
return ESP_OK;
}
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
{
if (!config) return ESP_ERR_INVALID_ARG;
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_shared();
esp_err_t error = apply_config_locked(config);
unlock_shared();
return error;
}
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *settings)
{
if (!settings) return ESP_ERR_INVALID_ARG;
memset(settings, 0, sizeof(*settings));
if (!s_mutex) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
settings->runtime = s_shared.snapshot;
portENTER_CRITICAL(&s_drop_mux);
settings->runtime.counters.queue_drops = s_queue_drops;
portEXIT_CRITICAL(&s_drop_mux);
settings->enabled_at_boot = s_shared.config.enabled_at_boot;
settings->ap_policy = s_shared.config.ap_policy;
settings->ap_channel = s_shared.config.ap_channel;
settings->ap_ssid_len = s_shared.config.ap_ssid_len;
memcpy(settings->ap_ssid, s_shared.config.ap_ssid, sizeof(settings->ap_ssid));
settings->ap_password_configured = s_shared.config.ap_psk_len != 0;
for (unsigned i = 0; i < WIFI_CONFIG_STA_PROFILE_COUNT; ++i) {
const wifi_config_sta_profile_t *source = &s_shared.config.profiles[i];
wifi_manager_profile_settings_t *target = &settings->profiles[i];
target->enabled = source->enabled;
target->priority = source->priority;
target->security = source->security;
target->ssid_len = source->ssid_len;
memcpy(target->ssid, source->ssid, sizeof(target->ssid));
target->password_configured = source->psk_len != 0;
}
unlock_shared();
return ESP_OK;
}
static bool generation_matches(uint32_t generation)
{
return generation && generation == s_shared.snapshot.config_generation;
}
esp_err_t wifi_manager_patch_current(uint32_t generation, const wifi_manager_patch_t *patch)
{
if (!patch || patch->profile < -1 || patch->profile >= (int)WIFI_CONFIG_STA_PROFILE_COUNT ||
!patch->fields || patch->ssid_len > WIFI_CONFIG_SSID_MAX_LEN ||
patch->password_len > WIFI_CONFIG_PSK_MAX_LEN) return ESP_ERR_INVALID_ARG;
uint32_t allowed = WIFI_PATCH_SSID | WIFI_PATCH_PASSWORD |
(patch->profile < 0 ? WIFI_PATCH_BOOT | WIFI_PATCH_POLICY | WIFI_PATCH_CHANNEL :
WIFI_PATCH_ENABLED | WIFI_PATCH_PRIORITY | WIFI_PATCH_SECURITY);
if (patch->fields & ~allowed) return ESP_ERR_INVALID_ARG;
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_shared();
if (!generation_matches(generation)) { unlock_shared(); return ESP_ERR_NOT_FOUND; }
wifi_app_config_t candidate = s_shared.config;
uint8_t *ssid, *ssid_len, *password, *password_len;
if (patch->profile < 0) {
if (patch->fields & WIFI_PATCH_BOOT) candidate.enabled_at_boot = patch->enabled_at_boot;
if (patch->fields & WIFI_PATCH_POLICY) candidate.ap_policy = patch->ap_policy;
if (patch->fields & WIFI_PATCH_CHANNEL) candidate.ap_channel = patch->ap_channel;
ssid = candidate.ap_ssid; ssid_len = &candidate.ap_ssid_len;
password = candidate.ap_psk; password_len = &candidate.ap_psk_len;
} else {
wifi_config_sta_profile_t *profile = &candidate.profiles[(unsigned)patch->profile];
if (patch->fields & WIFI_PATCH_ENABLED) profile->enabled = patch->enabled;
if (patch->fields & WIFI_PATCH_PRIORITY) profile->priority = patch->priority;
if (patch->fields & WIFI_PATCH_SECURITY) profile->security = patch->security;
ssid = profile->ssid; ssid_len = &profile->ssid_len;
password = profile->psk; password_len = &profile->psk_len;
}
if (patch->fields & WIFI_PATCH_SSID) {
memset(ssid, 0, WIFI_CONFIG_SSID_MAX_LEN);
memcpy(ssid, patch->ssid, patch->ssid_len); *ssid_len = patch->ssid_len;
}
if (patch->fields & WIFI_PATCH_PASSWORD) {
wifi_config_secure_wipe(password, WIFI_CONFIG_PSK_MAX_LEN);
memcpy(password, patch->password, patch->password_len); *password_len = patch->password_len;
}
esp_err_t error = apply_config_locked(&candidate);
wifi_config_secure_wipe(&candidate, sizeof(candidate));
unlock_shared();
return error;
}
esp_err_t wifi_manager_save_current(uint32_t generation)
{
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_shared();
/* Hold the config lock through persistence, not HTTPD. Local controls cannot
* change the selected generation while its bytes are being committed. */
esp_err_t error = generation_matches(generation) ? wifi_config_save(&s_shared.config) : ESP_ERR_NOT_FOUND;
unlock_shared();
return error;
}
esp_err_t wifi_manager_load_current(uint32_t generation)
{
if (!s_mutex) return ESP_ERR_INVALID_STATE;
lock_shared();
if (!generation_matches(generation)) { unlock_shared(); return ESP_ERR_NOT_FOUND; }
/* wifi_config_load intentionally generates fallback credentials. Browser
* load must instead read the same canonical blob without that fallback. */
wifi_app_config_t candidate = {0};
esp_err_t error = wifi_config_storage_init();
nvs_handle_t handle;
if (error == ESP_OK) {
error = nvs_open(WIFI_CONFIG_NVS_NAMESPACE, NVS_READONLY, &handle);
if (error == ESP_OK) {
size_t size = sizeof(candidate);
error = nvs_get_blob(handle, WIFI_CONFIG_NVS_BLOB_KEY, &candidate, &size);
nvs_close(handle);
if (error == ESP_OK && size != sizeof(candidate)) error = ESP_ERR_INVALID_SIZE;
}
}
if (error == ESP_OK) error = apply_config_locked(&candidate);
wifi_config_secure_wipe(&candidate, sizeof(candidate));
unlock_shared();
return error;
}
static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
int enabled_at_boot)
{
@@ -1453,6 +1565,11 @@ static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
manager_message_t message = {.type = type};
lock_shared();
if (enabled_at_boot >= 0 && s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot &&
s_shared.snapshot.config_generation == UINT32_MAX) {
unlock_shared();
return ESP_ERR_INVALID_STATE;
}
if (!enqueue_message(&message)) {
unlock_shared();
return ESP_ERR_TIMEOUT;
@@ -1462,9 +1579,6 @@ static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot) {
s_shared.config.enabled_at_boot = (uint8_t)enabled_at_boot;
++s_shared.snapshot.config_generation;
if (s_shared.snapshot.config_generation == 0U) {
s_shared.snapshot.config_generation = 1U;
}
}
unlock_shared();
return ESP_OK;
+46
View File
@@ -89,6 +89,52 @@ esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config);
*/
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config);
/* Secret-free working projection, copied together with runtime under the mutex.
* Zero wait: ESP_ERR_TIMEOUT means no snapshot was obtained. Password presence
* is the only credential metadata, needed to stage/enable disabled profiles. */
typedef struct {
uint8_t enabled, priority;
wifi_config_security_t security;
uint8_t ssid_len, ssid[WIFI_CONFIG_SSID_MAX_LEN];
bool password_configured;
} wifi_manager_profile_settings_t;
typedef struct {
wifi_manager_snapshot_t runtime;
uint8_t enabled_at_boot, ap_channel;
wifi_config_ap_policy_t ap_policy;
uint8_t ap_ssid_len, ap_ssid[WIFI_CONFIG_SSID_MAX_LEN];
bool ap_password_configured;
wifi_manager_profile_settings_t profiles[WIFI_CONFIG_STA_PROFILE_COUNT];
} wifi_manager_settings_t;
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *settings);
enum {
WIFI_PATCH_BOOT = 1U << 0, WIFI_PATCH_POLICY = 1U << 1,
WIFI_PATCH_CHANNEL = 1U << 2, WIFI_PATCH_ENABLED = 1U << 3,
WIFI_PATCH_PRIORITY = 1U << 4, WIFI_PATCH_SECURITY = 1U << 5,
WIFI_PATCH_SSID = 1U << 6, WIFI_PATCH_PASSWORD = 1U << 7,
};
/* profile=-1 selects AP/global fields; 0..3 selects a station profile.
* Absent bits preserve CURRENT bytes, never a stale caller's secret copy.
* PASSWORD with length zero clears only when canonical validation permits it.
* Caller owns and must wipe this transient input after every exit path. */
typedef struct {
uint32_t fields;
int8_t profile;
uint8_t enabled_at_boot, ap_channel, enabled, priority;
wifi_config_ap_policy_t ap_policy;
wifi_config_security_t security;
uint8_t ssid_len, ssid[WIFI_CONFIG_SSID_MAX_LEN];
uint8_t password_len, password[WIFI_CONFIG_PSK_MAX_LEN];
} wifi_manager_patch_t;
/* Dispatcher-only conditional operations. A nonzero expected generation must
* match under the mutation mutex; ESP_ERR_NOT_FOUND denotes stale selection.
* No generation wrap/reuse. Queue failure leaves RAM untouched. */
esp_err_t wifi_manager_patch_current(uint32_t generation, const wifi_manager_patch_t *patch);
esp_err_t wifi_manager_save_current(uint32_t generation);
/* Stored-only load: never generates or installs unknown default credentials. */
esp_err_t wifi_manager_load_current(uint32_t generation);
/* Lifecycle requests are asynchronous and serialized by the manager task. */
esp_err_t wifi_manager_start(void);
esp_err_t wifi_manager_stop(void);
+164
View File
@@ -0,0 +1,164 @@
/* Included in accounts.py's canonical DB/console transaction harness.
* Production SSH parsing, with OpenSSL-backed curve/SHA adapters, not mbedTLS. */
static user_database_account_t key_target(void)
{
user_database_accounts_t accounts;
assert(user_database_get_accounts(&accounts)==ESP_OK);
return accounts.users[1];
}
static size_t ssh_string(uint8_t *out, const void *value, size_t length)
{
out[0]=out[1]=out[2]=0; out[3]=(uint8_t)length;
memcpy(out+4,value,length); return length+4;
}
static void stale_keys(const user_database_account_t *target, const uint8_t *blob, size_t length)
{
stored_database_t before=s_database;
unsigned saved=commits;
uint8_t index=0;
user_database_user_snapshot_t snapshot;
assert(user_database_add_ssh_key_current(target,s_ed25519_type,11,blob,length,&index)==ESP_ERR_NOT_FOUND);
assert(user_database_remove_ssh_key_current(target,0)==ESP_ERR_NOT_FOUND);
assert(user_database_clear_ssh_keys_current(target)==ESP_ERR_NOT_FOUND);
memset(&snapshot,0xff,sizeof(snapshot));
assert(user_database_get_account_keys(target,&snapshot)==ESP_ERR_NOT_FOUND);
assert(all_zero(&snapshot,sizeof(snapshot)) && commits==saved); unchanged(&before);
}
static void typed_key_tests(void)
{
reset();
uint8_t ed[128]={0}, p256[128]={0}, point[65], public[32]={1};
size_t en=ssh_string(ed,s_ed25519_type,11); en+=ssh_string(ed+en,public,32);
EC_GROUP *group=EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1);
assert(group && EC_POINT_point2oct(group,EC_GROUP_get0_generator(group),POINT_CONVERSION_UNCOMPRESSED,point,sizeof(point),NULL)==65);
EC_GROUP_free(group);
size_t pn=ssh_string(p256,s_ecdsa_type,19);
pn+=ssh_string(p256+pn,s_ecdsa_curve,8); pn+=ssh_string(p256+pn,point,65);
assert(user_database_key_valid(s_ed25519_type,11,ed,en));
assert(user_database_key_valid(s_ecdsa_type,19,p256,pn));
for (size_t n=0;n<en;++n) assert(!user_database_key_valid(s_ed25519_type,11,ed,n));
for (size_t n=0;n<pn;++n) assert(!user_database_key_valid(s_ecdsa_type,19,p256,n));
assert(!user_database_key_valid(s_ed25519_type,11,ed,en+1));
assert(!user_database_key_valid(s_ecdsa_type,19,p256,pn+1));
assert(!user_database_key_valid(s_ed25519_type,11,p256,pn));
assert(!user_database_key_valid((const uint8_t *)"ssh-rsa",7,ed,en));
uint8_t bad[129]; memcpy(bad,p256,pn); bad[pn-65]=2;
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
memset(bad+pn-64,0,64); bad[pn-65]=4;
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
memcpy(bad,p256,pn); bad[27]='x';
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
memset(bad,0xff,sizeof(bad));
assert(!user_database_key_valid(s_ed25519_type,11,bad,sizeof(bad)));
user_database_account_t target=key_target();
user_database_user_snapshot_t snapshot;
snapshot_busy=true; memset(&snapshot,0xff,sizeof(snapshot));
assert(user_database_get_account_keys(&target,&snapshot)==ESP_ERR_TIMEOUT && last_wait==0);
assert(all_zero(&snapshot,sizeof(snapshot))); snapshot_busy=false;
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && last_wait==0 && !snapshot.public_key_count);
uint8_t index=255;
stored_database_t invalid_before=s_database;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,bad,sizeof(bad),&index)==ESP_ERR_INVALID_ARG);
unchanged(&invalid_before);
for (fail_stage=1;fail_stage<=3;++fail_stage) {
stored_database_t before=s_database;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_FAIL);
unchanged(&before);
}
fail_stage=0;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_OK && index==0);
stale_keys(&target,ed,en); target=key_target();
user_principal_t authenticated; bool authorized=false;
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ed25519_type,11,ed,en,&authenticated,&authorized)==ESP_OK && authorized);
assert(authenticated.method==USER_AUTH_METHOD_SSH_PUBLIC_KEY && authenticated.auth_generation==target.auth_generation);
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && snapshot.public_key_count==1);
uint8_t digest[32]; assert(SHA256(ed,en,digest));
assert(snapshot.public_keys[0].active && !strcmp(snapshot.public_keys[0].key_type,"ssh-ed25519"));
assert(!memcmp(snapshot.public_keys[0].sha256_fingerprint,digest,32));
unsigned saved=commits;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==USER_DATABASE_ERR_DUPLICATE_SSH_KEY && commits==saved);
assert(user_database_add_ssh_key_current(&target,s_ecdsa_type,19,p256,pn,&index)==ESP_OK && index==1);
target=key_target();
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && authorized);
assert(user_database_authorize_ssh_public_key((const uint8_t *)"observer",8,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
ed[en-1]=2;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_OK && index==2);
target=key_target(); saved=commits;
/* Reload the persisted v1 record with all three keys and verifiers intact. */
stored_database_t stored=s_database;
storage_test=true; unload_database();
user_database_load_result_t loaded;
assert(user_database_init(&loaded)==ESP_OK && loaded==USER_DATABASE_LOAD_STORED);
assert(!memcmp(&stored,&s_database,sizeof(stored)) && commits==saved);
storage_test=false;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==USER_DATABASE_ERR_DUPLICATE_SSH_KEY);
ed[en-1]=3;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_ERR_NO_MEM && commits==saved);
for (fail_stage=1;fail_stage<=3;++fail_stage) {
stored_database_t before=s_database;
assert(user_database_remove_ssh_key_current(&target,1)==ESP_FAIL); unchanged(&before);
assert(user_database_clear_ssh_keys_current(&target)==ESP_FAIL); unchanged(&before);
}
fail_stage=0;
assert(user_database_remove_ssh_key_current(&target,1)==ESP_OK);
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
stale_keys(&target,ed,en); target=key_target();
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && snapshot.public_key_count==2);
assert(!snapshot.public_keys[1].active && snapshot.public_keys[2].index==2);
/* Sparse v1 reload must preserve the entire record, including IDs,
* generations, password verifiers, key blobs/types and fingerprints. */
stored=s_database;
user_database_user_snapshot_t sparse_snapshot=snapshot;
unsigned sparse_writes=writes, sparse_commits=commits;
assert(persisted_size==sizeof(stored) && !memcmp(persisted,&stored,sizeof(stored)));
storage_test=true; unload_database();
assert(user_database_init(&loaded)==ESP_OK && loaded==USER_DATABASE_LOAD_STORED);
storage_test=false;
assert(!memcmp(&stored,&s_database,sizeof(stored)));
assert(persisted_size==sizeof(stored) && !memcmp(persisted,&stored,sizeof(stored)));
assert(writes==sparse_writes && commits==sparse_commits);
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK);
assert(!memcmp(&sparse_snapshot,&snapshot,sizeof(snapshot)));
for (size_t slot=0;slot<3;slot+=2) {
const stored_key_t *key=&stored.users[1].keys[slot];
assert(key->active && snapshot.public_keys[slot].active);
assert(snapshot.public_keys[slot].index==slot);
assert(user_database_key_valid(key->type,key->type_length,key->blob,key->blob_length));
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,
key->type,key->type_length,key->blob,key->blob_length,
&authenticated,&authorized)==ESP_OK && authorized);
assert(authenticated.user_id==target.user_id && authenticated.auth_generation==target.auth_generation);
assert(authenticated.role==target.role && authenticated.method==USER_AUTH_METHOD_SSH_PUBLIC_KEY);
bool current=false;
assert(user_database_principal_is_current(&authenticated,&current)==ESP_OK && current);
}
assert(!snapshot.public_keys[1].active);
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,
s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
assert(writes==sparse_writes && commits==sparse_commits);
assert(user_database_remove_ssh_key_current(&target,1)==ESP_ERR_NOT_FOUND);
assert(user_database_remove_ssh_key_current(&target,3)==ESP_ERR_INVALID_ARG);
assert(user_database_add_ssh_key_current(&target,s_ecdsa_type,19,p256,pn,&index)==ESP_OK && index==1);
target=key_target(); assert(user_database_clear_ssh_keys_current(&target)==ESP_OK);
stale_keys(&target,ed,en); target=key_target(); saved=commits;
assert(user_database_clear_ssh_keys_current(&target)==ESP_OK && commits==saved);
assert(user_database_delete_current(&target)==ESP_OK);
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,(const uint8_t *)"test-password",13)==ESP_OK);
stale_keys(&target,ed,en);
target=key_target(); assert(user_database_set_role_current(&target,USER_ROLE_ADMIN)==ESP_OK); stale_keys(&target,ed,en);
target=key_target(); assert(user_database_set_password_current(&target,(const uint8_t *)"test-password",13)==ESP_OK); stale_keys(&target,ed,en);
target=key_target(); target.user_id=0; stale_keys(&target,ed,en);
target=key_target(); target.auth_generation=0; stale_keys(&target,ed,en);
target=key_target(); memset(target.username,'x',sizeof(target.username));
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_ERR_INVALID_ARG);
assert(user_database_remove_ssh_key_current(&target,0)==ESP_ERR_INVALID_ARG);
assert(user_database_clear_ssh_keys_current(NULL)==ESP_ERR_INVALID_ARG);
assert(user_database_get_account_keys(NULL,&snapshot)==ESP_ERR_INVALID_ARG && all_zero(&snapshot,sizeof(snapshot)));
/* Ordinary CLI APIs retain the exact transaction path and generation changes. */
target=key_target(); assert(user_database_add_ssh_key((const uint8_t *)"other",5,s_ed25519_type,11,ed,en,&index)==ESP_OK);
stale_keys(&target,ed,en);
assert(user_database_remove_ssh_key((const uint8_t *)"other",5,index)==ESP_OK);
assert(user_database_clear_ssh_keys((const uint8_t *)"other",5)==ESP_OK);
s_initialized=false; memset(&snapshot,0xff,sizeof(snapshot));
assert(user_database_get_account_keys(&target,&snapshot)==ESP_ERR_INVALID_STATE && all_zero(&snapshot,sizeof(snapshot)));
}
+374
View File
@@ -0,0 +1,374 @@
/* Included by accounts.py after extracted production functions. */
static void reset(void)
{
memset(&s_database, 0, sizeof(s_database));
memset(&candidate_storage, 0, sizeof(candidate_storage));
s_candidate=&candidate_storage; s_mutex=(void *)1; s_initialized=true;
s_database.version=USER_DATABASE_SCHEMA_VERSION;
s_database.size=sizeof(s_database); s_database.generation=1;
fail_stage=0; invalidate_during_derivation=false; derivation_invalidations=0;
assert(initialize_user(&s_database.users[0], (const uint8_t *)"admin", 5,
USER_ROLE_ADMIN, (const uint8_t *)"test-password", 13)==ESP_OK);
assert(initialize_user(&s_database.users[1], (const uint8_t *)"other", 5,
USER_ROLE_USER, (const uint8_t *)"test-password", 13)==ESP_OK);
assert(initialize_user(&s_database.users[2], (const uint8_t *)"observer", 8,
USER_ROLE_USER, (const uint8_t *)"test-password", 13)==ESP_OK);
recount(&s_database);
assert(validate_database(&s_database)==ESP_OK);
actor=(user_principal_t){.role=USER_ROLE_ADMIN, .username_length=5,
.username="admin", .user_id=s_database.users[0].user_id,
.auth_generation=s_database.users[0].auth_generation};
writes=commits=prompts=checks=web_revokes=ssh_revokes=0;
revoke_prompt=revoke_check=0; owner_current=true; remote=web=true;
mismatch=cancel_prompt=stale_prompt=false; notify_error=ESP_OK;
memset(revoked_name,0,sizeof(revoked_name));
}
static int run(const char *line)
{
char copy[257]; char *argv[10]={0};
assert(strlen(line)<sizeof(copy)); strcpy(copy,line);
size_t argc=esp_console_split_argv(copy,argv,10);
/* Direct canonical handler, deliberately bypassing dispatcher policy. */
return command_user((int)argc,argv);
}
static void unchanged(const stored_database_t *before)
{
assert(!memcmp(before,&s_database,sizeof(*before)));
assert(!web_revokes && !ssh_revokes);
assert(all_zero(s_candidate,sizeof(*s_candidate)));
assert(!locks);
}
static void typed_account_tests(void)
{
reset(); user_database_accounts_t list;
assert(user_database_get_accounts(&list)==ESP_OK && last_wait==0 && list.count==3);
assert(!strcmp(list.users[1].username,"other"));
user_database_account_t other=list.users[1], admin=list.users[0];
snapshot_busy=true; memset(&list,0xff,sizeof(list));
assert(user_database_get_accounts(&list)==ESP_ERR_TIMEOUT && all_zero(&list,sizeof(list)));
snapshot_busy=false;
assert(user_database_delete_current(&admin)==ESP_ERR_INVALID_STATE);
assert(user_database_set_role_current(&admin,USER_ROLE_USER)==ESP_ERR_INVALID_STATE);
assert(!writes && !commits);
assert(user_database_set_role_current(&other,USER_ROLE_ADMIN)==ESP_OK);
unsigned saved=commits;
assert(user_database_delete_current(&other)==ESP_ERR_NOT_FOUND && commits==saved);
assert(user_database_set_role_current(&other,USER_ROLE_USER)==ESP_ERR_NOT_FOUND);
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
for (fail_stage=1;fail_stage<=3;++fail_stage) {
stored_database_t before=s_database;
assert(user_database_delete_current(&other)==ESP_FAIL); unchanged(&before);
assert(user_database_set_role_current(&other,USER_ROLE_USER)==ESP_FAIL); unchanged(&before);
}
fail_stage=0; assert(user_database_delete_current(&other)==ESP_OK);
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,(const uint8_t *)"test-password",13)==ESP_OK);
assert(user_database_delete_current(&other)==ESP_ERR_NOT_FOUND);
assert(user_database_set_role_current(&other,USER_ROLE_ADMIN)==ESP_ERR_NOT_FOUND);
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
assert(user_database_delete_current(&other)==ESP_OK);
assert(all_zero(s_candidate,sizeof(*s_candidate)) && !locks);
s_initialized=false; memset(&list,0xff,sizeof(list));
assert(user_database_get_accounts(&list)==ESP_ERR_INVALID_STATE && all_zero(&list,sizeof(list)));
assert(user_database_delete_current(NULL)==ESP_ERR_INVALID_ARG);
}
static void typed_password_tests(void)
{
reset(); user_database_accounts_t list;
assert(user_database_get_accounts(&list)==ESP_OK);
user_database_account_t other=list.users[1], admin=list.users[0];
const uint8_t password[]="quote\"slash\\ space";
for (unsigned stage=1;stage<=5;++stage) {
fail_stage=stage; stored_database_t before=s_database;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_FAIL);
unchanged(&before);
}
fail_stage=0; writes=commits=0;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_OK);
assert(writes==1 && commits==1 && s_database.users[1].auth_generation==other.auth_generation+1);
assert(all_zero(s_candidate,sizeof(*s_candidate)));
stored_database_t before=s_database; unsigned rng=random_calls;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
unchanged(&before); assert(writes==1 && commits==1 && random_calls==rng);
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
assert(user_database_delete_current(&other)==ESP_OK);
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,password,sizeof(password)-1)==ESP_OK);
before=s_database; rng=random_calls;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
unchanged(&before); assert(random_calls==rng);
assert(user_database_set_password_current(NULL,password,sizeof(password)-1)==ESP_ERR_INVALID_ARG);
other.user_id=0;
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
memset(other.username,'x',sizeof(other.username));
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_INVALID_ARG);
assert(user_database_set_password_current(&admin,(const uint8_t *)"short",5)==ESP_ERR_INVALID_ARG);
/* Own password is allowed even for the last administrator; old principal is stale. */
assert(user_database_set_password_current(&admin,password,sizeof(password)-1)==ESP_OK);
bool current=true; assert(user_database_principal_is_current(&actor,&current)==ESP_OK && !current);
assert(s_database.admin_count==1);
/* With a second admin, canonical self role/delete invariants allow both. */
assert(user_database_set_role((const uint8_t *)"other",5,USER_ROLE_ADMIN)==ESP_OK);
assert(user_database_get_accounts(&list)==ESP_OK); admin=list.users[0];
assert(user_database_set_role_current(&admin,USER_ROLE_USER)==ESP_OK);
assert(user_database_get_accounts(&list)==ESP_OK); admin=list.users[0];
assert(user_database_delete_current(&admin)==ESP_OK);
reset(); before=s_database; rng=random_calls;
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_ADMIN,password,sizeof(password)-1)==ESP_ERR_INVALID_STATE);
unchanged(&before); assert(!writes && !commits && rng==random_calls);
for (unsigned i=3;i<USER_DATABASE_MAX_USERS;++i) {
char name[17]; snprintf(name,sizeof(name),"account%u",i);
assert(user_database_create((const uint8_t *)name,strlen(name),USER_ROLE_USER,password,sizeof(password)-1)==ESP_OK);
}
before=s_database; rng=random_calls; unsigned saved=commits;
assert(user_database_create((const uint8_t *)"extra",5,USER_ROLE_USER,password,sizeof(password)-1)==ESP_ERR_NO_MEM);
unchanged(&before); assert(commits==saved && rng==random_calls);
/* RNG-only helper is independent of initialized storage and leaves all DB state alone. */
s_initialized=false; s_mutex=NULL;
for (unsigned mode=0;mode<2;++mode) {
user_database_generated_password_t generated; memset(&generated,0xa5,sizeof(generated));
fail_stage=mode ? 4 : 0;
assert(user_database_generate_password_value(&generated)==(mode ? ESP_FAIL : ESP_OK));
if (mode) assert(all_zero(&generated,sizeof(generated)));
else {
assert(generated.password_length==24 && strlen((const char *)generated.password)==24);
for (size_t i=0;i<24;++i) assert(strchr((const char *)s_generated_alphabet,generated.password[i]));
}
assert(!memcmp(&before,&s_database,sizeof(before)) && commits==saved && !locks);
secure_wipe(&generated,sizeof(generated));
}
assert(user_database_generate_password_value(NULL)==ESP_ERR_INVALID_ARG);
}
static void unload_database(void)
{
s_initialized=false; s_mutex=NULL; release_candidate();
secure_wipe(&s_database,sizeof(s_database));
}
static void storage_tests(void)
{
reset(); storage_test=true;
/* Both historical v1 states load without any account/verifier/ID changes. */
for (unsigned admins=0;admins<2;++admins) {
reset();
s_database.users[0].role=admins ? USER_ROLE_ADMIN : USER_ROLE_USER;
recount(&s_database);
stored_database_t before=s_database;
memcpy(persisted,&before,sizeof(before)); persisted_size=sizeof(before);
unload_database();
user_database_load_result_t result;
assert(user_database_init(&result)==ESP_OK && result==USER_DATABASE_LOAD_STORED);
assert(!memcmp(&before,&s_database,sizeof(before)) && !writes && !commits);
assert(user_database_recover_empty()==ESP_ERR_INVALID_STATE);
if (!admins) {
assert(user_database_delete((const uint8_t *)"admin",5)==ESP_OK);
assert(s_database.admin_count==0 && s_database.user_count==2);
}
}
for (unsigned kind=0;kind<4;++kind) {
reset(); stored_database_t bad=s_database;
if (kind==0) ++bad.version;
if (kind==1) bad.v1_admin_marker=0;
if (kind==2) bad.users[0].user_id=0;
memcpy(persisted,&bad,sizeof(bad)); persisted_size=sizeof(bad)-(kind==3);
size_t size=persisted_size;
unload_database(); user_database_load_result_t result;
assert(user_database_init(&result)!=ESP_OK && !s_initialized && !s_mutex);
assert(!writes && !commits && persisted_size==size && !memcmp(persisted,&bad,size));
web=false; remote=true;
assert(run("user recover --force")!=0 && !writes);
remote=false;
assert(run("user recover")!=0 && !writes);
assert(run("user recover --force")==0 && s_initialized);
assert(!s_database.user_count && !s_database.admin_count);
assert(validate_database(&s_database)==ESP_OK);
}
reset(); unload_database(); persisted_size=0;
user_database_load_result_t result;
assert(user_database_init(&result)==ESP_OK && result==USER_DATABASE_LOAD_EMPTY);
assert(s_initialized && !s_database.user_count && writes==1 && commits==1);
stored_database_t empty=s_database;
assert(persisted_size==sizeof(empty) && !memcmp(persisted,&empty,sizeof(empty)));
unload_database();
assert(user_database_init(&result)==ESP_OK && result==USER_DATABASE_LOAD_STORED);
assert(!memcmp(&empty,&s_database,sizeof(empty)) && writes==1 && commits==1);
user_database_snapshot_t snapshot;
assert(user_database_get_snapshot(&snapshot)==ESP_OK && snapshot.initialized);
assert(!snapshot.user_count && !snapshot.admin_count);
web=remote=false;
assert(run("user bootstrap")!=0 && run("user bootstrap --generate")!=0);
assert(run("user recover --force")!=0 && !memcmp(&empty,&s_database,sizeof(empty)));
assert(run("user add chief admin")==0 && s_database.admin_count==1);
assert(user_database_delete((const uint8_t *)"chief",5)==ESP_ERR_INVALID_STATE);
assert(user_database_set_role((const uint8_t *)"chief",5,USER_ROLE_USER)==ESP_ERR_INVALID_STATE);
for (unsigned stage=1;stage<=3;++stage) {
reset(); unload_database(); persisted_size=0; fail_stage=stage;
assert(user_database_init(&result)==ESP_FAIL && !s_initialized && !s_mutex);
assert(!persisted_size);
assert(user_database_recover_empty()==ESP_FAIL && !s_initialized && !s_mutex);
}
storage_test=false;
}
int main(void)
{
storage_tests();
typed_account_tests();
typed_password_tests();
const char *supported[]={
"user add fresh user", "user add fresh admin", "user password other",
"user delete other --force", "user role other admin --force",
"\"user\" \"password\" \"other\"", "user role other user --force",
};
for (size_t i=0;i<sizeof(supported)/sizeof(*supported);++i) {
reset(); stored_database_t before=s_database;
assert(run(supported[i])==0);
assert(web_revokes==1 && ssh_revokes==1);
assert(!strcmp(revoked_name,i<2 ? "fresh" : "other"));
assert(!memcmp(&before.users[0],&s_database.users[0],sizeof(stored_user_t)));
assert(!memcmp(&before.users[2],&s_database.users[2],sizeof(stored_user_t)));
assert(admin_ssh_console_dispatch_is_current());
assert(validate_database(&s_database)==ESP_OK);
if (i<2) {
assert(s_database.user_count==before.user_count+1);
int fresh=find_user(&s_database,(const uint8_t *)"fresh",5);
assert(fresh>=0 && s_database.users[fresh].role==(i==0 ? USER_ROLE_USER : USER_ROLE_ADMIN));
} else if (i==3) {
assert(find_user(&s_database,(const uint8_t *)"other",5)<0);
} else if (i!=6) {
assert(s_database.users[1].auth_generation==before.users[1].auth_generation+1);
} else {
assert(!writes && !commits); /* Existing no-op role still revokes target. */
}
assert(all_zero(s_candidate,sizeof(*s_candidate)));
assert(!locks);
}
const char *denied[]={
"user password admin", "\"user\" \"password\" \"admin\"",
"user delete admin --force", "user role admin user --force",
"user role admin admin --force", "user add admin admin",
"user password admin --generate", "user password other --generate",
"user add fresh user --generate", "user key add other",
"user key add other ssh-ed25519 AAAA", "user key delete other 0 --force",
"user key clear other --force", "user bootstrap", "user bootstrap --generate",
"user recover --force", "user password other extra",
"user role other admin --force extra", "user delete other --force extra",
"user add fresh invalid", "user add fresh user extra",
"user delete other", "user role other user",
"user role \"admin\" user --force", "user add \"admin\" user",
};
for (size_t i=0;i<sizeof(denied)/sizeof(*denied);++i) {
reset(); stored_database_t before=s_database; unsigned rng=random_calls;
assert(run(denied[i])!=0); unchanged(&before);
assert(!prompts && !writes && !commits && random_calls==rng);
}
reset();
actor.username_length=0;
assert(run("user password other")!=0 && !prompts && !writes);
reset();
actor.username_length=USER_DATABASE_USERNAME_CAPACITY+1;
assert(run("user password other")!=0 && !prompts && !writes);
reset();
/* Self defense is identity-based, not a hard-coded 'admin' name. */
memcpy(s_database.users[0].username,"chief",5);
memcpy(actor.username,"chief",5);
assert(run("user password \"chief\"")!=0 && !prompts && !writes);
assert(run("user role \"chief\" admin --force")!=0 && !writes);
const char *prompted[]={"user add fresh admin", "user password other"};
for (size_t i=0;i<2;++i) {
for (unsigned mode=0;mode<7;++mode) {
reset(); stored_database_t before=s_database; unsigned rng=random_calls;
if (mode<2) revoke_prompt=mode+1;
if (mode==2) revoke_check=2; /* After both successful prompts. */
if (mode==3) mismatch=true;
if (mode==4) cancel_prompt=true;
if (mode==5) { ++actor.auth_generation; }
if (mode==6) stale_prompt=true;
assert(run(prompted[i])!=0); unchanged(&before);
assert(!writes && !commits && random_calls==rng);
}
}
/* Operation admission is the final post-prompt check before the database
* API, not the later NVS commit. Once admitted, expiry/closure during
* derivation does not cancel the transaction. No browser receipt is proved. */
for (size_t i=0;i<2;++i) {
for (unsigned stage=0;stage<=3;++stage) {
reset(); stored_database_t before=s_database;
invalidate_during_derivation=true; fail_stage=stage;
int result=run(prompted[i]);
assert(derivation_invalidations==1 && !owner_current && checks==2);
assert(prompts==2 && !locks);
if (stage==0) {
assert(result==0 && writes==1 && commits==1);
assert(s_database.generation==before.generation+1);
if (i==0) {
int fresh=find_user(&s_database,(const uint8_t *)"fresh",5);
assert(fresh>=0 && s_database.users[fresh].role==USER_ROLE_ADMIN);
assert(s_database.user_count==before.user_count+1);
assert(!memcmp(&before.users[1],&s_database.users[1],sizeof(stored_user_t)));
} else {
assert(s_database.users[1].auth_generation==before.users[1].auth_generation+1);
assert(memcmp(before.users[1].password_salt,s_database.users[1].password_salt,
sizeof(before.users[1].password_salt))!=0);
}
assert(web_revokes==1 && ssh_revokes==1);
assert(!strcmp(revoked_name,i==0 ? "fresh" : "other"));
assert(!memcmp(&before.users[0],&s_database.users[0],sizeof(stored_user_t)));
assert(!memcmp(&before.users[2],&s_database.users[2],sizeof(stored_user_t)));
assert(validate_database(&s_database)==ESP_OK);
assert(all_zero(s_candidate,sizeof(*s_candidate)));
} else {
assert(result!=0); unchanged(&before);
assert(commits==(stage==3 ? 1U : 0U));
}
/* Loss of liveness cannot authorize a subsequent operation. */
stored_database_t after=s_database;
unsigned prior_writes=writes, prior_commits=commits;
unsigned prior_web=web_revokes, prior_ssh=ssh_revokes;
assert(run("user password observer")!=0);
assert(!memcmp(&after,&s_database,sizeof(after)));
assert(prompts==2 && writes==prior_writes && commits==prior_commits);
assert(web_revokes==prior_web && ssh_revokes==prior_ssh);
}
}
/* Every enabled mutation fails closed when the originating session or copied
* account is stale BEFORE operation admission, including forced mutations. */
for (size_t i=0;i<sizeof(supported)/sizeof(*supported);++i) {
reset(); stored_database_t before=s_database; owner_current=false;
assert(run(supported[i])!=0); unchanged(&before); assert(!prompts && !writes);
reset(); before=s_database; revoke_check=2;
assert(run(supported[i])!=0); unchanged(&before); assert(!writes && !commits);
reset(); before=s_database; actor.role=USER_ROLE_USER;
assert(run(supported[i])!=0); unchanged(&before); assert(!prompts && !writes);
}
for (size_t i=0;i<5;++i) {
for (unsigned stage=1;stage<=3;++stage) {
reset(); stored_database_t before=s_database; fail_stage=stage;
assert(run(supported[i])!=0); unchanged(&before);
assert(commits==(stage==3 ? 1U : 0U));
}
}
for (size_t i=0;i<3;++i) {
for (unsigned stage=4;stage<=5;++stage) {
reset(); stored_database_t before=s_database; fail_stage=stage;
assert(run(supported[i])!=0); unchanged(&before); assert(!writes && !commits);
}
}
reset(); notify_error=ESP_FAIL;
assert(run("user password other")==0);
assert(commits==1 && web_revokes==1 && ssh_revokes==1);
assert(s_database.users[1].auth_generation==2);
/* Real database invariants, independent of browser self-target policy. */
reset(); stored_database_t before=s_database;
assert(user_database_delete((const uint8_t *)"admin",5)!=ESP_OK); unchanged(&before);
assert(user_database_set_role((const uint8_t *)"admin",5,USER_ROLE_USER)!=ESP_OK);
unchanged(&before); assert(!writes && !commits);
/* Trusted UART0 bypasses browser admission, not database invariants. */
reset(); web=false; remote=false;
assert(run("user delete admin --force")!=0); assert(!writes && !web_revokes);
reset(); web=false; remote=false;
assert(run("user role admin user --force")!=0); assert(!writes && !web_revokes);
/* Normal UART0 and SSH prompted nonself commands still use the same handler. */
reset(); web=false; assert(run("user password other")==0);
reset(); web=false; remote=false; owner_current=false;
assert(run("user password other")==0);
return 0;
}
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Canonical account handlers + production DB transactions; deterministic IO/NVS/crypto.
Not a concurrent RTOS, cryptographic, real-NVS or target test. Run directly.
"""
from pathlib import Path
import os
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
def function(source, name):
start = source.index(name + "(")
start = source.rfind("\n", 0, start) + 1
return source[start:source.index("\n}", start) + 2] + "\n"
def strip_includes(text):
return "\n".join(line for line in text.splitlines()
if not line.startswith(("#include", "#pragma once")))
db = (ROOT / "src/user_database.c").read_text()
console = (ROOT / "src/user_console.c").read_text()
admin = (ROOT / "src/admin_ssh_console.c").read_text()
prelude = r'''
#define _POSIX_C_SOURCE 200809L
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
ESP_ERR_NO_MEM, ESP_ERR_NOT_FOUND, ESP_ERR_NOT_ALLOWED,
ESP_ERR_INVALID_RESPONSE, ESP_ERR_INVALID_VERSION, ESP_ERR_TIMEOUT };
#define pdTRUE 1
static bool snapshot_busy;
static int last_wait;
typedef void *SemaphoreHandle_t;
#define portMAX_DELAY 0
#define NVS_READWRITE 1
#define NVS_READONLY 0
#define ESP_ERR_NVS_NOT_FOUND 100
static uint8_t persisted[65536], staged[65536];
static size_t persisted_size, staged_size;
static bool storage_test;
static void *xSemaphoreCreateMutex(void) { return (void *)1; }
static void vSemaphoreDelete(void *m) { (void)m; }
static int nvs_flash_init(void) { return ESP_OK; }
static int nvs_get_blob(int h, const char *key, void *out, size_t *n) {
(void)h; (void)key;
if (!persisted_size) return ESP_ERR_NVS_NOT_FOUND;
if (out) { assert(*n>=persisted_size); memcpy(out,persisted,persisted_size); }
*n=persisted_size; return ESP_OK;
}
typedef int nvs_handle_t;
static unsigned locks, writes, commits, random_calls, prompts, checks, web_revokes, ssh_revokes;
static unsigned fail_stage, revoke_prompt, revoke_check, derivation_invalidations;
static bool invalidate_during_derivation;
static bool owner_current = true, remote = true, web = true, mismatch, cancel_prompt, stale_prompt;
static int notify_error = ESP_OK;
static char revoked_name[17];
static void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
static int xSemaphoreTake(void *m, int t) { (void)m; last_wait=t; if (snapshot_busy) return 0; assert(!locks++); return pdTRUE; }
static void xSemaphoreGive(void *m) { (void)m; assert(locks-- == 1); }
static const char *esp_err_to_name(int e) { (void)e; return "injected error"; }
static int nvs_open(const char *ns, int mode, int *h) {
(void)ns; (void)mode; assert(locks || storage_test);
if (invalidate_during_derivation) {
assert(derivation_invalidations==1 && !owner_current);
}
*h=1; return fail_stage==1 ? ESP_FAIL : ESP_OK;
}
static int nvs_set_blob(int h, const char *key, const void *data, size_t n) {
(void)h; (void)key; ++writes;
if (fail_stage==2) return ESP_FAIL;
assert(n<=sizeof(staged)); memcpy(staged,data,n); staged_size=n; return ESP_OK;
}
static int nvs_commit(int h) {
(void)h; ++commits; if (fail_stage==3) return ESP_FAIL;
memcpy(persisted,staged,staged_size); persisted_size=staged_size; return ESP_OK;
}
static void nvs_close(int h) { (void)h; }
static int secure_random_fill(void *p, size_t n) {
memset(p, ++random_calls, n); return fail_stage==4 ? ESP_FAIL : ESP_OK;
}
static int derive_password(const uint8_t *p, size_t n, const uint8_t *s,
uint32_t iterations, uint8_t *hash) {
(void)p; (void)n; (void)s; (void)iterations; memset(hash, 7, 32);
if (invalidate_during_derivation) {
/* Model originating browser expiry/closure after operation admission.
* This is a deterministic derivation double, not real PBKDF2/HTTPD. */
assert(locks==1 && prompts==2 && checks==2 && owner_current);
assert(!writes && !commits);
owner_current=false;
++derivation_invalidations;
}
return fail_stage==5 ? ESP_FAIL : ESP_OK;
}
#include <openssl/sha.h>
#include <openssl/ec.h>
#include <openssl/obj_mac.h>
typedef EC_GROUP *mbedtls_ecp_group;
typedef struct { EC_POINT *point; } mbedtls_ecp_point;
#define MBEDTLS_ECP_DP_SECP256R1 1
static void mbedtls_ecp_group_init(mbedtls_ecp_group *g) { *g=NULL; }
static void mbedtls_ecp_point_init(mbedtls_ecp_point *p) { p->point=NULL; }
static int mbedtls_ecp_group_load(mbedtls_ecp_group *g, int id) {
assert(id==1); *g=EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1); return *g ? 0 : -1;
}
static int mbedtls_ecp_point_read_binary(mbedtls_ecp_group *g, mbedtls_ecp_point *p, const uint8_t *b, size_t n) {
p->point=EC_POINT_new(*g); return p->point && EC_POINT_oct2point(*g,p->point,b,n,NULL)==1 ? 0 : -1;
}
static int mbedtls_ecp_check_pubkey(mbedtls_ecp_group *g, mbedtls_ecp_point *p) {
return EC_POINT_is_at_infinity(*g,p->point)==0 && EC_POINT_is_on_curve(*g,p->point,NULL)==1 ? 0 : -1;
}
static void mbedtls_ecp_point_free(mbedtls_ecp_point *p) { EC_POINT_free(p->point); }
static void mbedtls_ecp_group_free(mbedtls_ecp_group *g) { EC_GROUP_free(*g); }
static int mbedtls_sha256(const uint8_t *p, size_t n, uint8_t *h, int mode) {
assert(mode==0); return SHA256(p,n,h) ? 0 : -1;
}
'''
header = strip_includes((ROOT / "src/user_database.h").read_text())
state = db[db.index("#define USER_DATABASE_SCHEMA_VERSION"):db.index("static esp_err_t initialize_dummy_verifier(")]
fakes = r'''
static stored_database_t candidate_storage;
static int allocate_candidate(void) { s_candidate=&candidate_storage; return ESP_OK; }
static void release_candidate(void) { secure_wipe(&candidate_storage,sizeof(candidate_storage)); s_candidate=NULL; }
static user_principal_t actor;
static bool admin_ssh_console_dispatch_is_remote(void) { return remote; }
static bool admin_ssh_console_dispatch_is_web(void) { return web; }
static const user_principal_t *admin_ssh_console_dispatch_principal(void) { return remote ? &actor : NULL; }
static bool admin_ssh_console_dispatch_is_current(void) {
bool current=false; ++checks;
if (checks==revoke_check) owner_current=false;
return owner_current && user_database_principal_is_current(&actor, &current)==ESP_OK && current;
}
static int admin_command_gate_take(void) { return ESP_OK; }
static void admin_command_gate_give(void) {}
static int console_input_read_hidden(const char *prompt, uint8_t *out, size_t cap,
size_t min, size_t max, size_t *n) {
(void)prompt; (void)min; (void)max; assert(cap>=13); ++prompts;
memcpy(out, "test-password", 13); *n=13;
if (mismatch && prompts==2) out[0]='X';
/* Simulate invalidation just after the prompt boundary returned success. */
if (prompts==revoke_prompt) owner_current=false;
if (stale_prompt && prompts==2) ++actor.auth_generation;
return cancel_prompt ? ESP_ERR_INVALID_STATE : ESP_OK;
}
static int web_serial_transport_revoke_user(const uint8_t *u, size_t n) {
++web_revokes; assert(n<sizeof(revoked_name)); memcpy(revoked_name,u,n); revoked_name[n]=0;
assert(strcmp(revoked_name,"admin")); return notify_error;
}
static int ssh_transport_revoke_user(const uint8_t *u, size_t n) {
++ssh_revokes; assert(strlen(revoked_name)==n && !memcmp(u,revoked_name,n)); return notify_error;
}
/* Forbidden paths are traps rather than alternative implementations. */
static int show_users(const char *n) { (void)n; return 0; }
static int add_key(const char *n) { (void)n; assert(!"key mutation"); return 1; }
static int add_key_parts(const char *n,const uint8_t *t,size_t tl,const uint8_t *b,size_t bl) {
(void)n; (void)t; (void)tl; (void)b; (void)bl; assert(!"key mutation"); return 1;
}
esp_err_t user_database_create_generated(const uint8_t *u,size_t n,user_role_t r,user_database_generated_password_t *p) {
(void)u; (void)n; (void)r; (void)p; assert(!"generated credential"); return ESP_FAIL;
}
esp_err_t user_database_generate_password(const uint8_t *u,size_t n,user_database_generated_password_t *p) {
(void)u; (void)n; (void)p; assert(!"generated credential"); return ESP_FAIL;
}
size_t esp_console_split_argv(char *, char **, size_t);
'''
db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
"user_database_password_valid", "read_ssh_string", "user_database_key_valid",
"user_role_to_string", "user_role_parse", "set_record_password", "find_user",
"find_free_user", "stored_keys_equal", "validate_database", "recount",
"next_generation", "discard_candidate", "commit_candidate_locked", "initialize_user",
"user_database_principal_is_current", "create_locked", "user_database_create",
"mutate_user_begin", "target_matches_locked", "delete_user", "set_role",
"user_database_delete", "user_database_set_role", "user_database_get_accounts",
"user_database_delete_current", "user_database_set_role_current",
"set_password", "user_database_set_password", "user_database_set_password_current",
"user_database_generate_password_value",
"user_database_get_account_keys", "add_ssh_key", "remove_ssh_key", "clear_ssh_keys",
"user_database_add_ssh_key", "user_database_remove_ssh_key", "user_database_clear_ssh_keys",
"key_target_valid", "user_database_add_ssh_key_current", "user_database_remove_ssh_key_current",
"user_database_clear_ssh_keys_current", "fill_principal", "user_database_authorize_ssh_public_key",
"initialize_dummy_verifier", "user_database_init", "user_database_recover_empty",
"user_database_get_snapshot"]
console_names = ["print_usage", "revoke_user_network_sessions", "read_password",
"show_generated_password", "mutation_currentness", "add_user", "change_password",
"parse_key_index", "recover_database", "command_user_inner", "command_user"]
unit = prelude + header + "\n" + state + fakes
unit += "\n".join(function(db, n) for n in db_names)
unit += function(admin, "admin_ssh_console_web_user_command_allowed")
unit += "\n".join(function(console, n) for n in console_names)
account_tests = (ROOT / "tests/admin_console_boundary/accounts.c").read_text()
key_tests = (ROOT / "tests/admin_console_boundary/account_keys.c").read_text()
account_tests = account_tests.replace('int main(void)', key_tests + '\nint main(void)')
account_tests = account_tests.replace(' typed_account_tests();', ' typed_key_tests();\n typed_account_tests();')
assert ' typed_key_tests();' in account_tests
unit += account_tests
with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
path = Path(directory)
(path / "test.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-variable",
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
"-lcrypto", "-o", str(path / "test")], check=True, timeout=30)
result = subprocess.run([str(path / "test")], check=True, timeout=10, capture_output=True, text=True)
assert "test-password" not in result.stdout
assert "Generated password for" not in result.stdout
print("PASS: empty initialization/recovery, unchanged v1 records, corrupt/unsupported fail-closed loads, first UART0 administrator and removed bootstrap commands")
print("PASS: canonical SSH keys: Ed25519/P256 parser and authorization, malformed/off-curve/truncated inputs, zero-wait fingerprints, stale ID/generation/recreation, duplicates/capacity, sparse indices, failed persistence and CLI parity (OpenSSL-backed curve/SHA adapters)")
print("PASS: operation-admission semantics: browser invalidated in derivation double before NVS; admitted add/password transactions still commit, only target is revoked, next command rejects; persistence failure still preserves live state (not precommit cancellation or real concurrency)")
print("PASS: canonical parsed accounts + production DB transactions: nonself isolation, prompt revocation/cancel/mismatch, currentness, persistence/RNG/derive failures, final-admin invariants, self/generated/key/recovery traps; no password output")
+115
View File
@@ -0,0 +1,115 @@
#define SSH_TRANSPORT_MAX_SESSIONS 2U
enum { SSH_TRANSPORT_SESSION_FREE=0, SSH_TRANSPORT_SESSION_ACTIVE=2,
SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE=2 };
enum { USER_AUTH_METHOD_PASSWORD=0 };
typedef struct {
uint32_t session_id, generation, broker_client_id;
int state, route, socket_fd;
uint8_t console_slot_index;
bool authenticated, principal_valid, writer, close_requested;
size_t rx_length, rx_offset, tx_length, tx_offset;
user_principal_t principal;
char peer[48];
} ssh_slot_t;
typedef struct {
bool active, tx_pending, rx_pending, authenticated, principal_valid, close_requested;
bool writer, admin_command_pending;
uint32_t session_id, generation, broker_client_id, admin_output_pending;
int state, route, socket_fd, user_role, auth_method;
char username[USER_DATABASE_USERNAME_CAPACITY+1U], peer[48];
} ssh_transport_session_snapshot_t;
static ssh_transport_session_snapshot_t s_session_snapshots[2];
static user_principal_t s_console_principals[2];
static uint8_t s_console_slot_indices[2];
static uint32_t s_external_close_id[2];
static unsigned stopped, disconnected, rotated, reset, restarted;
static esp_err_t ssh_transport_stop(void) { ++stopped; return ESP_OK; }
static esp_err_t ssh_transport_disconnect(uint32_t id) { disconnected=id; return ESP_OK; }
static esp_err_t ssh_transport_replace_host_key(bool r) { if(r) ++reset; else ++rotated; return ESP_OK; }
static void esp_restart(void) { ++restarted; }
static void publish_slot(const ssh_slot_t *, size_t);
static bool admin_console_drained(const admin_ssh_console_token_t *);
static bool admin_console_is_current(const admin_ssh_console_token_t *, const user_principal_t *);
static bool consume_external_close(const ssh_slot_t *, size_t);
static esp_err_t admin_console_perform(const admin_ssh_console_token_t *, admin_ssh_deferred_action_type_t, uint32_t);
static void test_adapter(void)
{
admin_ssh_console_token_t token={ .slot_index=0, .session_id=7, .slot_generation=3 };
user_principal_t admin={ .role=USER_ROLE_ADMIN, .user_id=11, .auth_generation=2,
.username_length=5, .username="admin" };
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
assert(!admin_console_drained(&token));
s_session_snapshots[0]=(ssh_transport_session_snapshot_t){ .active=true, .session_id=7, .generation=3 };
assert(!admin_console_drained(&token)); /* No published console binding. */
assert(!admin_console_is_current(&token,&admin));
ssh_slot_t active={ .session_id=7, .generation=3, .authenticated=true,
.principal_valid=true, .state=SSH_TRANSPORT_SESSION_ACTIVE,
.route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE, .principal=admin };
publish_slot(&active,0);
assert(admin_console_is_current(&token,&admin));
/* Production publication carries both console output and principal binding. */
assert(s_session_snapshots[0].tx_pending && s_session_snapshots[0].admin_output_pending);
assert(!strcmp(s_session_snapshots[0].username,"admin"));
uint8_t output[4096]; size_t n;
assert(admin_ssh_console_read_output(&token,output,sizeof(output),&n)==ESP_OK && n);
publish_slot(&active,0);
assert(admin_console_drained(&token));
active.state=SSH_TRANSPORT_SESSION_FREE; active.principal_valid=false;
publish_slot(&active,0);
user_principal_t empty={0};
assert(!memcmp(&s_console_principals[0],&empty,sizeof(empty)));
assert(!admin_console_is_current(&token,&admin));
active.state=SSH_TRANSPORT_SESSION_ACTIVE; active.principal_valid=true;
publish_slot(&active,0);
assert(admin_console_is_current(&token,&admin));
admin.username[0]='A'; assert(!admin_console_is_current(&token,&admin)); admin.username[0]='a';
active.route=0; publish_slot(&active,0); assert(!admin_console_is_current(&token,&admin));
active.route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
active.authenticated=false; publish_slot(&active,0); assert(!admin_console_is_current(&token,&admin));
active.authenticated=true; publish_slot(&active,0);
s_external_close_id[0]=7; assert(!admin_console_is_current(&token,&admin));
ssh_slot_t closing={.session_id=7, .state=SSH_TRANSPORT_SESSION_ACTIVE};
assert(consume_external_close(&closing,0) && !s_external_close_id[0]);
assert(!admin_console_is_current(&token,&admin));
s_session_snapshots[0].close_requested=true;
assert(!admin_console_is_current(&token,&admin)); s_session_snapshots[0].close_requested=false;
++admin.auth_generation; assert(!admin_console_is_current(&token,&admin)); --admin.auth_generation;
++admin.user_id; assert(!admin_console_is_current(&token,&admin)); --admin.user_id;
++admin.method; assert(!admin_console_is_current(&token,&admin)); --admin.method;
admin.username_length=1; assert(!admin_console_is_current(&token,&admin)); admin.username_length=5;
token.transport=1; assert(!admin_console_drained(&token));
assert(!admin_console_is_current(&token,&admin));
assert(admin_ssh_console_open(&token,&admin)==ESP_ERR_INVALID_ARG);
token.transport=0; token.slot_generation=4; assert(!admin_console_drained(&token));
assert(!admin_console_is_current(&token,&admin));
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND && stopped==0);
token.slot_generation=3;
/* Same physical session can be assigned the other console slot. */
admin_ssh_console_close(&token);
token.slot_index=1;
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
active.console_slot_index=1; publish_slot(&active,0);
assert(admin_console_is_current(&token,&admin));
assert(s_console_slot_indices[0]==1);
admin_ssh_console_token_t wrong=token; wrong.slot_index=0;
assert(!admin_console_is_current(&wrong,&admin) && !admin_console_drained(&wrong));
assert(admin_console_perform(&wrong,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND);
/* A colliding published ID with a stale generation cannot steal the lookup. */
s_session_snapshots[1]=s_session_snapshots[0];
++s_session_snapshots[1].generation; s_console_slot_indices[1]=0;
assert(admin_console_is_current(&token,&admin));
assert(admin_ssh_console_read_output(&token,output,sizeof(output),&n)==ESP_OK && n);
publish_slot(&active,0);
s_session_snapshots[0].tx_pending=true;
assert(!admin_console_drained(&token)); s_session_snapshots[0].tx_pending=false;
assert(admin_console_perform(&token,ADMIN_CONSOLE_DEFER_SELF_CLOSE,99)==ESP_OK && disconnected==7);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_DISCONNECT,99)==ESP_OK && disconnected==99);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_OK && stopped==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_ROTATE,0)==ESP_OK && rotated==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_RESET,0)==ESP_OK && reset==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_REBOOT,0)==ESP_OK && restarted==1);
puts("PASS: actual SSH snapshot/principal publication and wiping, adapter identity/drain checks, legacy admission and lifecycle action routing");
}
+138
View File
@@ -0,0 +1,138 @@
/* Typed deferred work exercises the production dispatcher/control state machine. */
static admin_ssh_console_token_t token={.session_id=7, .slot_generation=1,
.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
static user_principal_t principal={.role=USER_ROLE_ADMIN, .auth_generation=1};
static bool live=true, close_in_action;
static unsigned validations, invalidate_at;
static esp_err_t action_result;
static bool current(const admin_ssh_console_token_t *t, const user_principal_t *p) {
assert(!lock_depth && t->session_id==7 && p->auth_generation==1);
if (++validations==invalidate_at) live=false;
return live;
}
static bool drained(const admin_ssh_console_token_t *t) {
assert(!lock_depth && current_task==s_control_task && t->session_id==7);
return owner_drained;
}
static esp_err_t perform(const admin_ssh_console_token_t *t,
admin_ssh_deferred_action_type_t action, uint32_t arg);
static const admin_console_owner_t owner={
.supported_actions=1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
.dispatcher_actions=1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
.is_current=current, .drained=drained, .perform=perform,
};
static esp_err_t perform(const admin_ssh_console_token_t *t,
admin_ssh_deferred_action_type_t action, uint32_t arg) {
assert(!lock_depth && current_task==s_task && current_task!=s_control_task);
assert(action==ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE && arg==0);
assert(s_sessions[0].executing && s_sessions[0].deferred_action_pending);
assert(!admin_ssh_console_accepts_input(t));
size_t consumed=99;
assert(!admin_ssh_console_feed_input(t,(const uint8_t *)"ignored",7,&consumed) && !consumed);
++actions;
if (close_in_action) {
admin_ssh_console_close(t);
admin_ssh_console_token_t replacement=*t; ++replacement.slot_generation;
assert(admin_ssh_console_open_owned(&replacement,&principal,&owner)==ESP_ERR_INVALID_STATE);
}
return action_result;
}
static void pump(void (*task)(void *)) {
current_task=task==control_task ? s_control_task : s_task;
if (!setjmp(loop_done)) task(NULL);
}
static void clear_output(void) {
uint8_t data[4096]; size_t n;
assert(admin_ssh_console_read_output(&token,data,sizeof(data),&n)==ESP_OK);
}
static void reopen_certificate_session(void) {
admin_ssh_console_close(&token); ++token.slot_generation;
live=principal_current=owner_drained=true; validations=invalidate_at=0;
close_in_action=false; action_result=ESP_OK; ticks=0;
assert(admin_ssh_console_open_owned(&token,&principal,&owner)==ESP_OK);
clear_output();
}
static esp_err_t schedule(void) {
current_task=s_task; s_dispatch_remote=true; s_dispatch_token=token;
s_dispatch_principal=principal;
s_sessions[0].executing=s_sessions[0].command_pending=true;
esp_err_t result=admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,0);
s_sessions[0].executing=s_sessions[0].command_pending=false;
s_dispatch_remote=false;
return result;
}
static void revoke_delay(void) { if (ticks>=200) live=false; }
static void reuse_delay(void) {
if (ticks>=200) { delay_hook=NULL; reopen_certificate_session(); }
}
static void assert_pending(void) {
admin_ssh_console_session_snapshot_t snapshot;
assert(admin_ssh_console_get_session_snapshot(&token,&snapshot)==ESP_OK);
assert(snapshot.deferred_action_pending && !admin_ssh_console_accepts_input(&token));
}
static void uart_observes_pending(void) {
assert(current_task==s_task && actions==0); assert_pending();
}
int main(void) {
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
s_task=(void *)1; s_control_task=(void *)2;
/* Union overlay preserves the old queue item allocation on this ABI. */
struct old_request { admin_request_origin_t origin; admin_ssh_console_token_t token;
user_principal_t principal; TaskHandle_t completion_task;
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY+1U]; };
assert(sizeof(admin_request_t)==sizeof(struct old_request));
assert(s_request_queue->capacity==4 && s_control_queue->capacity==2);
reopen_certificate_session(); queue_full=true;
assert(schedule()==ESP_ERR_TIMEOUT && !s_sessions[0].deferred_action_pending && !actions);
queue_full=false;
assert(schedule()==ESP_OK); assert_pending();
/* Drain waits for acknowledgement, then times out without enqueue/mutation. */
s_sessions[0].output_length=1; pump(control_task);
assert(ticks==10000 && !actions && !s_request_queue->count && !s_sessions[0].deferred_action_pending);
clear_output(); ticks=0;
assert(schedule()==ESP_OK);
admin_request_t uart={.origin=ADMIN_REQUEST_UART0, .line="memory"};
for (unsigned i=0;i<4;++i) assert(xQueueSend(s_request_queue,&uart,0));
pump(control_task);
assert(!actions && !s_sessions[0].deferred_action_pending && s_request_queue->count==4);
assert(s_sessions[0].output_length); pump(worker_task); clear_output();
puts("PASS: unchanged queue item/depths, admission and handoff queue failure before mutation, ack drain cancellation");
assert(schedule()==ESP_OK);
assert(xQueueSend(s_request_queue,&uart,0));
pump(control_task); assert_pending(); assert(!actions && s_request_queue->count==2);
command_hook=uart_observes_pending; pump(worker_task); command_hook=NULL;
assert(actions==1 && !s_sessions[0].deferred_action_pending && !s_sessions[0].executing);
assert(runs==5); /* Typed work never calls esp_console_run. */
puts("PASS: control only hands off, queued UART first, crypto callback exclusively serialized on dispatcher, input gated through callback");
for (unsigned cancellation=0;cancellation<7;++cancellation) {
reopen_certificate_session(); assert(schedule()==ESP_OK);
if (cancellation==0) delay_hook=revoke_delay;
if (cancellation==1) delay_hook=reuse_delay;
pump(control_task); delay_hook=NULL;
if (cancellation==2) live=false;
if (cancellation==3) principal_current=false;
if (cancellation==4) reopen_certificate_session();
if (cancellation==5) admin_ssh_console_close(&token);
if (cancellation==6) invalidate_at=2; /* Last check after executing reservation. */
pump(worker_task);
assert(actions==1 && !s_sessions[0].executing);
if (cancellation==1 || cancellation==4) {
assert(s_sessions[0].active && !s_sessions[0].deferred_action_pending && !s_sessions[0].output_length);
}
}
puts("PASS: delay/queued revoke, account revoke, close/reuse, final execution check; no output into replacements");
reopen_certificate_session(); action_result=ESP_ERR_NO_MEM; assert(schedule()==ESP_OK);
pump(control_task); pump(worker_task);
assert(actions==2 && !s_sessions[0].deferred_action_pending && admin_ssh_console_accepts_input(&token));
uint8_t out[512]={0}; size_t n;
assert(admin_ssh_console_read_output(&token,out,sizeof(out)-1,&n)==ESP_OK);
assert(strstr((char *)out,"Deferred remote action failed: fake"));
reopen_certificate_session(); close_in_action=true; assert(schedule()==ESP_OK);
pump(control_task); pump(worker_task);
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)) && actions==3);
puts("PASS: action error reaches deferred result, input resumes on failure, self-detach reserves slot until return and wipes state");
}
+101
View File
@@ -0,0 +1,101 @@
#include <assert.h>
#include <errno.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <setjmp.h>
typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND };
enum { USER_ROLE_USER, USER_ROLE_ADMIN };
#define USER_DATABASE_USERNAME_CAPACITY 16U
typedef struct {
uint32_t user_id, auth_generation;
int role, method;
size_t username_length;
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
} user_principal_t;
typedef unsigned TickType_t;
typedef void *TaskHandle_t;
typedef int portMUX_TYPE;
typedef struct { size_t size; unsigned count, capacity; unsigned char bytes[2048]; } StaticQueue_t;
typedef StaticQueue_t *QueueHandle_t;
typedef int StaticSemaphore_t;
typedef int *SemaphoreHandle_t;
#define portMUX_INITIALIZER_UNLOCKED 0
#define pdTRUE 1
#define pdPASS 1
#define portMAX_DELAY UINT32_MAX
#define pdMS_TO_TICKS(x) (x)
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
static unsigned lock_depth, ticks, runs, actions;
static uint32_t lifecycle_settings_executed;
static void web_lifecycle_settings_execute(uint32_t id) { assert(!lock_depth); lifecycle_settings_executed = id; }
static uint32_t ssh_settings_executed;
static void web_ssh_settings_execute(uint32_t id) { assert(!lock_depth); ssh_settings_executed = id; }
static uint32_t broker_settings_executed;
static void web_broker_settings_execute(uint32_t id) { assert(!lock_depth); broker_settings_executed = id; }
static uint32_t serial_settings_executed, account_settings_executed, network_settings_executed, display_settings_executed;
static void web_display_settings_execute(uint32_t id) { assert(!lock_depth); display_settings_executed = id; }
static void web_network_settings_execute(uint32_t id) { assert(!lock_depth); network_settings_executed = id; }
static void web_account_settings_execute(uint32_t id) { assert(!lock_depth); account_settings_executed = id; }
static unsigned serial_settings_preceding_runs, queue_send_wait;
static void web_serial_settings_execute(uint32_t id) {
assert(!lock_depth);
serial_settings_executed = id;
serial_settings_preceding_runs = runs;
}
static bool principal_current = true, queue_full, owner_drained = true;
static TaskHandle_t current_task = (void *)1;
static jmp_buf loop_done;
static void (*delay_hook)(void), (*prompt_hook)(void), (*completion_hook)(void);
static void (*command_hook)(void);
#define taskENTER_CRITICAL(p) ((void)(p), ++lock_depth)
#define taskEXIT_CRITICAL(p) ((void)(p), --lock_depth)
static void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
static size_t strlcpy(char *d, const char *s, size_t n) {
size_t len = strlen(s); if (n) { size_t k = len < n-1 ? len : n-1;
memcpy(d, s, k); d[k] = 0; } return len;
}
static esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *c)
{ (void)p; assert(!lock_depth); *c = principal_current; return ESP_OK; }
static const char *esp_err_to_name(int e) { (void)e; return "fake"; }
static TaskHandle_t xTaskGetCurrentTaskHandle(void) { return current_task; }
static unsigned xTaskGetTickCount(void) { return ticks; }
static void vTaskDelay(unsigned n) { assert(!lock_depth); ticks += n; if (delay_hook) delay_hook(); }
static int xTaskCreate(void (*f)(void *), const char *n, unsigned s, void *c,
unsigned p, TaskHandle_t *t)
{ (void)f; (void)n; (void)s; (void)c; (void)p; *t = (void *)1; return pdPASS; }
static void vTaskDelete(TaskHandle_t t) { (void)t; }
static void xTaskNotifyGive(TaskHandle_t t) { (void)t; }
static unsigned ulTaskNotifyTake(int b, unsigned t) { (void)b; (void)t; return 1; }
static QueueHandle_t xQueueCreateStatic(unsigned n, size_t s, uint8_t *b, StaticQueue_t *q)
{ (void)b; q->size = s; q->capacity = n; assert(n*s <= sizeof(q->bytes)); return q; }
static int xQueueSend(QueueHandle_t q, const void *p, unsigned t)
{ queue_send_wait=t; if (queue_full || q->count==q->capacity) return 0;
memcpy(q->bytes+q->count*q->size,p,q->size); ++q->count; return 1; }
static int xQueueReceive(QueueHandle_t q, void *p, unsigned t)
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size);
--q->count; memmove(q->bytes,q->bytes+q->size,q->count*q->size); return 1; }
static SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *s) { return s; }
static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t)
{ assert(!lock_depth); if (t && !*s) { ticks+=t; if (prompt_hook) prompt_hook(); }
int r=*s; *s=0; return r; }
static int xSemaphoreGive(SemaphoreHandle_t s) { *s=1; return 1; }
static void linenoiseSetMaxLineLen(unsigned n) { (void)n; }
static char *linenoise(const char *p) { (void)p; return NULL; }
static int linenoiseHistoryAdd(const char *p) { (void)p; return 1; }
static void linenoiseFree(char *p) { (void)p; }
static bool console_completion_expand(const char *s, char *d, size_t n)
{ (void)s; (void)d; (void)n; if (completion_hook) completion_hook(); return false; }
static bool console_completion_format_matches(const char *s, char *d, size_t n, size_t *len)
{ (void)s; *len=strlcpy(d,"help\r\n",n); return true; }
size_t esp_console_split_argv(char *s, char **v, size_t n);
static esp_err_t esp_console_run(const char *s, int *r)
{ (void)s; ++runs; if (command_hook) command_hook(); *r=0; return ESP_OK; }
typedef struct { const char *command, *help, *hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
static int esp_console_cmd_register(const esp_console_cmd_t *c) { (void)c; return 0; }
static FILE *funopen(void *c, void *r, int (*w)(void *,const char *,int), void *s, void *f)
{ (void)c; (void)r; (void)w; (void)s; (void)f; return tmpfile(); }
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Actual canonical stop/reboot handlers with deterministic side-effect doubles."""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
def function(path, name):
source = path.read_text()
start = source.index('static int ' + name + '(')
return source[start:source.index('\n}', start) + 2]
header = '\n'.join(line for line in (ROOT / 'src/admin_ssh_console.h').read_text().splitlines()
if not line.startswith(('#include', '#pragma once')))
prelude = r'''
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
enum { ESP_OK=0, ESP_FAIL=-1, ESP_ERR_TIMEOUT=7 };
typedef struct { int unused; } user_principal_t;
'''
fakes = r'''
static bool remote, web;
static unsigned stops, reboots, scheduled, waits, rotations, usages;
static esp_err_t schedule_result, stop_result;
static admin_ssh_deferred_action_type_t last_action;
bool admin_ssh_console_dispatch_is_remote(void) { return remote; }
bool admin_ssh_console_dispatch_is_web(void) { return remote && web; }
esp_err_t admin_ssh_console_dispatch_defer(admin_ssh_deferred_action_type_t action, uint32_t argument) {
assert(remote && !argument); ++scheduled; last_action=action; return schedule_result;
}
static const char *esp_err_to_name(esp_err_t error) { (void)error; return "fake"; }
static esp_err_t web_server_stop(void) { ++stops; return stop_result; }
static esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
static esp_err_t web_server_clear_counters(void) { assert(false); return ESP_FAIL; }
static esp_err_t web_serial_transport_clear_counters(void) { assert(false); return ESP_FAIL; }
static void esp_restart(void) { ++reboots; }
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
#define pdMS_TO_TICKS(ms) (ms)
static void print_usage(void) { ++usages; }
static int web_diagnostics_command(const char *action) { assert(!strcmp(action, "show")); return 0; }
static int performance_command(const char *action) { return strcmp(action, "show") ? 1 : 0; }
static int show_status(void) { assert(false); return 1; }
static int show_counters(void) { assert(false); return 1; }
static int show_certificate(void) { assert(false); return 1; }
static int rotate_certificate(void) { ++rotations; return 0; }
static int reset_material(void) { assert(false); return 1; }
static bool force_is_present(int argc, char **argv, int expected) {
return argc == expected && !strcmp(argv[expected - 1], "--force");
}
'''
tests = r'''
int main(void) {
char *removed[]={"web", "credentials", "show", "--force"};
for (unsigned origin=0; origin<3; ++origin) {
remote=origin!=0; web=origin==2;
removed[2]="show";
assert(command_web(2,removed)==1);
assert(command_web(3,removed)==1);
removed[2]="rotate";
assert(command_web(3,removed)==1);
assert(command_web(4,removed)==1);
}
assert(usages==12 && !stops && !scheduled && !rotations);
remote=web=false;
char *diagnostics[]={"web", "diagnostics", "show"};
assert(command_web(3, diagnostics)==0 && !stops && !scheduled);
char *performance[]={"web", "performance", "show", "extra"};
assert(command_web(3, performance)==0 && !stops && !scheduled);
assert(command_web(4, performance)==1 && !stops && !scheduled);
performance[2]="invalid";
assert(command_web(3, performance)==1 && !stops && !scheduled);
char *stop[]={"web", "stop"};
remote=web=true;
assert(command_web(2,stop)==0 && scheduled==1 && !stops && last_action==ADMIN_CONSOLE_DEFER_WEB_STOP);
schedule_result=ESP_ERR_TIMEOUT;
assert(command_web(2,stop)==1 && scheduled==2 && !stops);
schedule_result=ESP_OK;
web=false; /* SSH preserves its synchronous HTTPS path. */
assert(command_web(2,stop)==0 && stops==1 && scheduled==2);
remote=false;
assert(command_web(2,stop)==0 && stops==2 && scheduled==2);
stop_result=ESP_FAIL;
assert(command_web(2,stop)==1 && stops==3);
remote=true;
assert(command_reboot(1,NULL)==0 && scheduled==3 && !reboots && last_action==ADMIN_SSH_DEFER_REBOOT);
web=true;
assert(command_reboot(1,NULL)==0 && scheduled==4 && !reboots && last_action==ADMIN_SSH_DEFER_REBOOT);
schedule_result=ESP_FAIL;
assert(command_reboot(1,NULL)==1 && scheduled==5 && !reboots);
assert(command_reboot(2,NULL)==1 && scheduled==5 && !reboots);
remote=false;
assert(command_reboot(1,NULL)==0 && reboots==1 && waits==1 && scheduled==5);
char *rotate[]={"web", "certificate", "rotate", "--force", "extra"};
remote=web=true; schedule_result=ESP_OK;
assert(command_web(4,rotate)==0 && scheduled==6 && !rotations &&
last_action==ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE);
schedule_result=ESP_ERR_TIMEOUT;
assert(command_web(4,rotate)==1 && scheduled==7 && !rotations);
assert(command_web(3,rotate)==1 && scheduled==7 && !rotations);
assert(command_web(5,rotate)==1 && scheduled==7 && !rotations);
web=false;
assert(command_web(4,rotate)==0 && rotations==1 && scheduled==7);
remote=false;
assert(command_web(4,rotate)==0 && rotations==2 && scheduled==7);
puts("PASS: canonical WEB stop/certificate deferred, exact force required, SSH/UART unchanged, reboot and queue failure isolation");
}
'''
with tempfile.TemporaryDirectory(prefix='console-lifecycle-') as directory:
tmp = Path(directory)
(tmp / 'test.c').write_text(prelude + header + fakes +
function(ROOT / 'src/web_console.c', 'command_web') +
function(ROOT / 'src/system_console.c', 'command_reboot') + tests)
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror',
str(tmp / 'test.c'), '-o', str(tmp / 'test')], check=True, timeout=30)
subprocess.run([str(tmp / 'test')], check=True, timeout=10)
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Compile actual console implementation with deterministic host RTOS/IO fakes.
No target scheduler, socket library, or hardware execution is claimed.
"""
from pathlib import Path
import os
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
parser = str(IDF / "components/console/split_argv.c")
source = (ROOT / "src/admin_ssh_console.c").read_text()
header = (ROOT / "src/admin_ssh_console.h").read_text()
def strip_includes(text):
return "\n".join(line for line in text.splitlines()
if not line.startswith(("#include", "#pragma once")))
with tempfile.TemporaryDirectory(prefix="admin-console-boundary-") as directory:
path = Path(directory)
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
+ strip_includes(header) + "\n" + strip_includes(source)
+ (ROOT / "tests/admin_console_boundary/test.c").read_text())
(path / "test.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-g", str(path / "test.c"), parser,
"-o", str(path / "test")], check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10)
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
+ strip_includes(header) + "\n" + strip_includes(source)
+ (ROOT / "tests/admin_console_boundary/certificate.c").read_text())
(path / "certificate.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-g", str(path / "certificate.c"), parser,
"-o", str(path / "certificate")], check=True, timeout=30)
subprocess.run([str(path / "certificate")], check=True, timeout=10)
ssh = (ROOT / "src/ssh_transport.c").read_text()
adapter = ssh[ssh.index("static admin_ssh_console_token_t admin_console_token("):
ssh.index("static void *ssh_malloc(")]
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
+ strip_includes(header) + "\n" + strip_includes(source)
+ (ROOT / "tests/admin_console_boundary/adapter.c").read_text()
+ adapter + "\nint main(void) { test_adapter(); }\n")
(path / "adapter.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-variable", str(path / "adapter.c"), parser,
"-o", str(path / "adapter")], check=True, timeout=30)
subprocess.run([str(path / "adapter")], check=True, timeout=10)
+428
View File
@@ -0,0 +1,428 @@
static admin_ssh_console_token_t a = { .slot_index=0, .session_id=7, .slot_generation=1 };
static admin_ssh_console_token_t b = { .slot_index=1, .session_id=7, .slot_generation=1, .transport=1 };
static user_principal_t admin = { .role=USER_ROLE_ADMIN };
static bool live[2] = {true, true};
static void (*current_hook)(void);
static bool is_current(const admin_ssh_console_token_t *t, const user_principal_t *p)
{
assert(!lock_depth && p->role==USER_ROLE_ADMIN);
if (current_hook) current_hook();
return live[t->slot_index];
}
static bool drained(const admin_ssh_console_token_t *t)
{ assert(!lock_depth); assert(t->session_id==7); return owner_drained; }
static esp_err_t perform(const admin_ssh_console_token_t *t,
admin_ssh_deferred_action_type_t action, uint32_t arg)
{ (void)t; (void)arg; assert(!lock_depth); assert(action==ADMIN_CONSOLE_DEFER_SELF_CLOSE); ++actions; return ESP_OK; }
static const admin_console_owner_t owner = {
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE, .drained=drained, .perform=perform,
.is_current=is_current,
};
static void pump(void (*task)(void *)) { if (!setjmp(loop_done)) task(NULL); }
static void feed(const admin_ssh_console_token_t *t, const char *s)
{ size_t n=0; assert(admin_ssh_console_feed_input(t,(const uint8_t *)s,strlen(s),&n)); assert(n==strlen(s)); }
static void clear_output(const admin_ssh_console_token_t *t)
{ uint8_t out[4096]; size_t n; assert(admin_ssh_console_read_output(t,out,sizeof(out),&n)==ESP_OK); }
static void competing_completion(void)
{
size_t n=99;
assert(!admin_ssh_console_feed_input(&b,(const uint8_t *)"\t",1,&n));
assert(n==0 && s_completion_busy);
}
static void reopen_during_completion(void)
{
assert(!lock_depth && s_completion_busy);
admin_ssh_console_close(&a);
++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
clear_output(&a);
}
static void hidden_reply(void) { feed(&a,"secret\r"); }
static void cancel_reply(void) { feed(&a,"secret\x03"); }
static void close_prompt(void) { admin_ssh_console_close(&a); }
static void close_during_delay(void) { if (ticks>=200) admin_ssh_console_close(&a); }
static void close_during_command(void)
{
assert(s_sessions[0].executing);
admin_ssh_console_close(&a);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
}
static void setup_dispatch(void)
{ s_dispatch_remote=true; s_dispatch_token=a; s_dispatch_principal=admin;
s_sessions[0].executing=true; s_sessions[0].command_pending=true; }
static void reopen_during_current(void)
{
current_hook=NULL;
admin_ssh_console_close(&a);
++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
live[0]=false; /* Failed old validation must not close the replacement. */
}
static void revoked_reply(void) { hidden_reply(); live[0]=false; }
static unsigned checks;
static void stale_at_execution(void) { if (++checks==2) live[0]=false; }
static void account_revoked_reply(void) { hidden_reply(); principal_current=false; }
static void closed_reply(void) { hidden_reply(); close_prompt(); }
static unsigned waits;
static void unanswered(void)
{
++waits;
if (waits==1) xSemaphoreGive(s_prompt_done); /* Stale wake while still waiting. */
if (waits==3) live[0]=false; /* No close notification. */
}
static void prompt_command(void)
{
uint8_t answer[32]; size_t n=99;
assert(admin_ssh_console_dispatch_read_input("Password: ",answer,sizeof(answer),true,&n)==ESP_ERR_NOT_FOUND);
assert(n==0);
for (size_t i=0;i<sizeof(answer);++i) assert(!answer[i]);
assert(!s_sessions[0].active && !s_sessions[0].prompt_length);
for (size_t i=0;i<sizeof(s_sessions[0].prompt_input);++i) assert(!s_sessions[0].prompt_input[i]);
}
static void test_currentness(void)
{
++a.slot_generation;
admin_console_owner_t missing=owner; missing.is_current=NULL;
assert(admin_ssh_console_open_owned(&a,&admin,&missing)==ESP_ERR_INVALID_ARG);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
unsigned before=runs;
feed(&a,"owner stale\r"); live[0]=false; pump(worker_task);
assert(runs==before && !s_sessions[0].active && principal_current);
feed(&b,"isolated\r"); pump(worker_task); assert(runs==++before);
live[0]=true; ++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
feed(&a,"reuse\r"); current_hook=reopen_during_current; pump(worker_task);
assert(runs==before && token_matches(&s_sessions[0],&a));
live[0]=true;
feed(&a,"last check\r"); checks=0; current_hook=stale_at_execution;
pump(worker_task); current_hook=NULL;
assert(checks==2 && runs==before && !s_sessions[0].active);
live[0]=true; ++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
void (*hooks[])(void)={revoked_reply,account_revoked_reply,closed_reply,unanswered};
for (size_t i=0;i<sizeof(hooks)/sizeof(hooks[0]);++i) {
clear_output(&a); ticks=0; waits=0;
feed(&a,"prompt\r"); prompt_hook=hooks[i]; command_hook=prompt_command;
pump(worker_task); prompt_hook=NULL; command_hook=NULL;
assert(runs==++before);
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
if (i==3) assert(waits==3 && ticks==750);
/* Dispatcher recovered, so trusted UART0 work still runs. */
admin_request_t uart={.origin=ADMIN_REQUEST_UART0};
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==++before);
live[0]=true; principal_current=true; ++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
}
setup_dispatch(); clear_output(&a); live[0]=false;
uint8_t answer[32]; size_t n=99;
assert(admin_ssh_console_dispatch_read_input("Not published",answer,sizeof(answer),true,&n)==ESP_ERR_NOT_FOUND);
assert(!n && !s_sessions[0].output_length);
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); live[0]=true;
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
clear_output(&a);
s_sessions[0].output_start=4094;
assert(worker_write(&a,"abcdef"));
uint8_t out[8];
assert(admin_ssh_console_read_output(&a,out,3,&n)==ESP_OK && n==3 && !memcmp(out,"abc",3));
assert(!s_sessions[0].output[4094] && !s_sessions[0].output[4095] && !s_sessions[0].output[0]);
assert(!memcmp(s_sessions[0].output+1,"def",3));
assert(admin_ssh_console_read_output(&a,out,sizeof(out),&n)==ESP_OK && n==3 && !memcmp(out,"def",3));
for (size_t i=0;i<sizeof(s_sessions[0].output);++i) assert(!s_sessions[0].output[i]);
admin_ssh_console_close(&a);
puts("PASS: owner stale/account current isolation, callback close/reuse, revoked submitted prompts, periodic unanswered invalidation/stale wake, UART recovery, consumed output wiping");
}
static void test_dispatch_currentness(void)
{
++a.slot_generation; live[0]=true; principal_current=true;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
setup_dispatch();
assert(admin_ssh_console_dispatch_is_current());
current_task=(void *)2;
assert(!admin_ssh_console_dispatch_is_current());
current_task=s_task;
live[0]=false;
assert(!admin_ssh_console_dispatch_is_current());
assert(!s_sessions[0].active);
secure_wipe(&s_sessions[0],sizeof(s_sessions[0]));
++a.slot_generation; live[0]=true;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
setup_dispatch(); principal_current=false;
assert(!admin_ssh_console_dispatch_is_current());
assert(!s_sessions[0].active);
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); principal_current=true;
++a.slot_generation;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
setup_dispatch(); s_dispatch_token.slot_generation--;
assert(!admin_ssh_console_dispatch_is_current());
assert(s_sessions[0].active); /* Stale dispatch cannot close replacement. */
secure_wipe(&s_sessions[0],sizeof(s_sessions[0]));
s_dispatch_remote=false;
assert(admin_ssh_console_dispatch_is_current()); /* Trusted UART0. */
puts("PASS: handler currentness API rejects wrong task, stale owner/account/token; preserves replacement and UART0");
}
static void test_shared_admission(void)
{
admin_ssh_console_token_t web={.slot_index=255, .session_id=7,
.slot_generation=42, .transport=ADMIN_CONSOLE_TRANSPORT_WEB};
admin_ssh_console_token_t ssh=web; ssh.transport=ADMIN_CONSOLE_TRANSPORT_SSH;
static const admin_console_owner_t second_owner={
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE,
.drained=drained, .perform=perform, .is_current=is_current,
};
assert(admin_ssh_console_open_available(&web,&admin,&owner)==ESP_OK);
assert(web.slot_index==0 && web.session_id==7 && web.slot_generation==42 &&
web.transport==ADMIN_CONSOLE_TRANSPORT_WEB);
assert(admin_ssh_console_open_available(&ssh,&admin,&second_owner)==ESP_OK);
assert(ssh.slot_index==1 && ssh.session_id==7 && ssh.slot_generation==42 && !ssh.transport);
assert(s_sessions[0].owner==&owner && s_sessions[1].owner==&second_owner);
unsigned before=runs;
clear_output(&web);
feed(&web,"\"web\" \"reset\" --force\r"); pump(worker_task);
assert(runs==before && !s_control_queue->count);
uint8_t diagnostic[512]={0}; size_t received=0;
assert(admin_ssh_console_read_output(&web,diagnostic,sizeof(diagnostic)-1,&received)==ESP_OK);
assert(strstr((char *)diagnostic,"unavailable from the web console"));
feed(&web,"\"user\" \"password\" admin --generate\r"); pump(worker_task);
assert(runs==before && !s_control_queue->count);
feed(&web,"\"web\" \"status\"\r"); pump(worker_task); assert(runs==before+1);
/* UART0 bypasses remote policy and remains the recovery path. */
admin_request_t uart={.origin=ADMIN_REQUEST_UART0, .line="user recover --force"};
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==before+2);
runs=before;
admin_ssh_console_token_t full=web; full.slot_index=99;
assert(admin_ssh_console_open_available(&full,&admin,&owner)==ESP_ERR_INVALID_STATE);
assert(full.slot_index==99);
admin_ssh_console_token_t stale=web;
s_sessions[0].executing=true;
admin_ssh_console_close(&web);
assert(admin_ssh_console_open_available(&full,&admin,&owner)==ESP_ERR_INVALID_STATE);
assert(full.slot_index==99); /* Inactive executing slots still consume capacity. */
s_sessions[0].executing=false;
++web.slot_generation;
assert(admin_ssh_console_open_available(&web,&admin,&owner)==ESP_OK);
admin_ssh_console_close(&stale);
assert(!admin_ssh_console_accepts_input(&stale) && admin_ssh_console_accepts_input(&web));
assert(admin_ssh_console_accepts_input(&ssh));
admin_ssh_console_close(&web); admin_ssh_console_close(&ssh);
puts("PASS: two-owner shared admission, colliding preferred indices/IDs, full capacity, executing reservation and stale tokens");
}
int main(void)
{
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
test_shared_admission();
principal_current=false;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
principal_current=true;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
assert(admin_ssh_console_open_owned(&b,&admin,&owner)==ESP_OK);
admin_ssh_console_token_t other=a; other.transport=1;
assert(admin_ssh_console_open_owned(&other,&admin,&owner)==ESP_ERR_INVALID_STATE);
admin_ssh_console_close(&other);
assert(!admin_ssh_console_accepts_input(&other));
other=a; ++other.slot_generation; admin_ssh_console_close(&other);
assert(admin_ssh_console_accepts_input(&a));
clear_output(&a); clear_output(&b);
completion_hook=competing_completion; feed(&a,"\t"); completion_hook=NULL;
assert(!s_completion_busy && s_sessions[0].output_length && !s_sessions[1].output_length);
/* Keep the in-flight token unchanged; only the reopened session advances. */
admin_ssh_console_token_t completing=a;
completion_hook=reopen_during_completion; feed(&completing,"\t"); completion_hook=NULL;
assert(a.slot_generation==completing.slot_generation+1);
assert(!s_completion_busy && !s_sessions[0].output_length && !s_sessions[0].input_length);
assert(!admin_ssh_console_accepts_input(&completing));
feed(&a,"\t"); assert(s_sessions[0].output_length); clear_output(&a);
feed(&a,"help\r"); assert(runs==0); pump(worker_task); assert(runs==1);
feed(&a,"\x1b[A"); assert(!strcmp((char *)s_sessions[0].input,"help"));
feed(&a,"\x03");
feed(&a,"stale\r"); admin_ssh_console_close(&a);
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
pump(worker_task); assert(runs==1);
feed(&a,"revoked\r"); principal_current=false; pump(worker_task); assert(runs==1); principal_current=true;
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
admin_request_t uart={ .origin=ADMIN_REQUEST_UART0 };
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==2);
feed(&a,"close\r"); command_hook=close_during_command; pump(worker_task); command_hook=NULL;
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
setup_dispatch(); clear_output(&a); prompt_hook=hidden_reply;
uint8_t secret[32]; size_t n;
uint8_t history_before[sizeof(s_sessions[0].history)];
memcpy(history_before,s_sessions[0].history,sizeof(history_before));
assert(admin_ssh_console_dispatch_is_current());
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_OK);
assert(!memcmp(history_before,s_sessions[0].history,sizeof(history_before)));
for (size_t i=0;i<sizeof(s_sessions[0].prompt_input);++i) assert(!s_sessions[0].prompt_input[i]);
assert(n==6 && !memcmp(secret,"secret",6));
assert(s_sessions[0].output_length==strlen("Password: \r\n"));
clear_output(&a);
assert(admin_ssh_console_dispatch_read_input("Visible: ",secret,sizeof(secret),false,&n)==ESP_OK);
assert(s_sessions[0].output_length==strlen("Visible: secret\r\n"));
prompt_hook=cancel_reply;
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_ERR_INVALID_STATE);
assert(n==0 && secret[0]==0 && s_sessions[0].prompt_input[0]==0);
prompt_hook=close_prompt;
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_ERR_NOT_FOUND);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); prompt_hook=NULL;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
setup_dispatch(); clear_output(&a);
assert(!admin_ssh_console_dispatch_is_web());
s_dispatch_token.transport = ADMIN_CONSOLE_TRANSPORT_WEB;
assert(admin_ssh_console_dispatch_is_web());
s_dispatch_remote = false; assert(!admin_ssh_console_dispatch_is_web());
s_dispatch_remote = true; s_dispatch_token = a;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_STOP,0)==ESP_ERR_NOT_SUPPORTED);
assert(admin_ssh_console_dispatch_defer((admin_ssh_deferred_action_type_t)32,0)==ESP_ERR_NOT_SUPPORTED);
assert(admin_ssh_console_dispatch_defer(ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_SUPPORTED);
assert(!s_sessions[0].deferred_action_pending);
queue_full=true;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_ERR_TIMEOUT);
assert(!s_sessions[0].deferred_action_pending); queue_full=false;
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
admin_ssh_console_session_snapshot_t pending;
assert(admin_ssh_console_get_session_snapshot(&a, &pending)==ESP_OK && pending.deferred_action_pending);
assert(!admin_ssh_console_feed_input(&a,(const uint8_t *)"x",1,&n) && n==0);
s_sessions[0].command_pending=false; owner_drained=false; ticks=0;
pump(control_task); assert(ticks==10000 && actions==0);
clear_output(&a); owner_drained=true;
/* The fake esp_console_run does not invoke registered command callbacks. */
assert(command_exit(1,NULL)==0);
assert(s_control_queue->count==1);
admin_control_request_t exit_request;
memcpy(&exit_request,s_control_queue->bytes,sizeof(exit_request));
assert(exit_request.action==ADMIN_CONSOLE_DEFER_SELF_CLOSE);
assert(token_matches(&s_sessions[0],&exit_request.token));
assert(exit_request.owner==&owner && exit_request.argument==a.session_id);
ticks=0; pump(control_task); assert(ticks==200 && actions==1);
clear_output(&a);
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
ticks=0; delay_hook=close_during_delay; pump(control_task); delay_hook=NULL; assert(actions==1);
secure_wipe(&s_sessions[0],sizeof(s_sessions[0]));
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
s_sessions[0].output_length=4096; ticks=0;
assert(ssh_output_write(&a,"x",1)==-1 && errno==EAGAIN && ticks==5000);
clear_output(&a); s_dispatch_output_previous_cr=false;
assert(ssh_output_write(&a,"a\nb\r\n",6)==6);
assert(s_sessions[0].output_length==7);
admin_ssh_console_close(&a);
assert(ssh_output_write(&a,"x",1)==-1 && errno==EPIPE);
assert(!lock_depth);
test_currentness();
test_dispatch_currentness();
unsigned before_serial = runs;
assert(admin_ssh_console_submit_serial_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_serial_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_serial_settings(1) == ESP_ERR_TIMEOUT);
queue_full = false;
admin_request_t preceding_uart = {.origin = ADMIN_REQUEST_UART0};
assert(xQueueSend(s_request_queue, &preceding_uart, 0));
queue_send_wait = portMAX_DELAY;
assert(admin_ssh_console_submit_serial_settings(17) == ESP_OK && !serial_settings_executed);
assert(queue_send_wait == 0);
admin_request_t typed;
memcpy(&typed, s_request_queue->bytes + sizeof(typed), sizeof(typed));
assert(typed.origin == ADMIN_REQUEST_SERIAL_SETTINGS && typed.serial_settings_id == 17);
assert(s_request_queue->capacity == 4 && sizeof(typed.line) == 257);
pump(worker_task);
assert(serial_settings_executed == 17 && runs == before_serial + 1 && !s_request_queue->count);
assert(serial_settings_preceding_runs == before_serial + 1);
for (unsigned i = 0; i < s_request_queue->capacity; ++i)
assert(xQueueSend(s_request_queue, &preceding_uart, 0));
queue_send_wait = portMAX_DELAY;
assert(admin_ssh_console_submit_serial_settings(18) == ESP_ERR_TIMEOUT);
assert(queue_send_wait == 0 && s_request_queue->count == 4 && serial_settings_executed == 17);
pump(worker_task);
assert(runs == before_serial + 5 && serial_settings_executed == 17 && !s_request_queue->count);
puts("PASS: typed Serial admission uses zero wait on success/full queue, preserves all four queued UART requests and FIFO execution, no command-string dispatch");
assert(admin_ssh_console_submit_account_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_account_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_account_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
queue_full = false;
assert(admin_ssh_console_submit_serial_settings(21) == ESP_OK);
assert(admin_ssh_console_submit_account_settings(22) == ESP_OK && queue_send_wait == 0);
pump(worker_task);
assert(serial_settings_executed == 21 && account_settings_executed == 22 && runs == before_serial + 5);
puts("PASS: typed Accounts uses same bounded queue with nonblocking admission and isolated dispatcher routing");
assert(admin_ssh_console_submit_network_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_network_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_network_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
queue_full = false;
assert(admin_ssh_console_submit_serial_settings(31) == ESP_OK);
assert(admin_ssh_console_submit_network_settings(32) == ESP_OK && queue_send_wait == 0);
assert(admin_ssh_console_submit_account_settings(33) == ESP_OK);
pump(worker_task);
assert(serial_settings_executed == 31 && network_settings_executed == 32 && account_settings_executed == 33);
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
puts("PASS: typed Network queues only an ID, shares unchanged queue, executes outside lock without command runner");
assert(admin_ssh_console_submit_display_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_display_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_display_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
queue_full = false;
assert(admin_ssh_console_submit_serial_settings(41) == ESP_OK);
assert(admin_ssh_console_submit_network_settings(42) == ESP_OK);
assert(admin_ssh_console_submit_account_settings(43) == ESP_OK);
assert(admin_ssh_console_submit_display_settings(44) == ESP_OK && queue_send_wait == 0);
assert(admin_ssh_console_submit_display_settings(45) == ESP_ERR_TIMEOUT && s_request_queue->count == 4);
pump(worker_task);
assert(serial_settings_executed == 41 && network_settings_executed == 42 && account_settings_executed == 43 && display_settings_executed == 44);
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
puts("PASS: typed Display IDs share all four unchanged queue slots; full/not-ready admission fails, routing never invokes command runner");
assert(admin_ssh_console_submit_broker_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_broker_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_broker_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
queue_full = false;
assert(admin_ssh_console_submit_broker_settings(51) == ESP_OK);
assert(admin_ssh_console_submit_display_settings(52) == ESP_OK);
assert(admin_ssh_console_submit_network_settings(53) == ESP_OK);
assert(admin_ssh_console_submit_account_settings(54) == ESP_OK);
assert(admin_ssh_console_submit_broker_settings(55) == ESP_ERR_TIMEOUT);
pump(worker_task);
assert(broker_settings_executed == 51 && display_settings_executed == 52 && network_settings_executed == 53 && account_settings_executed == 54);
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
puts("PASS: Broker typed IDs, not-ready/full queue, routing and unchanged dispatcher capacity");
assert(admin_ssh_console_submit_ssh_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_ssh_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_ssh_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
queue_full = false;
assert(admin_ssh_console_submit_ssh_settings(61) == ESP_OK);
assert(admin_ssh_console_submit_broker_settings(62) == ESP_OK);
assert(admin_ssh_console_submit_network_settings(63) == ESP_OK);
assert(admin_ssh_console_submit_account_settings(64) == ESP_OK);
assert(admin_ssh_console_submit_ssh_settings(65) == ESP_ERR_TIMEOUT);
pump(worker_task);
assert(ssh_settings_executed == 61 && broker_settings_executed == 62 && network_settings_executed == 63 && account_settings_executed == 64);
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
puts("PASS: SSH typed ID dispatcher routing, zero-wait/full/not-ready, no command runner or capacity growth");
assert(admin_ssh_console_submit_lifecycle_settings(0) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = false;
assert(admin_ssh_console_submit_lifecycle_settings(1) == ESP_ERR_INVALID_STATE);
s_dispatch_ready = true; queue_full = true;
assert(admin_ssh_console_submit_lifecycle_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
queue_full = false;
assert(admin_ssh_console_submit_lifecycle_settings(71) == ESP_OK);
assert(admin_ssh_console_submit_ssh_settings(72) == ESP_OK);
assert(admin_ssh_console_submit_network_settings(73) == ESP_OK);
assert(admin_ssh_console_submit_account_settings(74) == ESP_OK);
assert(admin_ssh_console_submit_lifecycle_settings(75) == ESP_ERR_TIMEOUT);
pump(worker_task);
assert(lifecycle_settings_executed == 71 && ssh_settings_executed == 72 && network_settings_executed == 73 && account_settings_executed == 74);
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
puts("PASS: Lifecycle typed ID routing, zero-wait/full/not-ready; unchanged runner and four-entry queue");
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
}
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""Focused policy test: actual project helper plus installed IDF argv parser.
Requires Python 3, cc and IDF_PATH (defaults to PlatformIO's installed SDK).
Does not run FreeRTOS dispatch, SSH I/O or target hardware.
"""
import os
import re
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
source = (ROOT / "src/admin_ssh_console.c").read_text()
start = source.index("bool admin_ssh_console_web_user_command_allowed(")
policy = source[start:source.index("\n}", start) + 2]
start = source.index("static bool remote_command_allowed(")
helper = source[start:source.index("\n}", start) + 2]
prelude = r'''
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <stdio.h>
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
#define ADMIN_SSH_CONSOLE_MAX_ARGUMENTS 10U
#define ADMIN_CONSOLE_TRANSPORT_WEB 1U
#define USER_DATABASE_USERNAME_CAPACITY 16U
#define USER_ROLE_ADMIN 2
typedef struct { int role; size_t username_length; char username[17]; } user_principal_t;
typedef struct {
user_principal_t principal;
struct { uint8_t transport; } token;
char line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
} admin_request_t;
size_t esp_console_split_argv(char *, char **, size_t);
static void secure_wipe(void *p, size_t n) {
volatile unsigned char *bytes = p;
while (n--) *bytes++ = 0;
}
'''
cases = r'''
int main(void) {
const struct { const char *line; bool allowed; } cases[] = {
{"", true}, {" ", true}, {" ", true},
{"memory", true}, {"user", true}, {"user list", true},
{"user show bootstrap", true}, {"exit", true},
{"web performance enable", true}, {"web performance disable", true},
{"web performance show", true}, {"web performance clear", true},
/* Removed verbs reach the canonical handler, not a bootstrap policy. */
{"user bootstrap", true}, {"user bootstrap extra", true},
{"user recover", false}, {"user recover --force", false},
{" user recover --force ", false},
{"\"user\" \"bootstrap\"", true},
{"\"user\" \"recover\" --force", false},
};
for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); ++i) {
admin_request_t request = {0};
strcpy(request.line, cases[i].line);
assert(remote_command_allowed(&request) == cases[i].allowed);
assert(!strcmp(request.line, cases[i].line));
}
const char *web_allowed[] = {
"", " ", "help", "memory", "exit", "user", "user status", "user list",
"user show admin", "\"user\" \"show\" \"bootstrap\"",
"web status", "web stop", "reboot", "\"reboot\"", "\"web\" \"stop\"",
"wifi status", "mdns status", "\"web\" \"status\"",
"user add other user", "user add other admin", "user password other",
"user delete other --force", "user role other user --force",
"user role other admin --force", "\"user\" \"password\" \"other\"",
"web certificate rotate --force",
" \"web\" \"certificate\" \"rotate\" \"--force\" ",
"ssh status", "ssh sessions", "ssh counters", "ssh host-key info", "ssh start",
};
const char *web_denied[] = {
"web", "web help", "web start", "web stop extra", "web counters", "web clear-counters",
"web diagnostics enable", "web diagnostics disable", "web diagnostics show", "web diagnostics clear",
"web performance enable", "web performance disable", "web performance show", "web performance clear",
"web credentials show", "web credentials rotate --force", "web certificate info",
"web certificate rotate", "web certificate rotate --force extra",
"web certificate rotate --force --force", "web certificate rotate --Force",
"web certificate rotate --forcex", "web certificate --force rotate",
"\"web\" \"certificate\" \"rotate\" \"--force extra\"",
"web reset --force", "web status extra",
"wifi", "wifi profiles", "wifi scan", "wifi start", "wifi stop", "wifi save",
"wifi load", "wifi defaults", "wifi reset", "wifi ping example.org",
"mdns", "mdns suffix test", "mdns save", "mdns load", "mdns defaults", "mdns reset",
"reboot --force", "user bootstrap", "user recover --force",
"user add other admin --generate", "user delete other",
"user role other user", "user password admin --generate",
"user password admin", "user password other --generate",
"user delete admin --force", "user role admin admin --force",
"user role admin user --force", "user add admin admin",
"\"user\" \"password\" \"admin\"", "user password other extra",
"user add other invalid", "user add other user extra",
"user delete other --force extra", "user role other admin --force extra",
"user key add admin", "user key clear admin --force",
"user key delete admin 0 --force", "user list extra", "user show admin extra",
"ssh stop", "ssh disconnect 7", "ssh host-key rotate --force", "ssh reset --force",
" \"user\" \"password\" \"admin\" \"--generate\"",
"\"web\" \"credentials\" \"show\"", "\"wifi\" \"stop\"",
"\"mdns\" \"reset\"", "\"reboot\" extra", "\"ssh\" \"stop\"",
"\"ssh\" \"host-key\" \"rotate\" --force", "\"user\" \"recover\" --force",
};
for (size_t i=0; i<sizeof(web_allowed)/sizeof(web_allowed[0]); ++i) {
admin_request_t request={.token.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
request.principal = (user_principal_t){.role=USER_ROLE_ADMIN,
.username_length=5, .username="admin"};
strcpy(request.line,web_allowed[i]);
assert(remote_command_allowed(&request));
assert(!strcmp(request.line,web_allowed[i]));
}
for (size_t i=0; i<sizeof(web_denied)/sizeof(web_denied[0]); ++i) {
admin_request_t request={.token.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
request.principal = (user_principal_t){.role=USER_ROLE_ADMIN,
.username_length=5, .username="admin"};
strcpy(request.line,web_denied[i]);
if (remote_command_allowed(&request)) fprintf(stderr,"Unexpected allow: %s\n",request.line);
assert(!remote_command_allowed(&request));
assert(!strcmp(request.line,web_denied[i]));
request.token.transport=0;
/* SSH retains only the global recovery dispatcher restriction. */
assert(remote_command_allowed(&request) ==
(strstr(request.line,"recover")==NULL));
}
puts("PASS: UART0-only recovery, removed bootstrap policy, web bounded account forms, restrictions/lifecycle and quoted forms checked with actual IDF parser");
}
'''
with tempfile.TemporaryDirectory(prefix="admin-ssh-policy-") as directory:
path = Path(directory)
(path / "test.c").write_text(prelude + policy + helper + cases)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
"-o", str(path / "test")], check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10)
# Compile the actual composition-root security initialization and start gates.
# Other subsystem setup is excluded; deterministic errors model its results.
main = (ROOT / "src/main.c").read_text()
initialization = main[main.index(" web_security_load_result_t web_security_source"):
main.index(" esp_err_t web_runtime_error")]
gates = main[main.index(" if (wifi_error == ESP_OK && web_security_error"):
main.index(" if (local_ui_error == ESP_OK)")]
web_header = "\n".join(line for line in (ROOT / "src/web_security.h").read_text().splitlines()
if not line.startswith(("#include", "#pragma once")))
user_header = (ROOT / "src/user_database.h").read_text()
user_state = re.search(r"typedef enum \{[^{}]*\} user_database_load_result_t;", user_header).group()
startup = r'''
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define SSH_TRANSPORT_PORT 22
#define TAG "test"
#define ESP_LOGI(tag, ...) snprintf(last_log, sizeof(last_log), __VA_ARGS__)
#define ESP_LOGE(tag, ...) snprintf(last_error, sizeof(last_error), __VA_ARGS__)
static char last_log[256], last_error[256], tls_log[256];
static esp_err_t tls_error, db_error;
static unsigned tls_calls, db_calls, web_starts, ssh_starts;
static const char *esp_err_to_name(esp_err_t e) { (void)e; return "error"; }
static esp_err_t web_server_start(void) { ++web_starts; return ESP_OK; }
static esp_err_t ssh_transport_start(void) { ++ssh_starts; return ESP_OK; }
'''
startup += web_header + "\n" + user_state + r'''
static web_security_load_result_t tls_source;
static user_database_load_result_t db_source;
esp_err_t web_security_init(web_security_load_result_t *out) {
++tls_calls; *out=tls_source; return tls_error;
}
esp_err_t user_database_init(user_database_load_result_t *out) {
++db_calls; strcpy(tls_log,last_log); *out=db_source; return db_error;
}
static void boot(esp_err_t random_error, esp_err_t wifi_error,
esp_err_t web_runtime_error, esp_err_t ssh_security_error,
esp_err_t ssh_runtime_error) {
'''
startup += initialization + gates + r'''
}
int main(void) {
const web_security_load_result_t sources[]={WEB_SECURITY_LOAD_STORED,
WEB_SECURITY_LOAD_GENERATED_MISSING, WEB_SECURITY_LOAD_MIGRATED_V1};
const char *labels[]={"Using stored HTTPS identity", "Using newly generated HTTPS identity",
"Using migrated v1 HTTPS identity"};
for (unsigned i=0;i<3;++i) {
tls_source=sources[i];
for (unsigned empty=0;empty<2;++empty) {
db_source=empty ? USER_DATABASE_LOAD_EMPTY : USER_DATABASE_LOAD_STORED;
boot(ESP_OK,ESP_FAIL,ESP_OK,ESP_OK,ESP_OK);
assert(!strcmp(tls_log,labels[i]));
assert(!strcmp(last_log,empty ? "Using new empty user database" : "Using stored user database"));
}
}
for (unsigned failures=0;failures<64;++failures) {
tls_error=(failures&1) ? ESP_FAIL : ESP_OK;
db_error=(failures&2) ? ESP_FAIL : ESP_OK;
esp_err_t wifi=(failures&4) ? ESP_FAIL : ESP_OK;
esp_err_t web_runtime=(failures&8) ? ESP_FAIL : ESP_OK;
esp_err_t ssh_security=(failures&16) ? ESP_FAIL : ESP_OK;
esp_err_t ssh_runtime=(failures&32) ? ESP_FAIL : ESP_OK;
tls_calls=db_calls=web_starts=ssh_starts=0;
boot(ESP_OK,wifi,web_runtime,ssh_security,ssh_runtime);
assert(tls_calls==1 && db_calls==1);
assert(web_starts==(!wifi && !tls_error && !web_runtime));
assert(ssh_starts==(!wifi && !ssh_security && !ssh_runtime));
if (db_error) assert(strstr(last_error,"user recover --force"));
}
tls_calls=db_calls=web_starts=ssh_starts=0;
boot(ESP_FAIL,ESP_OK,ESP_OK,ESP_FAIL,ESP_OK);
assert(!tls_calls && db_calls==1 && !web_starts && !ssh_starts);
puts("PASS: startup init signatures/states, exact TLS source logs, 64 independent service-gate cases and RNG failure");
}
'''
(path / "startup.c").write_text(startup)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
str(path / "startup.c"), "-o", str(path / "startup")], check=True, timeout=30)
subprocess.run([str(path / "startup")], check=True, timeout=10)
completion = (ROOT / "src/console_completion.c").read_text()
candidates = re.findall(r'^\s*"([^"\n]+)",?$', completion, re.MULTILINE)
assert not any(c.startswith(("user bootstrap", "web credentials")) for c in candidates)
for retained in ("user recover --force", "user add", "user password", "user key add",
"web certificate info", "web certificate rotate --force", "web reset --force"):
assert retained in candidates
assert "Bootstrap/recovery" not in source
assert "legacy" not in initialization
print("PASS: removed completion entries, retained account/TLS/recovery commands and no startup credential copy")
+122
View File
@@ -0,0 +1,122 @@
#pragma once
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <setjmp.h>
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_ERR_INVALID_STATE 1
#define ESP_ERR_INVALID_ARG 2
#define ESP_ERR_NO_MEM 3
#define ESP_ERR_NOT_FOUND 4
#define ESP_ERR_TIMEOUT 5
static const char *esp_err_to_name(esp_err_t e) { (void)e; return "fake-error"; }
#define MALLOC_CAP_SPIRAM 1
#define MALLOC_CAP_8BIT 2
#define MALLOC_CAP_INTERNAL 4
static size_t allocations;
static void *heap_caps_calloc_prefer(size_t n, size_t s, int choices, ...) {
(void)choices; ++allocations; return calloc(n, s);
}
#define heap_caps_free free
typedef unsigned TickType_t;
typedef unsigned UBaseType_t;
#define pdTRUE 1
#define pdPASS 1
#define portMAX_DELAY UINT32_MAX
#define pdMS_TO_TICKS(ms) (ms)
static int mutex;
typedef int *SemaphoreHandle_t;
static SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &mutex; }
static int xSemaphoreTake(SemaphoreHandle_t m, TickType_t ticks) {
if (*m) { assert(ticks == 0); return 0; } *m = 1; return pdTRUE;
}
static void xSemaphoreGive(SemaphoreHandle_t m) { assert(*m); *m = 0; }
static void vSemaphoreDelete(SemaphoreHandle_t m) { assert(!*m); }
typedef struct { uint8_t *data; size_t capacity, used; } StaticStreamBuffer_t;
typedef StaticStreamBuffer_t *StreamBufferHandle_t;
static StreamBufferHandle_t xStreamBufferCreateStatic(size_t size, size_t trigger,
uint8_t *data, StaticStreamBuffer_t *s) {
assert(trigger == 1); *s = (StaticStreamBuffer_t){data, size - 1, 0}; return s;
}
static size_t xStreamBufferBytesAvailable(StreamBufferHandle_t s) {
assert(mutex); return s->used;
}
static size_t xStreamBufferSend(StreamBufferHandle_t s, const void *data, size_t size, TickType_t ticks) {
assert(mutex && ticks == 0);
if (size > s->capacity - s->used) size = s->capacity - s->used;
memcpy(s->data + s->used, data, size); s->used += size; return size;
}
static size_t xStreamBufferReceive(StreamBufferHandle_t s, void *data, size_t size, TickType_t ticks) {
assert(mutex && ticks == 0);
if (size > s->used) size = s->used;
memcpy(data, s->data, size); s->used -= size;
memmove(s->data, s->data + size, s->used); return size;
}
static void xStreamBufferReset(StreamBufferHandle_t s) { assert(mutex); s->used = 0; }
static void vStreamBufferDelete(StreamBufferHandle_t s) { (void)s; }
typedef struct { uint8_t *data; size_t capacity, item_size, used; } StaticQueue_t;
typedef StaticQueue_t *QueueHandle_t;
static QueueHandle_t xQueueCreateStatic(size_t capacity, size_t size, uint8_t *data, StaticQueue_t *q) {
*q = (StaticQueue_t){data, capacity, size, 0}; return q;
}
static int xQueueSend(QueueHandle_t q, const void *item, TickType_t ticks) {
assert(mutex && ticks == 0); if (q->used == q->capacity) return 0;
memcpy(q->data + q->used++ * q->item_size, item, q->item_size); return pdTRUE;
}
static int xQueueReceive(QueueHandle_t q, void *item, TickType_t ticks) {
assert(mutex && ticks == 0); if (!q->used) return 0;
memcpy(item, q->data, q->item_size); --q->used;
memmove(q->data, q->data + q->item_size, q->used * q->item_size); return pdTRUE;
}
static UBaseType_t uxQueueMessagesWaiting(QueueHandle_t q) { assert(mutex); return q->used; }
static void xQueueReset(QueueHandle_t q) { assert(mutex); q->used = 0; }
static void vQueueDelete(QueueHandle_t q) { (void)q; }
typedef void *TaskHandle_t;
static void (*task_entry)(void *);
static jmp_buf task_exit;
static const uint8_t *serial_input;
static size_t serial_remaining;
static int xTaskCreate(void (*entry)(void *), const char *name, unsigned stack,
void *context, unsigned priority, TaskHandle_t *handle) {
(void)name; (void)stack; (void)context; (void)priority;
task_entry = entry; *handle = &mutex; return pdPASS;
}
static void vTaskDelay(TickType_t ticks) {
assert(!mutex && ticks > 0); if (!serial_remaining) longjmp(task_exit, 1);
}
static size_t serial_service_read(uint8_t *data, size_t size) {
assert(mutex); if (size > serial_remaining) size = serial_remaining;
memcpy(data, serial_input, size); serial_input += size; serial_remaining -= size; return size;
}
static size_t serial_service_write(const uint8_t *data, size_t size) { (void)data; assert(mutex); return size; }
static esp_err_t serial_service_set_session_active(bool active) { (void)active; assert(mutex); return ESP_OK; }
typedef struct {
const char *command, *help, *hint;
int (*func)(int, char **);
void *argtable;
} esp_console_cmd_t;
static int (*registered_command)(int, char **);
static esp_err_t esp_console_cmd_register(const esp_console_cmd_t *cmd) {
registered_command = cmd->func; return ESP_OK;
}
static char console_output[16384];
static size_t console_used;
static int capture_printf(const char *format, ...) {
/* Console formatting must happen after releasing the broker mutex. */
assert(!mutex);
va_list args; va_start(args, format);
int n = vsnprintf(console_output + console_used, sizeof(console_output) - console_used, format, args);
va_end(args); assert(n >= 0 && (size_t)n < sizeof(console_output) - console_used);
console_used += (size_t)n; return n;
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Compile unchanged production broker/console with isolated deterministic doubles.
No SDK, device, network, or persistent generated files required.
"""
from pathlib import Path
import shutil
import subprocess
import tempfile
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
with tempfile.TemporaryDirectory(prefix="broker-diagnostics-") as directory:
build = Path(directory)
(build / "freertos").mkdir()
for name in ("session_broker.c", "session_broker.h", "session_console.c", "session_console.h"):
shutil.copyfile(ROOT / "src" / name, build / name)
shutil.copyfile(HERE / "fake.h", build / "fake.h")
shutil.copyfile(HERE / "test.c", build / "test.c")
for name in ("esp_err.h", "esp_heap_caps.h", "esp_console.h", "serial_service.h",
"freertos/FreeRTOS.h", "freertos/queue.h", "freertos/semphr.h",
"freertos/stream_buffer.h", "freertos/task.h"):
(build / name).write_text('#include "fake.h"\n')
subprocess.run(["cc", "-std=c11", "-D_POSIX_C_SOURCE=200809L", "-Wall", "-Wextra",
"-Werror", "-I", str(build), str(build / "test.c"),
"-o", str(build / "test")], check=True)
subprocess.run([str(build / "test")], check=True)
+210
View File
@@ -0,0 +1,210 @@
#include "fake.h"
#include "session_broker.c"
#define printf capture_printf
#include "session_console.c"
#undef printf
static uint8_t payload[SESSION_BROKER_OUTPUT_SIZE + 256];
static void feed(size_t size) {
assert(size <= sizeof(payload));
serial_input = payload; serial_remaining = size;
if (setjmp(task_exit) == 0) task_entry(NULL);
assert(!mutex && serial_remaining == 0);
}
static session_broker_client_snapshot_t snapshot(session_broker_client_id_t id) {
session_broker_client_snapshot_t s;
assert(session_broker_get_client_snapshot(id, &s) == ESP_OK); return s;
}
static session_broker_global_snapshot_t global(void) {
session_broker_global_snapshot_t s;
assert(session_broker_get_global_snapshot(&s) == ESP_OK); return s;
}
static size_t drain(session_broker_client_id_t id, size_t size) {
uint8_t data[sizeof(payload)]; size_t received;
assert(size <= sizeof(data));
assert(session_broker_read(id, data, size, &received) == ESP_OK); return received;
}
static session_broker_client_id_t connect_type(session_broker_client_type_t type) {
session_broker_client_id_t id;
assert(session_broker_connect(type, "SECRET-NAME-\033[2J", &id) == ESP_OK); return id;
}
static int command(const char *operation, session_broker_client_id_t id, const char *size) {
char id_text[16]; snprintf(id_text, sizeof(id_text), "%u", id);
char *argv[] = {"broker", (char *)operation, id_text, (char *)size};
console_used = 0; console_output[0] = 0;
return registered_command(id ? (size ? 4 : 3) : 2, argv);
}
static void disconnect_all(void) {
session_broker_client_snapshot_t clients[SESSION_BROKER_MAX_CLIENTS];
size_t n = session_broker_list_clients(clients, SESSION_BROKER_MAX_CLIENTS);
for (size_t i = 0; i < n; ++i) assert(session_broker_disconnect(clients[i].id) == ESP_OK);
}
static void comparison(unsigned browsers) {
session_broker_client_id_t ids[4];
ids[0] = connect_type(SESSION_BROKER_CLIENT_USB);
ids[1] = connect_type(SESSION_BROKER_CLIENT_SSH);
for (unsigned i = 0; i < browsers; ++i) ids[2+i] = connect_type(SESSION_BROKER_CLIENT_WEB);
assert(session_broker_clear_counters() == ESP_OK);
size_t total = 0;
while (total < 71292) {
size_t n = 256;
if (n > 71292 - total) n = 71292 - total;
/* Deterministic slow-browser window, not a claim about real scheduling. */
if (browsers == 2 && total < 23412 && n > 23412 - total) n = 23412 - total;
feed(n); total += n;
for (unsigned i = 0; i < 2+browsers; ++i) {
if (browsers == 2 && i == 3 && total < 23412) continue;
drain(ids[i], sizeof(payload));
}
}
session_broker_global_snapshot_t g = global();
uint64_t expected = 71292U * (2+browsers);
uint64_t drops = browsers == 2 ? 19316 : 0;
assert(g.counters.uart_rx_bytes == 71292 && g.counters.disconnections == 0);
assert(g.counters.output_queued_bytes == expected-drops);
assert(g.counters.output_read_bytes == expected-drops && g.counters.output_dropped_bytes == drops);
for (unsigned i = 0; i < 2+browsers; ++i) {
session_broker_client_snapshot_t s = snapshot(ids[i]);
assert(s.counters.output_dropped_bytes == (i == 3 ? drops : 0));
assert(s.counters.output_high_water_bytes == (i == 3 ? 4096 : 256));
}
printf("PASS synthetic %u-browser comparison: UART=71292 copies=%" PRIu64 " queued=%" PRIu64 " read=%" PRIu64 " dropped=%" PRIu64 " disconnect=0\n",
browsers, expected, g.counters.output_queued_bytes, g.counters.output_read_bytes, drops);
disconnect_all();
}
int main(void) {
for (size_t i = 0; i < sizeof(payload); ++i) payload[i] = (uint8_t)i;
assert(session_broker_get_global_snapshot(&(session_broker_global_snapshot_t){0}) == ESP_ERR_INVALID_STATE);
assert(session_broker_init() == ESP_OK);
assert(session_console_register_commands() == ESP_OK);
size_t initial_allocations = allocations;
session_broker_client_id_t fast = connect_type(SESSION_BROKER_CLIENT_USB);
session_broker_client_id_t slow = connect_type(SESSION_BROKER_CLIENT_WEB);
assert(session_broker_request_writer(fast) == ESP_OK);
for (unsigned i = 0; i < 17; ++i) { feed(256); assert(drain(fast, 256) == 256); }
session_broker_client_snapshot_t s = snapshot(slow);
assert(s.output_bytes_pending == 4096 && s.counters.output_high_water_bytes == 4096);
assert(s.counters.output_queued_bytes == 4096 && s.counters.output_dropped_bytes == 256);
s = snapshot(fast);
assert(s.counters.output_read_bytes == 4352 && s.counters.output_high_water_bytes == 256);
assert(s.counters.output_dropped_bytes == 0 && s.is_writer);
puts("PASS isolated fanout overflow and exact 4096-byte high-water");
assert(drain(slow, 4000) == 4000);
assert(snapshot(slow).counters.output_high_water_bytes == 4096);
session_broker_global_snapshot_t before = global();
assert(session_broker_clear_client_counters(slow) == ESP_OK);
s = snapshot(slow);
assert(s.output_bytes_pending == 96 && s.counters.output_high_water_bytes == 96);
assert(s.counters.output_queued_bytes == 0 && s.counters.output_read_bytes == 0 && s.counters.output_dropped_bytes == 0);
assert(global().counters.output_queued_bytes == before.counters.output_queued_bytes);
feed(256); assert(snapshot(slow).counters.output_high_water_bytes == 352);
assert(session_broker_clear_counters() == ESP_OK);
s = snapshot(slow);
assert(s.output_bytes_pending == 352 && s.counters.output_high_water_bytes == 352);
assert(global().writer_id == fast && global().latest_event_sequence == before.latest_event_sequence);
assert(global().counters.uart_rx_bytes == 0 && s.counters.uart_rx_bytes == 0);
assert(drain(slow, 352) == 352);
assert(snapshot(slow).counters.output_read_bytes == 352);
feed(sizeof(payload));
assert(snapshot(slow).counters.output_dropped_bytes == 256);
puts("PASS per-client/global clears seed pending; subsequent read/drop/HWM accounting");
before = global();
assert(session_broker_disconnect(slow) == ESP_OK);
assert(global().counters.output_dropped_bytes == before.counters.output_dropped_bytes + 4096);
assert(session_broker_get_client_snapshot(slow, &s) == ESP_ERR_NOT_FOUND);
session_broker_client_id_t replacement = connect_type(SESSION_BROKER_CLIENT_WEB);
assert(replacement != slow && (replacement & 7) == (slow & 7));
s = snapshot(replacement);
assert(s.output_bytes_pending == 0 && s.counters.output_high_water_bytes == 0);
assert(s.counters.output_queued_bytes == 0 && s.counters.output_read_bytes == 0 && s.counters.output_dropped_bytes == 0);
assert(session_broker_clear_client_counters(slow) == ESP_ERR_NOT_FOUND);
puts("PASS disconnect discard retained globally and generation-safe reuse resets diagnostics");
while (session_broker_list_clients(NULL, 0) < SESSION_BROKER_MAX_CLIENTS)
connect_type(SESSION_BROKER_CLIENT_INTERNAL);
struct { session_broker_client_snapshot_t row; uint64_t guard; } bounded = {.guard = UINT64_MAX};
assert(session_broker_list_clients(&bounded.row, 1) == 1 && bounded.guard == UINT64_MAX);
before = global();
assert(command("counters", 0, NULL) == 0);
assert(strstr(console_output, "pending HWM") && !strstr(console_output, "SECRET-NAME") && !strchr(console_output, '\033'));
unsigned lines = 0;
for (const char *p = strstr(console_output, "ID type"); *p; ++p) lines += *p == '\n';
assert(lines == 1 + SESSION_BROKER_MAX_CLIENTS && console_used < 4096);
assert(global().counters.output_read_bytes == before.counters.output_read_bytes);
puts("PASS bounded eight-row metadata-only console counters; snapshots do not consume output");
feed(1024);
assert(command("read", replacement, "513") == 1);
assert(snapshot(replacement).output_bytes_pending == 1024);
assert(command("read", replacement, "0") == 1);
assert(command("read", replacement, NULL) == 0);
assert(strstr(console_output, "read 512 bytes: 00010203") && !strchr(console_output, '\033'));
assert(snapshot(replacement).output_bytes_pending == 512);
assert(command("read", replacement, "1") == 0);
assert(snapshot(replacement).output_bytes_pending == 511);
puts("PASS console read bounds and binary-safe hexadecimal rendering");
disconnect_all();
assert(command("counters", 0, NULL) == 0);
comparison(1); comparison(2);
assert(allocations == initial_allocations);
puts("PASS no post-init broker allocations; 7 diagnostic groups passed");
session_broker_management_snapshot_t management;
assert(session_broker_get_management_snapshot(NULL) == ESP_ERR_INVALID_ARG);
mutex = 1; assert(session_broker_get_management_snapshot(&management) == ESP_ERR_TIMEOUT); mutex = 0;
session_broker_client_id_t usb = connect_type(SESSION_BROKER_CLIENT_USB);
session_broker_client_id_t ssh = connect_type(SESSION_BROKER_CLIENT_SSH);
session_broker_client_id_t web = connect_type(SESSION_BROKER_CLIENT_WEB);
assert(session_broker_request_writer(usb) == ESP_OK);
feed(128);
assert(session_broker_get_management_snapshot(&management) == ESP_OK);
assert(management.count == 3 && management.writer_id == usb && management.clients[0].pending == 128);
uint32_t generation = management.generation;
before = global();
assert(session_broker_assign_writer_current(0, generation) == ESP_ERR_INVALID_ARG);
assert(session_broker_assign_writer_current(ssh, 0) == ESP_ERR_INVALID_ARG);
assert(session_broker_get_management_snapshot(&management) == ESP_OK && management.generation == generation);
assert(global().latest_event_sequence == before.latest_event_sequence && snapshot(usb).output_bytes_pending == 128);
assert(session_broker_assign_writer_current(ssh, generation) == ESP_OK);
assert(global().writer_id == ssh && !snapshot(usb).is_writer && snapshot(ssh).is_writer);
before = global();
assert(session_broker_assign_writer_current(web, generation) == ESP_ERR_INVALID_STATE);
assert(global().writer_id == ssh && global().latest_event_sequence == before.latest_event_sequence);
assert(session_broker_get_management_snapshot(&management) == ESP_OK); generation = management.generation;
assert(session_broker_release_writer(ssh) == ESP_OK && session_broker_request_writer(ssh) == ESP_OK);
assert(session_broker_assign_writer_current(web, generation) == ESP_ERR_INVALID_STATE);
assert(session_broker_get_management_snapshot(&management) == ESP_OK); generation = management.generation;
assert(session_broker_disconnect(web) == ESP_OK);
session_broker_client_id_t reused = connect_type(SESSION_BROKER_CLIENT_WEB);
assert(reused != web && (reused & 7) == (web & 7));
before = global();
assert(session_broker_assign_writer_current(web, generation) == ESP_ERR_NOT_FOUND);
assert(global().writer_id == ssh && global().latest_event_sequence == before.latest_event_sequence);
assert(session_broker_clear_counters() == ESP_OK);
assert(session_broker_get_management_snapshot(&management) == ESP_OK && management.generation == generation);
assert(session_broker_assign_writer_current(reused, generation) == ESP_OK);
assert(session_broker_get_management_snapshot(&management) == ESP_OK); generation = management.generation;
assert(session_broker_force_release_writer(reused) == ESP_OK);
assert(session_broker_assign_writer_current(usb, generation) == ESP_ERR_INVALID_STATE);
assert(session_broker_request_writer(usb) == ESP_OK);
assert(session_broker_get_management_snapshot(&management) == ESP_OK); generation = management.generation;
assert(session_broker_disconnect(usb) == ESP_OK);
assert(session_broker_assign_writer_current(ssh, generation) == ESP_ERR_INVALID_STATE);
puts("PASS Broker management: atomic nonconsuming snapshot, one writer, USB/SSH/Web interleavings, stale target/reuse/ABA and counter-clear fencing");
s_writer_generation = UINT32_MAX - 1;
assert(session_broker_request_writer(ssh) == ESP_OK && s_writer_generation == UINT32_MAX);
assert(session_broker_assign_writer_current(reused, UINT32_MAX) == ESP_ERR_INVALID_STATE);
assert(session_broker_force_writer(reused) == ESP_OK && s_writer_generation == UINT32_MAX);
disconnect_all();
for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) s_slots[i].generation = SESSION_BROKER_MAX_GENERATION;
s_slots[7].generation--;
session_broker_client_id_t last = connect_type(SESSION_BROKER_CLIENT_USB);
assert(last == UINT32_MAX);
assert(session_broker_disconnect(last) == ESP_OK);
assert(session_broker_connect(SESSION_BROKER_CLIENT_USB, "exhausted", &last) == ESP_ERR_NO_MEM);
assert(!global().connected_clients && !global().writer_id);
puts("PASS Broker wrap: saturated confirmation rejects, ordinary recovery remains; all 29-bit client generations retire without reuse");
cleanup_allocations();
return 0;
}
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Exact SSH management/lifecycle/close/slot-selection functions, deterministic RTOS.
No wolfSSH, sockets, real scheduling or target execution is claimed.
"""
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):
match = re.search(r'^(?:static )?[^\n]+\b' + name + r'\([^;]*?\n\{.*?^\}', source, re.M | re.S)
assert match, name
return match.group() + '\n'
header = '\n'.join(line for line in (ROOT / 'src/ssh_transport.h').read_text().splitlines() if not line.startswith(('#include', '#pragma once')))
constants = '\n'.join(re.search(r'^#define ' + name + r' .+$', source, re.M).group() for name in ('SSH_TRANSPORT_GENERATION_MAX', 'SSH_TRANSPORT_COMMAND_TIMEOUT_MS', 'SSH_TRANSPORT_MAX_PENDING_HANDSHAKES'))
username = re.search(r'^#define USER_DATABASE_USERNAME_CAPACITY .+$', (ROOT / 'src/user_database.h').read_text(), re.M).group()
fakes = r'''
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <stdio.h>
typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE, ESP_ERR_TIMEOUT, ESP_ERR_NOT_FOUND };
typedef uint32_t session_broker_client_id_t;
typedef int user_role_t;
typedef int user_auth_method_t;
'''
state = r'''
typedef struct { ssh_transport_session_state_t state; uint32_t generation, session_id; } ssh_slot_t;
static ssh_slot_t s_slots[SSH_TRANSPORT_MAX_SESSIONS];
static ssh_transport_session_snapshot_t s_session_snapshots[SSH_TRANSPORT_MAX_SESSIONS];
static uint32_t s_external_close_id[SSH_TRANSPORT_MAX_SESSIONS];
static unsigned depth, mutex_storage, notifications, ticks;
static unsigned *s_command_mutex = &mutex_storage;
static bool s_initialized, s_running, s_transitioning, s_cleanup_pending, s_desired_running;
static uint32_t s_management_generation, s_requested_sequence, s_completed_sequence;
static int s_command_result;
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;
#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 pdTRUE 1
#define portMAX_DELAY 99999U
#define pdMS_TO_TICKS(n) (n)
typedef unsigned TickType_t;
static int xSemaphoreTake(unsigned *m, unsigned wait) { assert(!depth); (void)wait; if (*m) return 0; *m = 1; return 1; }
static void xSemaphoreGive(unsigned *m) { assert(!depth && *m); *m = 0; }
static void notify_task(void) { assert(!depth); ++notifications; }
static unsigned xTaskGetTickCount(void) { return ticks; }
static void vTaskDelay(unsigned n) {
assert(!depth && mutex_storage); ticks += n;
if (!owner_stalled) {
s_completed_sequence = s_requested_sequence; s_command_result = owner_fail ? ESP_FAIL : ESP_OK;
s_running = owner_fail ? false : s_desired_running; s_transitioning = false; s_cleanup_pending = owner_fail;
}
}
'''
tests = r'''
static void reset(void) {
memset(s_slots, 0, sizeof(s_slots)); memset(s_session_snapshots, 0, sizeof(s_session_snapshots));
memset(s_external_close_id, 0, sizeof(s_external_close_id));
s_initialized = s_running = true; s_transitioning = s_cleanup_pending = owner_stalled = owner_fail = false;
mutex_storage = notifications = ticks = 0; s_management_generation = 7; s_requested_sequence = s_completed_sequence = 0;
for (unsigned i = 0; i < 2; ++i) {
s_slots[i] = (ssh_slot_t){SSH_TRANSPORT_SESSION_ACTIVE, 2, make_session_id(i, 2)};
s_session_snapshots[i] = (ssh_transport_session_snapshot_t){.active=true, .session_id=s_slots[i].session_id, .generation=2, .state=SSH_TRANSPORT_SESSION_ACTIVE};
}
}
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;
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(!notifications && !depth && !mutex_storage);
s_initialized = false; assert(ssh_transport_get_management_snapshot(&v) == ESP_ERR_INVALID_STATE); s_initialized = true;
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_OK);
assert(s_external_close_id[0] == 9 && !s_external_close_id[1] && notifications == 1);
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.sessions[0].close_requested && !v.sessions[1].close_requested);
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_NOT_FOUND);
assert(consume_external_close(&s_slots[0], 0) && !s_external_close_id[0] && s_session_snapshots[0].close_requested);
assert(!consume_external_close(&s_slots[1], 1));
puts("PASS SSH atomic published snapshot/target close, duplicate rejection and unrelated-slot isolation");
reset();
s_external_close_id[0] = 5; assert(!consume_external_close(&s_slots[0], 0));
s_slots[0].state = SSH_TRANSPORT_SESSION_FREE; assert(!consume_external_close(&s_slots[0], 0) && !s_external_close_id[0]);
s_session_snapshots[0].active = false;
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_NOT_FOUND);
s_session_snapshots[0].active = true; s_session_snapshots[0].session_id = 13;
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_NOT_FOUND);
assert(!s_external_close_id[0] && !notifications);
size_t index; s_slots[0].generation = SSH_TRANSPORT_GENERATION_MAX; s_slots[1].state = SSH_TRANSPORT_SESSION_FREE;
assert(find_free_slot(&index) == &s_slots[1] && index == 1);
s_slots[1].generation = SSH_TRANSPORT_GENERATION_MAX; assert(find_free_slot(&index) == NULL);
assert(make_session_id(1, SSH_TRANSPORT_GENERATION_MAX) != 0);
puts("PASS SSH disconnect/reuse/late owner close safety and generation exhaustion retires slots");
reset();
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 6) == ESP_ERR_INVALID_STATE);
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 7) == ESP_ERR_INVALID_STATE);
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 9, 7) == ESP_ERR_INVALID_ARG);
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 0, 7) == ESP_ERR_INVALID_ARG);
assert(ssh_transport_manage_current(99, 0, 7) == ESP_ERR_INVALID_ARG);
mutex_storage = 1; assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_ERR_TIMEOUT); mutex_storage = 0;
s_transitioning = true; assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_INVALID_STATE); s_transitioning = false;
assert(!notifications);
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_OK && !s_running && s_management_generation == 8);
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 7) == ESP_ERR_INVALID_STATE);
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 8) == ESP_OK && s_running && s_management_generation == 9);
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_INVALID_STATE);
assert(ssh_transport_stop() == ESP_OK && s_management_generation == 10);
assert(ssh_transport_start() == ESP_OK && s_management_generation == 11);
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 9) == ESP_ERR_INVALID_STATE);
puts("PASS SSH conditional start/stop, command mutex, CLI transitions and stop/start ABA fencing");
reset(); owner_stalled = true;
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_ERR_TIMEOUT && s_transitioning && !mutex_storage && s_management_generation == 8);
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 8) == ESP_ERR_INVALID_STATE);
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.transitioning);
reset(); owner_fail = true;
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_FAIL && s_cleanup_pending);
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 8) == ESP_ERR_INVALID_STATE);
owner_fail = false; assert(ssh_transport_stop() == ESP_OK && !s_cleanup_pending);
s_management_generation = UINT32_MAX - 1;
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, UINT32_MAX - 1) == ESP_OK && s_management_generation == UINT32_MAX);
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, UINT32_MAX) == ESP_ERR_INVALID_ARG);
assert(ssh_transport_stop() == ESP_OK && s_management_generation == UINT32_MAX);
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_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.
assert 'uint32_t generation = slot->generation + 1U;' in function('accept_connections')
assert 'next_generation(slot->generation)' not in source
with tempfile.TemporaryDirectory(prefix='ssh-management-') as directory:
tmp = Path(directory)
unit = fakes + username + '\n' + header + '\n' + constants + '\n' + state + '\n'.join(function(n) for n in names) + tests
(tmp / 'test.c').write_text(unit)
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', str(tmp / 'test.c'), '-o', str(tmp / 'test')], check=True, timeout=30)
subprocess.run([str(tmp / 'test')], check=True, timeout=10)
+71
View File
@@ -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)
+152
View File
@@ -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");
}

Some files were not shown because too many files have changed in this diff Show More