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.
This commit is contained in:
2026-09-08 19:09:26 +02:00
parent 82f21d6116
commit ac80863d80
26 changed files with 1013 additions and 583 deletions
+10 -4
View File
@@ -20,7 +20,7 @@ Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket,
- [Hardware wiring](docs/wiring.md): hardware profile, GPIO assignments, connector guidance, and safety notes. - [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. - [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 the planned integrated web-administration acceptance matrix.
- [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands. - [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands.
## Flash partition layout ## Flash partition layout
@@ -29,7 +29,7 @@ The N16R8 target has 16 MiB flash and 8 MiB octal PSRAM. PlatformIO uses the cus
| Partition | Offset | Size | Purpose | | 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 | | `otadata` | `0x089000` | 8 KiB | Active OTA-slot selection metadata |
| `phy_init` | `0x08B000` | 4 KiB | Optional PHY initialization data | | `phy_init` | `0x08B000` | 4 KiB | Optional PHY initialization data |
| `nvs_key` | `0x08C000` | 4 KiB | Reserved for future encrypted-NVS keys | | `nvs_key` | `0x08C000` | 4 KiB | Reserved for future encrypted-NVS keys |
@@ -73,12 +73,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. 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 ## Security notes
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 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 ## 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. 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.
+12 -6
View File
@@ -32,7 +32,7 @@ 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. 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. 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. 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. 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. 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. 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.
@@ -98,7 +98,7 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li
`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` 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.
HTTPS login uses `user_database` and opaque server-side cookie sessions; Basic authentication and its cache are removed in 8D.3. 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 roles retain the same shipped web status/serial UI. 8D.5 adds an admin-only backend without a normal UI entry. HTTPS login uses `user_database` and opaque server-side cookie sessions; Basic authentication and its cache are removed in 8D.3. No legacy credential is imported, synchronized or consulted for authentication. Both roles retain the same shipped web status/serial UI. 8D.5 adds an admin-only backend without a normal UI entry.
`web_cookie_auth` owns login/session/logout policy: four 120-second digest-only pre-login challenges, explicit same-origin bootstrap, five credential verifications per 60-second global window, and no live-record eviction. Host-only `__Host-` Secure/HttpOnly/SameSite=Strict cookies have absolute lifetimes. Login consumes a challenge, validates bounded JSON and issues a fresh session; logout invalidates only its originating session. Mutations require CSRF and strict canonical HTTPS Origin; serial/admin upgrades require matching cookie/Origin/ticket, with admin role additionally required by the admin endpoints. `web_cookie_auth` owns login/session/logout policy: four 120-second digest-only pre-login challenges, explicit same-origin bootstrap, five credential verifications per 60-second global window, and no live-record eviction. Host-only `__Host-` Secure/HttpOnly/SameSite=Strict cookies have absolute lifetimes. Login consumes a challenge, validates bounded JSON and issues a fresh session; logout invalidates only its originating session. Mutations require CSRF and strict canonical HTTPS Origin; serial/admin upgrades require matching cookie/Origin/ticket, with admin role additionally required by the admin endpoints.
@@ -152,19 +152,19 @@ 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; 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. 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 settings (8D.10) ## Typed Accounts settings (8D.10)
**8D.11 extension:** Authorized-key operations share the Accounts slot/dispatcher and canonical database key transactions with mutation-lock account ID/generation checks. A zero-wait per-account projection returns only key slot/type/SHA256 fingerprint. Independently optional admin/Origin/CSRF JSON POST `/api/settings/accounts/keys` reads this projection; existing operation POST admits bounded public-key text or key-delete/key-clear. Three stable slots may be sparse. Successful mutations target-revoke, including self; uncertain acknowledgement never triggers automatic replay. 24 handlers, unchanged socket/task/stack-size/queue budgets. Runtime stack margins remain pending. See `docs/phase8d11_implementation.md`; older no-8D.11 statements below are historical. **8D.11 extension:** Authorized-key operations share the Accounts slot/dispatcher and canonical database key transactions with mutation-lock account ID/generation checks. A zero-wait per-account projection returns only key slot/type/SHA256 fingerprint. Independently optional admin/Origin/CSRF JSON POST `/api/settings/accounts/keys` reads this projection; existing operation POST admits bounded public-key text or key-delete/key-clear. Three stable slots may be sparse. Successful mutations target-revoke, including self; uncertain acknowledgement never triggers automatic replay. 24 handlers, unchanged socket/task/stack-size/queue budgets. Runtime stack margins remain pending. See `docs/phase8d11_implementation.md`; older no-8D.11 statements below are historical.
Current slice 2 extends the same dispatcher slot to create/password and self role/delete/password. Mutation bodies are 768 bytes/four receives; results stay secret-free, 96 bytes, replaceable and session-bound. Conditional password mutation checks identity under the canonical database mutex. A one-second firmware-lifetime ESP timer cancels/wipes non-executing queued credentials at their 30-second deadline plus timer/scheduling latency; dequeue wipes shared inputs after copying, and dispatcher-local credentials persist until admitted work returns. This is not a hard execution/erasure deadline. Separate bodyless admin/Origin/CSRF POST `/api/settings/accounts/generate-password` returns a 24-character value before any commit, without retained retrieval. UI generation has a 60-second best-effort lifetime and context-bound saved acknowledgement before separate submission; JavaScript cannot securely wipe strings. Self revocation may prevent final response/result access; disconnect/401 proves neither success nor cancellation. Browser-shell restrictions and UART0-only bootstrap/recovery remain unchanged. The generated endpoint is independently optionally registered, with failure isolation/restart coverage and 23 handlers. Implementation is complete, host-tested/build-verified; target validation/signoff remains pending. Parent build: 25.61 s, 95,908 B RAM / 1,694,237 B flash; timer runtime costs and stack/heap margins remain unmeasured. No task/stack/queue depth/socket expansion or 8D.11 work. Current contracts and attributed host evidence: `docs/phase8d10_implementation.md`. Current slice 2 extends the same dispatcher slot to create/password and self role/delete/password. Mutation bodies are 768 bytes/four receives; results stay secret-free, 96 bytes, replaceable and session-bound. Conditional password mutation checks identity under the canonical database mutex. A one-second firmware-lifetime ESP timer cancels/wipes non-executing queued credentials at their 30-second deadline plus timer/scheduling latency; dequeue wipes shared inputs after copying, and dispatcher-local credentials persist until admitted work returns. This is not a hard execution/erasure deadline. Separate bodyless admin/Origin/CSRF POST `/api/settings/accounts/generate-password` returns a 24-character value before any commit, without retained retrieval. UI generation has a 60-second best-effort lifetime and context-bound saved acknowledgement before separate submission; JavaScript cannot securely wipe strings. Self revocation may prevent final response/result access; disconnect/401 proves neither success nor cancellation. Browser-shell restrictions and UART0 first-admin provisioning/recovery remain unchanged. The generated endpoint is independently optionally registered, with failure isolation/restart coverage and 23 handlers. Implementation is complete, host-tested/build-verified; target validation/signoff remains pending. Parent build: 25.61 s, 95,908 B RAM / 1,694,237 B flash; timer runtime costs and stack/heap margins remain unmeasured. No task/stack/queue depth/socket expansion or 8D.11 work. Current contracts and attributed host evidence: `docs/phase8d10_implementation.md`.
**Historical slice 1 architecture (superseded scope/counts, retained evidence):** **Historical slice 1 architecture (superseded scope/counts, retained evidence):**
`web_account_settings` supplies an optional admin-only compact account list and one session-bound role/delete operation/result slot, separate from Serial's slot but executed on the same dispatcher queue. HTTPD authorizes/parses/queues; the dispatcher revalidates the initiating login/admin and 30-second dequeue deadline, then calls conditional database mutations and best-effort target web/SSH revocation after success. `user_database_get_accounts()` copies at most eight username/role/ID/auth-generation records under the existing mutex with zero wait and no key/password fields. `user_database_delete_current()` and `user_database_set_role_current()` compare target identity under the mutation lock and share canonical CLI commit/invariant logic; stale selection never intentionally mutates a replacement account. Results are replaceable, not durable/idempotent, and already-admitted work can complete after logout. Self-target, create/password/generated-secret workflows remain unavailable in this slice; bootstrap/recovery remain UART0-only. Accounts UI confirms mutations, retains visible stale lists/outcomes during bounded auto-check/refresh and fences navigation/session changes without changing broker ownership. Three optional routes bring HTTPD handlers to 22; six sockets/no LRU and existing tasks/stacks/queue depth remain. See `docs/phase8d10_implementation.md` for limits and pending target checks. `web_account_settings` supplies an optional admin-only compact account list and one session-bound role/delete operation/result slot, separate from Serial's slot but executed on the same dispatcher queue. HTTPD authorizes/parses/queues; the dispatcher revalidates the initiating login/admin and 30-second dequeue deadline, then calls conditional database mutations and best-effort target web/SSH revocation after success. `user_database_get_accounts()` copies at most eight username/role/ID/auth-generation records under the existing mutex with zero wait and no key/password fields. `user_database_delete_current()` and `user_database_set_role_current()` compare target identity under the mutation lock and share canonical CLI commit/invariant logic; stale selection never intentionally mutates a replacement account. Results are replaceable, not durable/idempotent, and already-admitted work can complete after logout. Self-target, create/password/generated-secret workflows remain unavailable in this slice; first-admin provisioning/recovery remain UART0-only. Accounts UI confirms mutations, retains visible stale lists/outcomes during bounded auto-check/refresh and fences navigation/session changes without changing broker ownership. Three optional routes bring HTTPD handlers to 22; six sockets/no LRU and existing tasks/stacks/queue depth remain. See `docs/phase8d10_implementation.md` for limits and pending target checks.
## Typed Serial settings (8D.9) ## Typed Serial settings (8D.9)
@@ -235,3 +235,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. - 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. - 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. - 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: `docs/legacy_credential_removal.md`.
+3 -2
View File
@@ -65,6 +65,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}` - Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{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 limits and target checklist: `docs/https_idle_cleanup.md`. - 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 limits and target checklist: `docs/https_idle_cleanup.md`.
- Opt-in admission diagnostics: `src/web_diagnostics.{c,h}`, `tests/web_diagnostics/run.py`. Public synchronous HTTPS create/close callbacks publish six post-TLS connection records; four ticket/upgrade wrappers feed a 32-entry numeric ring. UART0/admin SSH `web diagnostics enable|disable|show|clear`; no queue/task/cleanup override or capacity change. Full bounds, SDK semantics and preaccept/TLS blind spots: `docs/phase8d11_implementation.md`. - Opt-in admission diagnostics: `src/web_diagnostics.{c,h}`, `tests/web_diagnostics/run.py`. Public synchronous HTTPS create/close callbacks publish six post-TLS connection records; four ticket/upgrade wrappers feed a 32-entry numeric ring. UART0/admin SSH `web diagnostics enable|disable|show|clear`; no queue/task/cleanup override or capacity change. Full bounds, SDK semantics and preaccept/TLS blind spots: `docs/phase8d11_implementation.md`.
- Legacy removal: `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}`. - 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}`.
- Asset files: authored/generated boundary in `src/web_assets_data.{h,c}`, `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.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 - Interfaces: web init/start/stop/snapshots; HTTP handlers; ticket mint/consume; attach/detach; targeted session revocation
@@ -118,11 +119,11 @@ 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. **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 - 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 - 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 - 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. - 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. - Constraint: final administrator cannot be deleted or demoted; transport principals must be rechecked after mutations.
## Administration console infrastructure ## Administration console infrastructure
+5 -1
View File
@@ -4,6 +4,10 @@ This file is working memory. Update it during active work and before handoff; do
## Development state ## Development state
- **Legacy credential removal (2026-09-08), implemented/host-tested/build-verified; target pending:** New database and TLS module contracts inspected. Missing user storage commits empty; normal UART0 `user add <username> admin [--generate]` provisions the first administrator. Unavailable-only UART0 recovery rebuilds empty and refuses healthy storage. Existing valid user v1 bytes stay unchanged; private derived `v1_admin_marker`, no public bootstrap/migration/sync API. HTTPS private v1 reader migrates `web_sec/material` 1392 → TLS-only v2 1340 bytes, retaining exact DER/fingerprint/generation, committing before publication; failures fail closed without fallback replacement. Legacy credential commands removed; generated user passwords and TLS rotation retained; reset is TLS-only. Older v1-only firmware cannot read v2; logical NVS replacement is not secure flash wiping, and no factory erase is required. Startup integration complete; SSH gate independent of HTTPS identity, obsolete completion/policy entries removed. Parent security15, canonical accounts, policy/startup64-gate combinations, console lifecycle, cookie accounts, idle18+guards and build/diff checks PASS. Integration agent reports broad auth/transport/lifecycle/UI regression PASS. Review no actionable findings; final agent-added sparse populated v1 reload/authorization regression PASS, test-only extension. Parent final production build 3.93 s, 99,260 B RAM / 1,705,781 B flash (56/3,200 vs idle-cleanup baseline). Host NVS doubles do not prove flash/power-loss behavior; no independently captured old-device TLS fixture or hardware validation. Record: `docs/legacy_credential_removal.md`.
- **Scoped user report — HTTPS idle cleanup worked (2026-09-08):** User accepted that the cleanup worked. This supersedes the no-target-acceptance wording for that specific behavior below, not the historical measurements. No additional soak duration, detailed checklist passes, resource reserve approval, broader 8D.11/M3 sign-off or legacy-removal target validation was supplied.
- **User-authorized bounded ordinary HTTPS idle cleanup complete (2026-09-08), host-tested/build-verified; target pending:** Preserved existing 8D.11 key/diagnostic implementation (initial Git status was clean). User capture: post-TLS occupancy 6/6, ordinary4/serial2/admin0, ordinary connection ages 5074 s; admin ticket returned in 14 ms, no TLS/upgrade observed and ticket unconsumed. Installed IDF stops selecting listener at capacity with LRU disabled. `web_httpd_idle.{c,h}` plus sole-private-boundary sweep in `web_httpd_adapter`: six owner-only rows, 15-second observed-idle window (three five-second status polls), one-second ESP timer/at most one queued owner probe, current fd shutdown (no reusable-pointer queued close), all-route SDK request-plus-purge completion marker and input readiness/WS/async checks, TLS-create fd reset, submit fence/nonwrapping restart generations. Diagnostics does not gate policy; socket/timeout/LRU/task/stack/asset settings unchanged. Implementation `pio run` PASS 57.55 s, final confirmation PASS 3.13 s; **99,316 B RAM / 1,708,981 B flash (+160/+1,384 versus diagnostic baseline)**. Rows144 B/module static167 B before placement; timer32 B internal heap before overhead; sweep local frame80 B, runtime margins unmeasured. Final idle **18 + SDK guards**, server **18**, diagnostics **12+1** PASS; cookie accounts/admin/settings/serial-settings, admin transport25/tickets12, store/serial, UI68+CSP and canonical console/account/key/lifecycle/policy suites PASS; diff check PASS. Cookie test-double missing `<stdint.h>` fixed and all variants rerun. Exact contracts/audit/tests/resources/target checklist: `docs/https_idle_cleanup.md`. Ordinary ages are connection ages, not proved idle duration. No hard wall-clock/admission guarantee: owner-blocking handshake/parser/send delays cleanup; continuously active ordinary sockets are not evicted. Reported queue errors retry, but accepted-and-lost nonblocking UDP work stays one reserved probe until successful stop/restart (tested), rather than accumulating unsafe delayed work. Failed stop remains detached/owned until retry. No hardware/upload/erase/commit, target acceptance or reserve approval; next is the documented target reproduction/soak, not capacity changes. - **User-authorized bounded ordinary HTTPS idle cleanup complete (2026-09-08), host-tested/build-verified; target pending:** Preserved existing 8D.11 key/diagnostic implementation (initial Git status was clean). User capture: post-TLS occupancy 6/6, ordinary4/serial2/admin0, ordinary connection ages 5074 s; admin ticket returned in 14 ms, no TLS/upgrade observed and ticket unconsumed. Installed IDF stops selecting listener at capacity with LRU disabled. `web_httpd_idle.{c,h}` plus sole-private-boundary sweep in `web_httpd_adapter`: six owner-only rows, 15-second observed-idle window (three five-second status polls), one-second ESP timer/at most one queued owner probe, current fd shutdown (no reusable-pointer queued close), all-route SDK request-plus-purge completion marker and input readiness/WS/async checks, TLS-create fd reset, submit fence/nonwrapping restart generations. Diagnostics does not gate policy; socket/timeout/LRU/task/stack/asset settings unchanged. Implementation `pio run` PASS 57.55 s, final confirmation PASS 3.13 s; **99,316 B RAM / 1,708,981 B flash (+160/+1,384 versus diagnostic baseline)**. Rows144 B/module static167 B before placement; timer32 B internal heap before overhead; sweep local frame80 B, runtime margins unmeasured. Final idle **18 + SDK guards**, server **18**, diagnostics **12+1** PASS; cookie accounts/admin/settings/serial-settings, admin transport25/tickets12, store/serial, UI68+CSP and canonical console/account/key/lifecycle/policy suites PASS; diff check PASS. Cookie test-double missing `<stdint.h>` fixed and all variants rerun. Exact contracts/audit/tests/resources/target checklist: `docs/https_idle_cleanup.md`. Ordinary ages are connection ages, not proved idle duration. No hard wall-clock/admission guarantee: owner-blocking handshake/parser/send delays cleanup; continuously active ordinary sockets are not evicted. Reported queue errors retry, but accepted-and-lost nonblocking UDP work stays one reserved probe until successful stop/restart (tested), rather than accumulating unsafe delayed work. Failed stop remains detached/owned until retry. No hardware/upload/erase/commit, target acceptance or reserve approval; next is the documented target reproduction/soak, not capacity changes.
- **Authorized 8D.11 admission diagnostic slice complete (2026-09-08), host-tested/build-verified; target pending:** `web_diagnostics.{c,h}` uses public synchronous HTTPS create/close callbacks and four ticket/upgrade wrappers, six fixed metadata slots and a 32-record opt-in numeric ring. Console `web diagnostics enable|disable|show|clear` via UART0/admin SSH; browser policy unchanged. No close/open override, queue probe/task, capacity/timeout/log-level/assets/SDK change. Post-TLS owner-published occupancy only; TLS failures/in-progress handshakes and preaccept backlog remain invisible. Final `pio run` PASS **10.30 s, 99,156 B RAM / 1,707,597 B flash**, **+3,080/+3,912 B** versus recorded key-slice baseline. Ring/table target symbols 2,816/192 B; diagnostic handler/record/TLS callback/show local frames 144/144/160/528 B excluding callees, runtime margins unmeasured. PASS diagnostic **12+1**, server lifecycle **16**, canonical console lifecycle/policy/boundary, canonical account/key transactions, cookie/admin and cookie/accounts, admin transport **25**/tickets **12**, browser **68 plus renderer/CSP**, store/serial and diff check. Exact files/commands/contracts and reproduction checklist in `docs/phase8d11_implementation.md`. Existing key work preserved; no device/commit/upload/erase, timeout diagnosis, target acceptance or reserve approval claimed. Next is user target capture, not capacity changes or 8D.12. - **Authorized 8D.11 admission diagnostic slice complete (2026-09-08), host-tested/build-verified; target pending:** `web_diagnostics.{c,h}` uses public synchronous HTTPS create/close callbacks and four ticket/upgrade wrappers, six fixed metadata slots and a 32-record opt-in numeric ring. Console `web diagnostics enable|disable|show|clear` via UART0/admin SSH; browser policy unchanged. No close/open override, queue probe/task, capacity/timeout/log-level/assets/SDK change. Post-TLS owner-published occupancy only; TLS failures/in-progress handshakes and preaccept backlog remain invisible. Final `pio run` PASS **10.30 s, 99,156 B RAM / 1,707,597 B flash**, **+3,080/+3,912 B** versus recorded key-slice baseline. Ring/table target symbols 2,816/192 B; diagnostic handler/record/TLS callback/show local frames 144/144/160/528 B excluding callees, runtime margins unmeasured. PASS diagnostic **12+1**, server lifecycle **16**, canonical console lifecycle/policy/boundary, canonical account/key transactions, cookie/admin and cookie/accounts, admin transport **25**/tickets **12**, browser **68 plus renderer/CSP**, store/serial and diff check. Exact files/commands/contracts and reproduction checklist in `docs/phase8d11_implementation.md`. Existing key work preserved; no device/commit/upload/erase, timeout diagnosis, target acceptance or reserve approval claimed. Next is user target capture, not capacity changes or 8D.12.
@@ -127,7 +131,7 @@ Based on checked-in source plus `README.md` and `docs/roadmap.md`:
These observations should be checked when touching the relevant area; they are not automatically bugs requiring unrelated cleanup. 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. - 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. - Legacy-removal module contracts now make `USER_DATABASE_LOAD_EMPTY` a successful persisted-empty initialization result; older migration/sentinel observations are superseded. Final startup integration/build evidence is pending.
- 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. - 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 ## Items to verify in future work
+5 -5
View File
@@ -46,11 +46,11 @@ Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs
## UART0 is the physical recovery authority ## 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. **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` **Relevant files:** `src/main.c`, `src/admin_ssh_console.c`, `src/user_console.c`, `docs/roadmap.md`
@@ -140,17 +140,17 @@ The following first-slice exclusions are historical and superseded by slice 2:
**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. **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 `docs/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` **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 ## 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. **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` **Relevant files:** `partitions.csv`, `README.md`, `src/web_security.c`, `src/ssh_security.c`, `src/wifi_config.c`
+11 -13
View File
@@ -1,8 +1,8 @@
# Command reference # 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 temporary 8D.7 restrictions. It now supports deferred **`reboot`** and **`web stop`** in addition to admin-only `exit`. The control task waits up to ten seconds for application output drain plus 200 ms; acknowledgement delivery is best effort, not confirmed receipt. Input observed during deferral is discarded. `web stop` closes both browser routes; restart HTTPS via UART0/admin SSH `web start` and sign in again. Reboot affects all transports and loses RAM-only changes. The second slice also permits exact parsed **`web certificate rotate --force`** (equivalent quoting accepted, no missing force or extra arguments). After drain/delay it queues a typed action on the existing dispatcher; pending input remains discarded until completion. This does not bound dispatcher wait or certificate/NVS/lifecycle execution time. Certificate commit precedes HTTPS stop/start: early generation/persistence errors skip restart, stop failure retains ownership and skips start, and later lifecycle failure does not undo the new persisted identity. Success closes both browser routes and any web writer lease; verify/trust the new certificate through a trusted channel and sign in again with unchanged account credentials. USB/UART0/SSH remain usable; use UART0/admin SSH for stop/start recovery if needed. Other `web` forms besides `web status`, `web stop` and this exact rotation remain blocked (including certificate info, credential display/rotation and reset). User mutations, Wi-Fi/mDNS mutations and restricted SSH lifecycle commands remain unavailable from browser admin. See `phase8d7_implementation.md` for the exact remaining policy and target-validation status. Browser-admin uses the same dispatcher with temporary 8D.7 restrictions. It now supports deferred **`reboot`** and **`web stop`** in addition to admin-only `exit`. The control task waits up to ten seconds for application output drain plus 200 ms; acknowledgement delivery is best effort, not confirmed receipt. Input observed during deferral is discarded. `web stop` closes both browser routes; restart HTTPS via UART0/admin SSH `web start` and sign in again. Reboot affects all transports and loses RAM-only changes. The second slice also permits exact parsed **`web certificate rotate --force`** (equivalent quoting accepted, no missing force or extra arguments). After drain/delay it queues a typed action on the existing dispatcher; pending input remains discarded until completion. This does not bound dispatcher wait or certificate/NVS/lifecycle execution time. Certificate commit precedes HTTPS stop/start: early generation/persistence errors skip restart, stop failure retains ownership and skips start, and later lifecycle failure does not undo the new persisted identity. Success closes both browser routes and any web writer lease; verify/trust the new certificate through a trusted channel and sign in again with unchanged account credentials. USB/UART0/SSH remain usable; use UART0/admin SSH for stop/start recovery if needed. Other `web` forms besides `web status`, `web stop` and this exact rotation remain blocked (including certificate info and reset; legacy credential commands are removed). User mutations, Wi-Fi/mDNS mutations and restricted SSH lifecycle commands remain unavailable from browser admin. See `phase8d7_implementation.md` for the exact remaining policy and target-validation status.
## System ## System
@@ -16,13 +16,11 @@ Browser-admin uses the same dispatcher with temporary 8D.7 restrictions. It now
| Command | Description | | 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 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>` | 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 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 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>` | Set and confirm a new password without echo. |
| `user password <username> --generate` | Replace a password with a generated value displayed once. | | `user password <username> --generate` | Replace a password with a generated value displayed once. |
@@ -30,13 +28,13 @@ Browser-admin uses the same dispatcher with temporary 8D.7 restrictions. It now
| `user key add <username> <type> <base64>` | Import a key non-interactively; intended for authenticated admin SSH and also accepted on UART0. | | `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 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 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. 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 ## Local display
@@ -129,11 +127,9 @@ When the Wi-Fi station receives an IPv4 address, the Wi-Fi manager announces `sa
| `web status` | Show HTTPS and WebSocket state. | | `web status` | Show HTTPS and WebSocket state. |
| `web start` / `web stop` | Start or stop HTTPS service. | | `web start` / `web stop` | Start or stop HTTPS service. |
| `web counters` / `web clear-counters` | Show or clear web counters. | | `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 certificate info` | Display certificate identity and fingerprint. | | `web certificate info` | Display certificate identity and fingerprint. |
| `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 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 missing, incompatible, or damaged legacy credentials and web material. | | `web reset --force` | Explicitly replace HTTPS certificate/private key only, including missing, incompatible or damaged material; never changes users. |
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. 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.
@@ -157,7 +153,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. 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 ## Hardware diagnostics
@@ -184,3 +180,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. | | `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. 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).
+1 -1
View File
@@ -1,6 +1,6 @@
# Bounded ordinary HTTPS idle retention # Bounded ordinary HTTPS idle retention
Implemented 2026-09-08 on the existing 8D.11 key/diagnostic implementation. Host-tested and firmware-build verified; **not target validated or signed off**. No upload, erase, commit, SDK patch or generated-asset change. Implemented 2026-09-08 on the existing 8D.11 key/diagnostic implementation. Host-tested and firmware-build verified. **Subsequent scoped user report:** the user accepted that idle cleanup worked. This supersedes the original target-pending status for that behavior only; no additional soak duration, individual checklist passes, memory reserve approval or broader 8D.11/M3 sign-off is inferred. The implementation evidence below remains historical and unchanged. No agent upload, erase, commit, SDK patch or generated-asset change.
## Evidence and scope ## Evidence and scope
+39
View File
@@ -0,0 +1,39 @@
# Legacy credential removal
## Scope and status — 2026-09-08
Agreed removal of the obsolete shared web credential, user bootstrap/migration/synchronization paths, and related public APIs/commands. User-generated passwords and HTTPS certificate rotation remain supported. Implementation and integration are complete, host-tested and build-verified; target validation remains pending. No device operation, factory erase, commit or asset generation was performed.
Separately, the user reported and accepted that ordinary HTTPS idle cleanup worked. This is a scoped functional report, not an invented soak, individual checklist execution, memory reserve approval, broader 8D.11/M3 sign-off or target validation of this credential removal. Prior measurements remain in [the idle-cleanup record](https_idle_cleanup.md).
## 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.
## Evidence and handoff
- Parent reran successfully: `python3 tests/web_security/run.py` (**15 production groups**, real installed host mbedTLS plus public-surface checks), `tests/admin_console_boundary/accounts.py`, `tests/admin_ssh_policy/run.py`, `tests/admin_console_boundary/lifecycle.py`, `tests/web_cookie_auth/run.py --accounts`, and `tests/web_httpd_idle/run.py` (**18 + SDK guards**).
- Integration agent additionally reports PASS for console boundary, auth parser, cookie default/admin/settings/serial-settings, session-store/default/serial, admin transport/tickets, server lifecycle, diagnostics, login UI and browser UI (68 + renderer/CSP). These are attributed agent runs, not additional parent reruns.
- Startup now uses credential-free initialization and explicit TLS migration-source logging; SSH no longer depends on HTTPS identity readiness. Policy tests cover 64 service-gate combinations and RNG failure. Removed command/completion entries are tested; browser restrictions and UART0-only recovery remain.
- Independent review found no actionable regression. A subsequent agent test extension verifies populated sparse key slots 0/2 across persisted v1 reload, unchanged entire records/no writes, fingerprint snapshots, retained-key authorization and rejection of the removed key; canonical accounts tests and scoped diff check passed. This final extension changes tests only.
- Parent `pio run` **PASS, 3.93 s**, **99,260 B RAM / 1,705,781 B flash**, **56 B RAM / 3,200 B flash** versus the recorded idle-cleanup baseline. Parent `git diff --check` passed before final test/documentation additions.
- Host NVS doubles do not prove actual flash durability or power-loss behavior; TLS v1 fixture uses current-generated identity wrapped in the historical layout rather than an independently captured old-device blob. Device upgrade, persistence, failure-injection, frontend command removal and TLS-reset isolation checks remain manual validation procedures, not claimed passes; see [user administration tests](user_administration_tests.md).
- Earlier phase records retain their historical evidence and procedures. Their legacy credential/bootstrap/reconciliation instructions are superseded by this record and the current command reference, not rewritten as if the old behavior never existed.
+14
View File
@@ -2,6 +2,20 @@
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. 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.
## 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.
Evidence: implementing agents report security 15 groups with real installed mbedTLS and account tests PASS; final integration/build and this target checklist are pending. The user's report that idle cleanup worked is separate, with no additional soak or broad sign-off inferred.
## Historical Phase 8A baseline — role-based database and UART0 administration ## Historical Phase 8A baseline — role-based database and UART0 administration
Keep UART0 physically attached throughout these tests. At the Phase 8A baseline, HTTPS and SSH still used the legacy shared credential while the new database and public keys were prepared for the Phase 8B cutover. Keep UART0 physically attached throughout these tests. At the Phase 8A baseline, HTTPS and SSH still used the legacy shared credential while the new database and public keys were prepared for the Phase 8B cutover.
+2 -2
View File
@@ -538,7 +538,7 @@ static bool remote_command_allowed(const admin_request_t *request)
/* Empty input is handled quietly by esp_console_run(), not UART0 policy. */ /* Empty input is handled quietly by esp_console_run(), not UART0 policy. */
bool allowed = true; bool allowed = true;
if (argc >= 2U && strcmp(argv[0], "user") == 0 && if (argc >= 2U && strcmp(argv[0], "user") == 0 &&
(strcmp(argv[1], "bootstrap") == 0 || strcmp(argv[1], "recover") == 0)) { strcmp(argv[1], "recover") == 0) {
allowed = false; allowed = false;
} }
/* Temporary browser policy until lifecycle acknowledgements/revocation are /* Temporary browser policy until lifecycle acknowledgements/revocation are
@@ -716,7 +716,7 @@ static void worker_task(void *context)
} else if (active) { } else if (active) {
(void)worker_write(&request.token, (void)worker_write(&request.token,
request.token.transport == ADMIN_CONSOLE_TRANSPORT_WEB request.token.transport == ADMIN_CONSOLE_TRANSPORT_WEB
? "Command is unavailable from the web console; use UART0 or SSH where permitted. Bootstrap/recovery require UART0.\r\n" ? "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"); : "Command is restricted to physical UART0.\r\n");
} }
current = session_is_current(&request.token, &request.principal); current = session_is_current(&request.token, &request.principal);
-6
View File
@@ -126,8 +126,6 @@ static const char *const s_completion_candidates[] = {
"user status", "user status",
"user list", "user list",
"user show", "user show",
"user bootstrap",
"user bootstrap --generate",
"user recover --force", "user recover --force",
"user add", "user add",
"user delete", "user delete",
@@ -188,10 +186,6 @@ static const char *const s_completion_candidates[] = {
"web diagnostics disable", "web diagnostics disable",
"web diagnostics show", "web diagnostics show",
"web diagnostics clear", "web diagnostics clear",
"web credentials",
"web credentials show",
"web credentials rotate",
"web credentials rotate --force",
"web certificate", "web certificate",
"web certificate info", "web certificate info",
"web certificate rotate", "web certificate rotate",
+10 -27
View File
@@ -1,5 +1,3 @@
#include <string.h>
#include "driver/uart.h" #include "driver/uart.h"
#include "admin_ssh_console.h" #include "admin_ssh_console.h"
#include "console_completion.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", "HTTPS security material unavailable (%s); use UART0 'web reset --force' to replace it",
esp_err_to_name(web_security_error)); esp_err_to_name(web_security_error));
} else { } else {
ESP_LOGI(TAG, "Using %s HTTPS identity and legacy recovery credential", ESP_LOGI(TAG, "Using %s HTTPS identity",
web_security_source == WEB_SECURITY_LOAD_STORED ? "stored" : "newly generated"); 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; user_database_load_result_t user_database_source = USER_DATABASE_LOAD_EMPTY;
web_security_credentials_t legacy_credentials; esp_err_t user_database_error = user_database_init(&user_database_source);
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));
if (user_database_error != ESP_OK) { 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_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)); esp_err_to_name(user_database_error));
@@ -162,9 +147,7 @@ void app_main(void)
ESP_LOGI(TAG, "Using %s user database", ESP_LOGI(TAG, "Using %s user database",
user_database_source == USER_DATABASE_LOAD_STORED user_database_source == USER_DATABASE_LOAD_STORED
? "stored" ? "stored"
: (user_database_source == USER_DATABASE_LOAD_MIGRATED_LEGACY : "new empty");
? "newly migrated user-level"
: "new empty"));
} }
esp_err_t web_runtime_error = web_server_init(); 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"); ESP_LOGI(TAG, "Authenticated HTTPS listening on TCP port 443");
} }
} }
if (wifi_error == ESP_OK && web_security_error == ESP_OK && if (wifi_error == ESP_OK && ssh_security_error == ESP_OK &&
ssh_security_error == ESP_OK && ssh_runtime_error == ESP_OK) { ssh_runtime_error == ESP_OK) {
esp_err_t start_error = ssh_transport_start(); esp_err_t start_error = ssh_transport_start();
if (start_error != ESP_OK) { if (start_error != ESP_OK) {
ESP_LOGE(TAG, "SSH startup failed: %s; UART0 recovery remains available", ESP_LOGE(TAG, "SSH startup failed: %s; UART0 recovery remains available",
+8 -62
View File
@@ -15,7 +15,6 @@
#include "secure_random.h" #include "secure_random.h"
#include "ssh_transport.h" #include "ssh_transport.h"
#include "user_database.h" #include "user_database.h"
#include "web_security.h"
#include "web_serial_transport.h" #include "web_serial_transport.h"
#define USER_CONSOLE_KEY_LINE_CAPACITY 256U #define USER_CONSOLE_KEY_LINE_CAPACITY 256U
@@ -28,7 +27,6 @@ static void print_usage(void)
printf("Usage:\n"); printf("Usage:\n");
printf(" user status|list\n"); printf(" user status|list\n");
printf(" user show <username>\n"); printf(" user show <username>\n");
printf(" user bootstrap [--generate]\n");
printf(" user recover --force\n"); printf(" user recover --force\n");
printf(" user add <username> <user|admin> [--generate]\n"); printf(" user add <username> <user|admin> [--generate]\n");
printf(" user delete <username> --force\n"); printf(" user delete <username> --force\n");
@@ -100,12 +98,11 @@ static int show_users(const char *selected)
return 1; return 1;
} }
if (selected == NULL) { 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 long)s_user_snapshot.generation,
(unsigned int)s_user_snapshot.user_count, (unsigned int)s_user_snapshot.user_count,
USER_DATABASE_MAX_USERS, USER_DATABASE_MAX_USERS,
(unsigned int)s_user_snapshot.admin_count, (unsigned int)s_user_snapshot.admin_count);
s_user_snapshot.admin_bootstrapped ? "yes" : "no");
} }
bool found = false; bool found = false;
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) { 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); printf("User '%s' not found.\n", selected);
return 1; return 1;
} }
if (!s_user_snapshot.admin_bootstrapped) { if (s_user_snapshot.admin_count == 0U) {
printf("Administrative network access is not bootstrapped; use 'user bootstrap'.\n"); printf("No administrators; use 'user add <username> admin' on UART0.\n");
} }
return 0; return 0;
} }
@@ -170,52 +167,13 @@ static void show_generated_password(const char *username,
static int recover_database(void) static int recover_database(void)
{ {
web_security_credentials_t credentials; esp_err_t error = user_database_recover_empty();
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));
if (error != ESP_OK) { if (error != ESP_OK) {
printf("Could not recover user database: %s\n", esp_err_to_name(error)); printf("Could not recover user database: %s\n", esp_err_to_name(error));
return 1; return 1;
} }
printf("User database replaced from the current legacy network credential.\n"); printf("User database rebuilt empty; no credentials imported.\n");
printf("The imported account has role user; run 'user bootstrap' to establish an administrator.\n"); printf("Use 'user add <username> admin' on UART0 to create an administrator.\n");
return 0;
}
static int bootstrap(bool generated)
{
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 (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 0;
} }
@@ -422,18 +380,6 @@ static int command_user_inner(int argc, char **argv)
} }
return recover_database(); 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) { if ((argc == 4 || argc == 5) && strcmp(argv[1], "add") == 0) {
bool generated = argc == 5 && strcmp(argv[4], "--generate") == 0; bool generated = argc == 5 && strcmp(argv[4], "--generate") == 0;
if (argc == 5 && !generated) { if (argc == 5 && !generated) {
@@ -449,7 +395,7 @@ static int command_user_inner(int argc, char **argv)
error = user_database_delete((const uint8_t *)argv[2], strlen(argv[2])); error = user_database_delete((const uint8_t *)argv[2], strlen(argv[2]));
} }
if (error != ESP_OK) { 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)); esp_err_to_name(error));
return 1; return 1;
} }
+9 -160
View File
@@ -25,7 +25,6 @@
static const uint8_t s_generated_alphabet[] = static const uint8_t s_generated_alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
static const uint8_t s_admin_username[] = "admin";
static const uint8_t s_ed25519_type[] = "ssh-ed25519"; 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_type[] = "ecdsa-sha2-nistp256";
static const uint8_t s_ecdsa_curve[] = "nistp256"; static const uint8_t s_ecdsa_curve[] = "nistp256";
@@ -58,7 +57,8 @@ typedef struct {
uint32_t version; uint32_t version;
uint32_t size; uint32_t size;
uint32_t generation; 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 user_count;
uint8_t admin_count; uint8_t admin_count;
uint8_t reserved; uint8_t reserved;
@@ -351,7 +351,7 @@ static esp_err_t validate_database(const stored_database_t *database)
{ {
if (database->version != USER_DATABASE_SCHEMA_VERSION || if (database->version != USER_DATABASE_SCHEMA_VERSION ||
database->size != sizeof(*database) || database->generation == 0U || 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; return ESP_ERR_INVALID_VERSION;
} }
uint8_t users = 0U; 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 || 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_ERR_INVALID_RESPONSE;
} }
return ESP_OK; 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) 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; return error;
} }
static bool legacy_credentials_valid(const user_database_legacy_credentials_t *legacy) esp_err_t user_database_init(user_database_load_result_t *load_result)
{
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)
{ {
if (load_result == NULL || s_mutex != NULL) { if (load_result == NULL || s_mutex != NULL) {
return ESP_ERR_INVALID_ARG; 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) { if (error == ESP_OK && !storage_missing) {
error = validate_database(&s_database); 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) { if (error == ESP_OK && !storage_missing) {
error = initialize_dummy_verifier(); error = initialize_dummy_verifier();
if (error == ESP_OK) { if (error == ESP_OK) {
@@ -630,7 +576,7 @@ esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
goto init_failed; goto init_failed;
} }
if (!storage_missing || !legacy_credentials_valid(legacy)) { if (!storage_missing) {
error = ESP_ERR_INVALID_STATE; error = ESP_ERR_INVALID_STATE;
goto init_failed; 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.version = USER_DATABASE_SCHEMA_VERSION;
s_database.size = sizeof(s_database); s_database.size = sizeof(s_database);
s_database.generation = 1U; 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); recount(&s_database);
*s_candidate = s_database; *s_candidate = s_database;
error = commit_candidate_locked(); error = commit_candidate_locked();
@@ -669,28 +608,8 @@ init_failed:
return error; return error;
} }
esp_err_t user_database_sync_legacy_credentials( esp_err_t user_database_recover_empty(void)
const user_database_legacy_credentials_t *legacy, bool *synchronized)
{ {
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) { if (s_initialized || s_mutex != NULL) {
return ESP_ERR_INVALID_STATE; 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->version = USER_DATABASE_SCHEMA_VERSION;
s_candidate->size = sizeof(*s_candidate); s_candidate->size = sizeof(*s_candidate);
s_candidate->generation = 1U; 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(); error = commit_candidate_locked();
} else {
discard_candidate();
}
if (error == ESP_OK) { if (error == ESP_OK) {
error = initialize_dummy_verifier(); 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)); memset(snapshot, 0, sizeof(*snapshot));
xSemaphoreTake(s_mutex, portMAX_DELAY); xSemaphoreTake(s_mutex, portMAX_DELAY);
snapshot->initialized = true; snapshot->initialized = true;
snapshot->admin_bootstrapped = s_database.admin_bootstrapped != 0U;
snapshot->generation = s_database.generation; snapshot->generation = s_database.generation;
snapshot->user_count = s_database.user_count; snapshot->user_count = s_database.user_count;
snapshot->admin_count = s_database.admin_count; snapshot->admin_count = s_database.admin_count;
@@ -972,9 +883,6 @@ static esp_err_t create_locked(const uint8_t *username, size_t username_length,
*s_candidate = s_database; *s_candidate = s_database;
esp_err_t error = initialize_user(&s_candidate->users[free_index], username, esp_err_t error = initialize_user(&s_candidate->users[free_index], username,
username_length, role, password, password_length); username_length, role, password, password_length);
if (error == ESP_OK && role == USER_ROLE_ADMIN) {
s_candidate->admin_bootstrapped = 1U;
}
if (error == ESP_OK) { if (error == ESP_OK) {
return commit_candidate_locked(); return commit_candidate_locked();
} }
@@ -1017,56 +925,6 @@ esp_err_t user_database_create_generated(
return error; 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 = user_database_generate_password_value(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, static esp_err_t mutate_user_begin(const uint8_t *username, size_t username_length,
int *index) int *index)
{ {
@@ -1141,13 +999,7 @@ static esp_err_t delete_user(const uint8_t *username, size_t username_length,
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND; ? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
if (error == ESP_OK) { if (error == ESP_OK) {
const stored_user_t *user = &s_database.users[index]; const stored_user_t *user = &s_database.users[index];
bool protected_migrated_admin = if (user->role == USER_ROLE_ADMIN && s_database.admin_count <= 1U) {
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)) {
error = ESP_ERR_INVALID_STATE; error = ESP_ERR_INVALID_STATE;
discard_candidate(); discard_candidate();
} else { } else {
@@ -1181,9 +1033,6 @@ static esp_err_t set_role(const uint8_t *username, size_t username_length,
} else { } else {
user->role = (uint8_t)role; user->role = (uint8_t)role;
error = next_generation(&user->auth_generation); error = next_generation(&user->auth_generation);
if (error == ESP_OK && role == USER_ROLE_ADMIN) {
s_candidate->admin_bootstrapped = 1U;
}
if (error == ESP_OK) { if (error == ESP_OK) {
error = commit_candidate_locked(); error = commit_candidate_locked();
} else { } else {
+6 -25
View File
@@ -38,17 +38,9 @@ typedef enum {
typedef enum { typedef enum {
USER_DATABASE_LOAD_STORED = 0, USER_DATABASE_LOAD_STORED = 0,
USER_DATABASE_LOAD_MIGRATED_LEGACY,
USER_DATABASE_LOAD_EMPTY, USER_DATABASE_LOAD_EMPTY,
} user_database_load_result_t; } 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 { typedef struct {
uint32_t user_id; uint32_t user_id;
uint32_t auth_generation; uint32_t auth_generation;
@@ -84,25 +76,18 @@ typedef struct {
typedef struct { typedef struct {
bool initialized; bool initialized;
bool admin_bootstrapped;
uint32_t generation; uint32_t generation;
uint8_t user_count; uint8_t user_count;
uint8_t admin_count; uint8_t admin_count;
user_database_user_snapshot_t users[USER_DATABASE_MAX_USERS]; user_database_user_snapshot_t users[USER_DATABASE_MAX_USERS];
} user_database_snapshot_t; } user_database_snapshot_t;
esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy, /* Missing storage is persisted empty; valid v1 records load unchanged.
user_database_load_result_t *load_result); * Corrupt/unsupported storage fails closed and is never automatically replaced. */
/* esp_err_t user_database_init(user_database_load_result_t *load_result);
* Before the first administrator is established, keep the migrated account in /* Explicit UART0 recovery only; refuses an initialized database. No credentials
* sync with the legacy recovery credential. Once bootstrapped, that credential * are imported or created. Caller enforces physical-console authorization. */
* remains independent and no longer authenticates Phase 8B network services. esp_err_t user_database_recover_empty(void);
*/
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);
esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot); esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot);
/* Compact secret-free list, zero-wait mutex acquisition; no key material. */ /* Compact secret-free list, zero-wait mutex acquisition; no key material. */
@@ -149,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, esp_err_t user_database_principal_is_current(const user_principal_t *principal,
bool *current); 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, esp_err_t user_database_create(const uint8_t *username, size_t username_length,
user_role_t role, user_role_t role,
const uint8_t *password, size_t password_length); const uint8_t *password, size_t password_length);
+6 -101
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */ /* 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 "web_console.h"
#include "admin_ssh_console.h" #include "admin_ssh_console.h"
@@ -9,8 +9,6 @@
#include <string.h> #include <string.h>
#include "esp_console.h" #include "esp_console.h"
#include "secure_random.h"
#include "ssh_transport.h"
#include "user_database.h" #include "user_database.h"
#include "web_security.h" #include "web_security.h"
#include "web_serial_transport.h" #include "web_serial_transport.h"
@@ -26,11 +24,9 @@ static void print_usage(void)
printf(" web status|start|stop\n"); printf(" web status|start|stop\n");
printf(" web counters|clear-counters\n"); printf(" web counters|clear-counters\n");
printf(" web diagnostics enable|disable|show|clear\n"); printf(" web diagnostics enable|disable|show|clear\n");
printf(" web credentials show\n");
printf(" web credentials rotate --force\n");
printf(" web certificate info\n"); printf(" web certificate info\n");
printf(" web certificate rotate --force\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]) static void print_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH])
@@ -207,25 +203,6 @@ static int show_counters(void)
return 0; 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) static int show_certificate(void)
{ {
web_security_certificate_metadata_t metadata; web_security_certificate_metadata_t metadata;
@@ -276,60 +253,6 @@ static int restart_if_running(bool was_running)
return 0; 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;
}
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 int rotate_credentials(void)
{
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));
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) static int rotate_certificate(void)
{ {
web_server_snapshot_t snapshot; web_server_snapshot_t snapshot;
@@ -351,19 +274,13 @@ static int reset_material(void)
{ {
web_server_snapshot_t snapshot; web_server_snapshot_t snapshot;
bool was_running = web_server_get_snapshot(&snapshot) == ESP_OK && snapshot.running; 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();
esp_err_t error = web_security_reset_all(&credentials);
if (error != ESP_OK) { if (error != ESP_OK) {
printf("Could not reset web security material: %s\n", esp_err_to_name(error)); printf("Could not reset web security material: %s\n", esp_err_to_name(error));
return 1; return 1;
} }
synchronize_migrated_user(&credentials); printf("HTTPS certificate and private key replaced and persisted; user accounts unchanged.\n");
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) { if (was_running) {
return restart_if_running(true); return restart_if_running(true);
} }
@@ -434,18 +351,6 @@ static int command_web(int argc, char **argv)
printf("HTTPS and WebSocket counters cleared.\n"); printf("HTTPS and WebSocket counters cleared.\n");
return 0; 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 && if (argc == 3 && strcmp(argv[1], "certificate") == 0 &&
strcmp(argv[2], "info") == 0) { strcmp(argv[2], "info") == 0) {
return show_certificate(); return show_certificate();
@@ -470,7 +375,7 @@ static int command_web(int argc, char **argv)
} }
if (strcmp(argv[1], "reset") == 0) { if (strcmp(argv[1], "reset") == 0) {
if (!force_is_present(argc, argv, 3)) { 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 1;
} }
return reset_material(); return reset_material();
@@ -484,7 +389,7 @@ esp_err_t web_console_register_commands(void)
{ {
const esp_console_cmd_t command = { const esp_console_cmd_t command = {
.command = "web", .command = "web",
.help = "Manage authenticated HTTPS and recover web credentials/certificate", .help = "Manage authenticated HTTPS and recover TLS certificate/private key",
.hint = NULL, .hint = NULL,
.func = &command_web, .func = &command_web,
.argtable = NULL, .argtable = NULL,
+93 -128
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */ /* 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" #include "web_security.h"
@@ -20,12 +20,10 @@
#include "nvs.h" #include "nvs.h"
#include "secure_random.h" #include "secure_random.h"
#define WEB_SECURITY_SCHEMA_VERSION 1U #define WEB_SECURITY_SCHEMA_VERSION 2U
#define WEB_SECURITY_BLOB_SIZE 1392U #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}; static const uint8_t s_ap_ipv4_address[4] = {192U, 168U, 4U, 1U};
typedef struct { typedef struct {
@@ -33,25 +31,20 @@ typedef struct {
uint16_t blob_size; uint16_t blob_size;
uint16_t reserved_header; uint16_t reserved_header;
uint32_t generation; uint32_t generation;
uint8_t username_length;
uint8_t password_length;
uint16_t private_key_length; uint16_t private_key_length;
uint16_t certificate_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 private_key_der[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY];
uint8_t certificate_der[WEB_SECURITY_CERTIFICATE_DER_CAPACITY]; uint8_t certificate_der[WEB_SECURITY_CERTIFICATE_DER_CAPACITY];
uint8_t certificate_fingerprint[WEB_SECURITY_SHA256_LENGTH]; uint8_t certificate_fingerprint[WEB_SECURITY_SHA256_LENGTH];
uint8_t reserved[12]; uint8_t reserved[12];
} web_security_blob_t; } web_security_blob_t;
_Static_assert(offsetof(web_security_blob_t, username) == 20U, _Static_assert(offsetof(web_security_blob_t, private_key_der) == 16U,
"web security schema offsets changed");
_Static_assert(offsetof(web_security_blob_t, private_key_der) == 68U,
"web security key offset changed"); "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"); "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, _Static_assert(sizeof(web_security_blob_t) == WEB_SECURITY_BLOB_SIZE,
"web security schema size changed"); "web security schema size changed");
@@ -116,28 +109,6 @@ static esp_err_t build_device_names(char *common_name, size_t common_name_size,
return ESP_OK; 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, static esp_err_t normalize_der(unsigned char *buffer, size_t capacity,
int written, uint16_t *output_length) int written, uint16_t *output_length)
{ {
@@ -396,6 +367,16 @@ cleanup:
return valid; 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) static esp_err_t validate_certificate_and_key(const web_security_blob_t *blob)
{ {
char common_name[WEB_SECURITY_COMMON_NAME_CAPACITY] = {0}; char common_name[WEB_SECURITY_COMMON_NAME_CAPACITY] = {0};
@@ -419,6 +400,10 @@ static esp_err_t validate_certificate_and_key(const web_security_blob_t *blob)
sizeof(fingerprint))) { sizeof(fingerprint))) {
goto cleanup; 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, if (mbedtls_pk_parse_key(&private_key,
blob->private_key_der, blob->private_key_length, blob->private_key_der, blob->private_key_length,
NULL, 0U, secure_random_mbedtls, NULL) != 0 || NULL, 0U, secure_random_mbedtls, NULL) != 0 ||
@@ -492,16 +477,7 @@ static esp_err_t validate_blob(const web_security_blob_t *blob)
return ESP_ERR_INVALID_VERSION; return ESP_ERR_INVALID_VERSION;
} }
if (blob->generation == 0U || blob->reserved_header != 0U || if (blob->generation == 0U || blob->reserved_header != 0U ||
blob->reserved_lengths != 0U ||
!bytes_are_zero(blob->reserved, sizeof(blob->reserved)) || !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 == 0U ||
blob->private_key_length > sizeof(blob->private_key_der) || blob->private_key_length > sizeof(blob->private_key_der) ||
!unused_bytes_are_zero(blob->private_key_der, blob->private_key_length, !unused_bytes_are_zero(blob->private_key_der, blob->private_key_length,
@@ -513,16 +489,6 @@ static esp_err_t validate_blob(const web_security_blob_t *blob)
return ESP_ERR_INVALID_RESPONSE; 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); return validate_certificate_and_key(blob);
} }
@@ -533,10 +499,7 @@ static esp_err_t generate_all(web_security_blob_t *blob, uint32_t generation)
blob->blob_size = WEB_SECURITY_BLOB_SIZE; blob->blob_size = WEB_SECURITY_BLOB_SIZE;
blob->generation = generation; blob->generation = generation;
esp_err_t error = generate_credentials(blob); esp_err_t error = generate_certificate(blob);
if (error == ESP_OK) {
error = generate_certificate(blob);
}
if (error == ESP_OK) { if (error == ESP_OK) {
error = validate_blob(blob); error = validate_blob(blob);
} }
@@ -556,7 +519,7 @@ static esp_err_t save_blob(const web_security_blob_t *blob)
return error; 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, error = nvs_set_blob(handle, WEB_SECURITY_NVS_BLOB_KEY,
blob, sizeof(*blob)); blob, sizeof(*blob));
if (error == ESP_OK) { if (error == ESP_OK) {
@@ -566,9 +529,57 @@ static esp_err_t save_blob(const web_security_blob_t *blob)
return error; 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; *missing = false;
*migrated = false;
nvs_handle_t handle; nvs_handle_t handle;
esp_err_t error = nvs_open(WEB_SECURITY_NVS_NAMESPACE, NVS_READONLY, &handle); esp_err_t error = nvs_open(WEB_SECURITY_NVS_NAMESPACE, NVS_READONLY, &handle);
if (error == ESP_ERR_NVS_NOT_FOUND) { if (error == ESP_ERR_NVS_NOT_FOUND) {
@@ -594,6 +605,21 @@ static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing)
nvs_close(handle); nvs_close(handle);
return error; 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)) { if (size != sizeof(*blob)) {
nvs_close(handle); nvs_close(handle);
return ESP_ERR_INVALID_VERSION; return ESP_ERR_INVALID_VERSION;
@@ -608,7 +634,7 @@ static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing)
if (error != ESP_OK) { if (error != ESP_OK) {
return error; 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) esp_err_t web_security_init(web_security_load_result_t *load_result)
@@ -633,7 +659,8 @@ esp_err_t web_security_init(web_security_load_result_t *load_result)
web_security_blob_t candidate; web_security_blob_t candidate;
bool missing = false; bool missing = false;
error = load_stored_blob(&candidate, &missing); bool migrated = false;
error = load_stored_blob(&candidate, &missing, &migrated);
if (error == ESP_OK && missing) { if (error == ESP_OK && missing) {
error = generate_all(&candidate, 1U); error = generate_all(&candidate, 1U);
if (error == ESP_OK) { if (error == ESP_OK) {
@@ -644,6 +671,7 @@ esp_err_t web_security_init(web_security_load_result_t *load_result)
s_material = candidate; s_material = candidate;
s_material_ready = true; s_material_ready = true;
s_load_result = missing ? WEB_SECURITY_LOAD_GENERATED_MISSING s_load_result = missing ? WEB_SECURITY_LOAD_GENERATED_MISSING
: migrated ? WEB_SECURITY_LOAD_MIGRATED_V1
: WEB_SECURITY_LOAD_STORED; : WEB_SECURITY_LOAD_STORED;
if (load_result != NULL) { if (load_result != NULL) {
*load_result = s_load_result; *load_result = s_load_result;
@@ -700,35 +728,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( esp_err_t web_security_get_certificate_metadata(
web_security_certificate_metadata_t *metadata) web_security_certificate_metadata_t *metadata)
{ {
@@ -782,37 +781,6 @@ static void install_committed_blob(const web_security_blob_t *candidate)
s_load_result = WEB_SECURITY_LOAD_STORED; s_load_result = WEB_SECURITY_LOAD_STORED;
} }
esp_err_t web_security_rotate_credentials(web_security_credentials_t *new_credentials)
{
if (s_security_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
esp_err_t error = ESP_ERR_INVALID_STATE;
web_security_blob_t candidate;
memset(&candidate, 0, sizeof(candidate));
if (s_material_ready) {
candidate = s_material;
error = increment_generation(&candidate);
if (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_rotate_certificate(void)
{ {
if (s_security_mutex == NULL) { if (s_security_mutex == NULL) {
@@ -841,7 +809,7 @@ esp_err_t web_security_rotate_certificate(void)
return error; return error;
} }
esp_err_t web_security_reset_all(web_security_credentials_t *new_credentials) esp_err_t web_security_reset_all(void)
{ {
esp_err_t error = secure_random_init(); esp_err_t error = secure_random_init();
if (error != ESP_OK) { if (error != ESP_OK) {
@@ -869,9 +837,6 @@ esp_err_t web_security_reset_all(web_security_credentials_t *new_credentials)
} }
if (error == ESP_OK) { if (error == ESP_OK) {
install_committed_blob(&candidate); install_committed_blob(&candidate);
if (new_credentials != NULL) {
copy_credentials_locked(new_credentials, &s_material);
}
} }
secure_wipe(&candidate, sizeof(candidate)); secure_wipe(&candidate, sizeof(candidate));
xSemaphoreGive(s_security_mutex); xSemaphoreGive(s_security_mutex);
+14 -20
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */ /* 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 #pragma once
@@ -16,9 +16,7 @@ extern "C" {
#define WEB_SECURITY_NVS_NAMESPACE "web_sec" #define WEB_SECURITY_NVS_NAMESPACE "web_sec"
#define WEB_SECURITY_NVS_BLOB_KEY "material" #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_PRIVATE_KEY_DER_CAPACITY 256U
#define WEB_SECURITY_CERTIFICATE_DER_CAPACITY 1024U #define WEB_SECURITY_CERTIFICATE_DER_CAPACITY 1024U
#define WEB_SECURITY_SHA256_LENGTH 32U #define WEB_SECURITY_SHA256_LENGTH 32U
@@ -31,18 +29,9 @@ extern "C" {
typedef enum { typedef enum {
WEB_SECURITY_LOAD_STORED = 0, WEB_SECURITY_LOAD_STORED = 0,
WEB_SECURITY_LOAD_GENERATED_MISSING = 1, WEB_SECURITY_LOAD_GENERATED_MISSING = 1,
WEB_SECURITY_LOAD_MIGRATED_V1 = 2,
} web_security_load_result_t; } 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 { typedef struct {
uint32_t material_generation; uint32_t material_generation;
@@ -55,7 +44,11 @@ typedef struct {
} web_security_certificate_metadata_t; } 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 * an existing wrong-version blob returns ESP_ERR_INVALID_VERSION, while any
* malformed or cryptographically inconsistent blob returns * malformed or cryptographically inconsistent blob returns
* ESP_ERR_INVALID_RESPONSE and is never overwritten. Call before radio startup * ESP_ERR_INVALID_RESPONSE and is never overwritten. Call before radio startup
@@ -73,17 +66,18 @@ esp_err_t web_security_copy_tls_material(
uint8_t *private_key, size_t private_key_capacity, uint8_t *private_key, size_t private_key_capacity,
size_t *private_key_length); 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( esp_err_t web_security_get_certificate_metadata(
web_security_certificate_metadata_t *metadata); web_security_certificate_metadata_t *metadata);
/* Mutations become visible only after a complete blob has committed to NVS. */ /* 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); esp_err_t web_security_rotate_certificate(void);
/* Explicitly replaces missing, valid, or incompatible stored material. */ /* TLS ONLY: explicitly replaces missing, valid, or incompatible material.
esp_err_t web_security_reset_all(web_security_credentials_t *new_credentials); * 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 #ifdef __cplusplus
} }
+39 -1
View File
@@ -84,6 +84,13 @@ static void typed_key_tests(void)
ed[en-1]=2; ed[en-1]=2;
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_OK && index==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; 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); 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; 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); assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_ERR_NO_MEM && commits==saved);
@@ -98,6 +105,37 @@ static void typed_key_tests(void)
stale_keys(&target,ed,en); target=key_target(); stale_keys(&target,ed,en); target=key_target();
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && snapshot.public_key_count==2); 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); 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,1)==ESP_ERR_NOT_FOUND);
assert(user_database_remove_ssh_key_current(&target,3)==ESP_ERR_INVALID_ARG); 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); assert(user_database_add_ssh_key_current(&target,s_ecdsa_type,19,p256,pn,&index)==ESP_OK && index==1);
@@ -116,7 +154,7 @@ static void typed_key_tests(void)
assert(user_database_remove_ssh_key_current(&target,0)==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_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))); assert(user_database_get_account_keys(NULL,&snapshot)==ESP_ERR_INVALID_ARG && all_zero(&snapshot,sizeof(snapshot)));
/* Legacy CLI APIs retain the exact transaction path and generation changes. */ /* 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); 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); stale_keys(&target,ed,en);
assert(user_database_remove_ssh_key((const uint8_t *)"other",5,index)==ESP_OK); assert(user_database_remove_ssh_key((const uint8_t *)"other",5,index)==ESP_OK);
+72 -1
View File
@@ -6,7 +6,6 @@ static void reset(void)
s_candidate=&candidate_storage; s_mutex=(void *)1; s_initialized=true; s_candidate=&candidate_storage; s_mutex=(void *)1; s_initialized=true;
s_database.version=USER_DATABASE_SCHEMA_VERSION; s_database.version=USER_DATABASE_SCHEMA_VERSION;
s_database.size=sizeof(s_database); s_database.generation=1; s_database.size=sizeof(s_database); s_database.generation=1;
s_database.admin_bootstrapped=1;
fail_stage=0; invalidate_during_derivation=false; derivation_invalidations=0; fail_stage=0; invalidate_during_derivation=false; derivation_invalidations=0;
assert(initialize_user(&s_database.users[0], (const uint8_t *)"admin", 5, assert(initialize_user(&s_database.users[0], (const uint8_t *)"admin", 5,
USER_ROLE_ADMIN, (const uint8_t *)"test-password", 13)==ESP_OK); USER_ROLE_ADMIN, (const uint8_t *)"test-password", 13)==ESP_OK);
@@ -138,8 +137,80 @@ static void typed_password_tests(void)
} }
assert(user_database_generate_password_value(NULL)==ESP_ERR_INVALID_ARG); 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) int main(void)
{ {
storage_tests();
typed_account_tests(); typed_account_tests();
typed_password_tests(); typed_password_tests();
const char *supported[]={ const char *supported[]={
+30 -7
View File
@@ -41,6 +41,20 @@ enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
typedef void *SemaphoreHandle_t; typedef void *SemaphoreHandle_t;
#define portMAX_DELAY 0 #define portMAX_DELAY 0
#define NVS_READWRITE 1 #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; typedef int nvs_handle_t;
static unsigned locks, writes, commits, random_calls, prompts, checks, web_revokes, ssh_revokes; static unsigned locks, writes, commits, random_calls, prompts, checks, web_revokes, ssh_revokes;
static unsigned fail_stage, revoke_prompt, revoke_check, derivation_invalidations; static unsigned fail_stage, revoke_prompt, revoke_check, derivation_invalidations;
@@ -53,16 +67,21 @@ static int xSemaphoreTake(void *m, int t) { (void)m; last_wait=t; if (snapshot_b
static void xSemaphoreGive(void *m) { (void)m; assert(locks-- == 1); } 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 const char *esp_err_to_name(int e) { (void)e; return "injected error"; }
static int nvs_open(const char *ns, int mode, int *h) { static int nvs_open(const char *ns, int mode, int *h) {
(void)ns; (void)mode; assert(locks); (void)ns; (void)mode; assert(locks || storage_test);
if (invalidate_during_derivation) { if (invalidate_during_derivation) {
assert(derivation_invalidations==1 && !owner_current); assert(derivation_invalidations==1 && !owner_current);
} }
*h=1; return fail_stage==1 ? ESP_FAIL : ESP_OK; *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) { static int nvs_set_blob(int h, const char *key, const void *data, size_t n) {
(void)h; (void)key; (void)data; (void)n; ++writes; return fail_stage==2 ? ESP_FAIL : ESP_OK; (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 int nvs_commit(int h) { (void)h; ++commits; return fail_stage==3 ? ESP_FAIL : ESP_OK; }
static void nvs_close(int h) { (void)h; } static void nvs_close(int h) { (void)h; }
static int secure_random_fill(void *p, size_t n) { static int secure_random_fill(void *p, size_t n) {
memset(p, ++random_calls, n); return fail_stage==4 ? ESP_FAIL : ESP_OK; memset(p, ++random_calls, n); return fail_stage==4 ? ESP_FAIL : ESP_OK;
@@ -108,6 +127,8 @@ state = db[db.index("#define USER_DATABASE_SCHEMA_VERSION"):db.index("static esp
fakes = r''' fakes = r'''
static stored_database_t candidate_storage; 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 user_principal_t actor;
static bool admin_ssh_console_dispatch_is_remote(void) { return remote; } static bool admin_ssh_console_dispatch_is_remote(void) { return remote; }
static bool admin_ssh_console_dispatch_is_web(void) { return web; } static bool admin_ssh_console_dispatch_is_web(void) { return web; }
@@ -138,8 +159,7 @@ static int ssh_transport_revoke_user(const uint8_t *u, size_t n) {
} }
/* Forbidden paths are traps rather than alternative implementations. */ /* Forbidden paths are traps rather than alternative implementations. */
static int show_users(const char *n) { (void)n; return 0; } static int show_users(const char *n) { (void)n; return 0; }
static int recover_database(void) { assert(!"recovery"); return 1; }
static int bootstrap(bool g) { (void)g; assert(!"bootstrap"); return 1; }
static int add_key(const char *n) { (void)n; assert(!"key mutation"); return 1; } 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) { 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; (void)n; (void)t; (void)tl; (void)b; (void)bl; assert(!"key mutation"); return 1;
@@ -167,10 +187,12 @@ db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
"user_database_get_account_keys", "add_ssh_key", "remove_ssh_key", "clear_ssh_keys", "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", "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", "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"] "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", console_names = ["print_usage", "revoke_user_network_sessions", "read_password",
"show_generated_password", "mutation_currentness", "add_user", "change_password", "show_generated_password", "mutation_currentness", "add_user", "change_password",
"parse_key_index", "command_user_inner", "command_user"] "parse_key_index", "recover_database", "command_user_inner", "command_user"]
unit = prelude + header + "\n" + state + fakes unit = prelude + header + "\n" + state + fakes
unit += "\n".join(function(db, n) for n in db_names) unit += "\n".join(function(db, n) for n in db_names)
unit += function(admin, "admin_ssh_console_web_user_command_allowed") unit += function(admin, "admin_ssh_console_web_user_command_allowed")
@@ -190,6 +212,7 @@ with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
result = subprocess.run([str(path / "test")], check=True, timeout=10, capture_output=True, text=True) result = subprocess.run([str(path / "test")], check=True, timeout=10, capture_output=True, text=True)
assert "test-password" not in result.stdout assert "test-password" not in result.stdout
assert "Generated password for" 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: 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: 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") 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")
+14 -4
View File
@@ -26,7 +26,7 @@ typedef struct { int unused; } user_principal_t;
''' '''
fakes = r''' fakes = r'''
static bool remote, web; static bool remote, web;
static unsigned stops, reboots, scheduled, waits, rotations; static unsigned stops, reboots, scheduled, waits, rotations, usages;
static esp_err_t schedule_result, stop_result; static esp_err_t schedule_result, stop_result;
static admin_ssh_deferred_action_type_t last_action; 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_remote(void) { return remote; }
@@ -42,13 +42,11 @@ static esp_err_t web_serial_transport_clear_counters(void) { assert(false); retu
static void esp_restart(void) { ++reboots; } static void esp_restart(void) { ++reboots; }
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; } static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
#define pdMS_TO_TICKS(ms) (ms) #define pdMS_TO_TICKS(ms) (ms)
static void print_usage(void) { assert(false); } static void print_usage(void) { ++usages; }
static int web_diagnostics_command(const char *action) { assert(!strcmp(action, "show")); return 0; } static int web_diagnostics_command(const char *action) { assert(!strcmp(action, "show")); return 0; }
static int show_status(void) { assert(false); return 1; } static int show_status(void) { assert(false); return 1; }
static int show_counters(void) { assert(false); return 1; } static int show_counters(void) { assert(false); return 1; }
static int show_credentials(void) { assert(false); return 1; }
static int show_certificate(void) { assert(false); return 1; } static int show_certificate(void) { assert(false); return 1; }
static int rotate_credentials(void) { assert(false); return 1; }
static int rotate_certificate(void) { ++rotations; return 0; } static int rotate_certificate(void) { ++rotations; return 0; }
static int reset_material(void) { assert(false); return 1; } static int reset_material(void) { assert(false); return 1; }
static bool force_is_present(int argc, char **argv, int expected) { static bool force_is_present(int argc, char **argv, int expected) {
@@ -57,6 +55,18 @@ static bool force_is_present(int argc, char **argv, int expected) {
''' '''
tests = r''' tests = r'''
int main(void) { 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"}; char *diagnostics[]={"web", "diagnostics", "show"};
assert(command_web(3, diagnostics)==0 && !stops && !scheduled); assert(command_web(3, diagnostics)==0 && !stops && !scheduled);
char *stop[]={"web", "stop"}; char *stop[]={"web", "stop"};
+103 -5
View File
@@ -5,6 +5,7 @@ Requires Python 3, cc and IDF_PATH (defaults to PlatformIO's installed SDK).
Does not run FreeRTOS dispatch, SSH I/O or target hardware. Does not run FreeRTOS dispatch, SSH I/O or target hardware.
""" """
import os import os
import re
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import tempfile import tempfile
@@ -46,10 +47,11 @@ int main(void) {
{"", true}, {" ", true}, {" ", true}, {"", true}, {" ", true}, {" ", true},
{"memory", true}, {"user", true}, {"user list", true}, {"memory", true}, {"user", true}, {"user list", true},
{"user show bootstrap", true}, {"exit", true}, {"user show bootstrap", true}, {"exit", true},
{"user bootstrap", false}, {"user bootstrap extra", false}, /* 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", false}, {"user recover --force", false},
{" user recover --force ", false}, {" user recover --force ", false},
{"\"user\" \"bootstrap\"", false}, {"\"user\" \"bootstrap\"", true},
{"\"user\" \"recover\" --force", false}, {"\"user\" \"recover\" --force", false},
}; };
for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); ++i) { for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); ++i) {
@@ -116,11 +118,11 @@ int main(void) {
assert(!remote_command_allowed(&request)); assert(!remote_command_allowed(&request));
assert(!strcmp(request.line,web_denied[i])); assert(!strcmp(request.line,web_denied[i]));
request.token.transport=0; request.token.transport=0;
/* SSH retains only the global bootstrap/recover dispatcher restriction. */ /* SSH retains only the global recovery dispatcher restriction. */
assert(remote_command_allowed(&request) == assert(remote_command_allowed(&request) ==
(strstr(request.line,"bootstrap")==NULL && strstr(request.line,"recover")==NULL)); (strstr(request.line,"recover")==NULL));
} }
puts("PASS: SSH policy unchanged; web bounded account forms, restrictions/lifecycle and quoted forms checked with actual IDF parser"); 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: with tempfile.TemporaryDirectory(prefix="admin-ssh-policy-") as directory:
@@ -130,3 +132,99 @@ with tempfile.TemporaryDirectory(prefix="admin-ssh-policy-") as directory:
str(path / "test.c"), str(IDF / "components/console/split_argv.c"), str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
"-o", str(path / "test")], check=True, timeout=30) "-o", str(path / "test")], check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10) 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")
+109
View File
@@ -0,0 +1,109 @@
# Production web-security host regression
Run from the repository root:
```sh
python3 tests/web_security/run.py
```
Requires a C11 compiler, `nm`, Python 3, and installed mbedTLS 3.6 headers plus
`libmbedx509` / `libmbedcrypto`. Verified with host mbedTLS 3.6.7. No downloads,
firmware build, device access, asset generation or persistent build outputs.
The runner creates adapters and binaries in a temporary directory.
`security.c` includes the **entire unchanged production `src/web_security.c`**.
No crypto function, generator, validator, decoder or transaction is extracted or
replaced. Actual mbedTLS generates P-256 keys and signed certificates and parses,
hashes, checks the key pair and verifies the self-signature. The legacy fixture
is independently assembled at fixed little-endian byte offsets from a freshly
generated real identity, not a production legacy encoder. No real device secret
or fixed private key is checked in.
Host adapters provide a fixed device MAC, Linux `getrandom`, a lock-ownership
assertion, and fault-injectable NVS with staged writes/commit. At every write and
commit the adapter checks that production live state is still its predecessor.
Legacy wipe calls are counted and checked. Production restart is simulated by
clearing only module RAM while retaining the adapter's stored record. These
adapters do **not** prove ESP entropy initialization, allocation failure inside
mbedTLS, FreeRTOS concurrency, ESP NVS flash/power-loss semantics, TLS handshakes,
HTTPD restarts, target stack margins, or secure physical flash erasure. In
particular, modeled failed commits retain predecessor storage; real flash fault
and power-loss behavior needs target validation. No claim that NVS logical
replacement securely erases historical flash pages.
The suite reports 15 production security groups plus one API/console static
absence check. Coverage includes fresh and stored-v2 paths, exact v1 migration,
metadata/pair-copy bounds, NVS failures and retries, 21 legacy corruptions,
14 v2 corruptions, unknown sizes, real bad signatures with recomputed hashes,
mismatched private keys, wrong-device certificates, RNG/MAC/mutex failures,
rotation/reset commit-before-publication, unavailable explicit recovery,
generation exhaustion and invalid arguments. Console checks establish removal
of legacy command/secret/synchronization references, not runtime console
lifecycle execution. Read-only database status and login-failure counters are
intentionally retained.
## Integration/API contract
Five public functions remain:
- `web_security_init(web_security_load_result_t *)`
- `web_security_copy_tls_material(...)` (unchanged pair-copy API)
- `web_security_get_certificate_metadata(...)` (unchanged metadata)
- `web_security_rotate_certificate(void)`
- `web_security_reset_all(void)` (**TLS only**, changed signature)
Removed: two credential functions (`show_credentials`, `rotate_credentials`),
one credential struct type, three username/password capacity/length constants,
and two console operations (`web credentials show`, `web credentials rotate`).
There is no credential generation/display/synchronization path. Authentication
continues to belong to the user database; read-only status does not mutate it.
The integration owner must remove legacy startup callers in `main.c` and adapt
other console policy/completion/UI/test callers outside this ownership scope.
Load results retain `STORED=0`, `GENERATED_MISSING=1`, and add `MIGRATED_V1=2`.
Repeated successful init returns the remembered result without reloading.
Migration must validate and commit before publication; no fallback generation
or overwrite follows migration failure. Reset explicitly overwrites missing,
valid, or incompatible material, increments a live generation or uses one when
no live identity exists, and fails on live generation exhaustion. Rotation
requires live material and also fails at `UINT32_MAX`.
`web reset --force` retains the old lifecycle: commit first; when running,
stop then start, with no start after failed stop; otherwise attempt start.
Lifecycle failure does not roll back committed identity. Database accounts are
never synchronized, reset or otherwise mutated by these operations.
## Storage contract
The namespace/key remain **`web_sec/material`**, one blob, no new NVS keys.
V1 is exactly **1392 bytes**, read only through a private fixed-offset decoder.
Its credential layout must still match the shipped `admin`/24-character URL-safe
format, zero padding/reserved fields, and strict TLS validation.
V2 is exactly **1340 bytes** (52 bytes smaller), native little-endian ESP32
layout with compile-time offset/size assertions:
| Offset | Size | Field |
|---:|---:|---|
| 0 | 4 | schema version = 2 |
| 4 | 2 | blob size = 1340 |
| 6 | 2 | reserved, zero |
| 8 | 4 | nonzero generation |
| 12 | 2 | private key DER length |
| 14 | 2 | certificate DER length |
| 16 | 256 | private key DER, unused bytes zero |
| 272 | 1024 | certificate DER, unused bytes zero |
| 1296 | 32 | certificate SHA-256 fingerprint |
| 1328 | 12 | reserved, zero |
Migration preserves exact DER bytes, fingerprint and generation, including
`UINT32_MAX`; it never regenerates TLS identity. TLS validation includes exact
outer DER lengths, P-256 pair consistency, self-signature, fingerprint, device
CN/SAN, validity and existing certificate extension policy. Unknown sizes or
schema versions fail with `ESP_ERR_INVALID_VERSION`; malformed known records
fail with `ESP_ERR_INVALID_RESPONSE` (underlying operational failures propagate).
Every legacy input buffer is wiped on all post-read exits. The live blob has no
credential fields. No new task, queue, mutex, heap allocation or storage key is
introduced; the existing mutex remains. Migration adds a bounded 1392-byte
transient decoder buffer alongside the 1340-byte candidate; target call-stack
high-water usage is unmeasured.
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Compile unchanged production security implementation with real host mbedTLS."""
from pathlib import Path
import subprocess
import re
import tempfile
ROOT = Path(__file__).resolve().parents[2]
HEADERS = {
"esp_err.h": """#pragma once
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_INVALID_ARG 1
#define ESP_ERR_INVALID_STATE 2
#define ESP_ERR_INVALID_SIZE 3
#define ESP_ERR_INVALID_VERSION 4
#define ESP_ERR_INVALID_RESPONSE 5
#define ESP_ERR_NO_MEM 6
""",
"esp_mac.h": """#pragma once
#include <stdint.h>
#include "esp_err.h"
#define ESP_MAC_WIFI_SOFTAP 1
esp_err_t esp_read_mac(uint8_t *, int);
""",
"freertos/FreeRTOS.h": """#pragma once
#define portMAX_DELAY 0xffffffffU
""",
"freertos/semphr.h": """#pragma once
typedef void *SemaphoreHandle_t;
SemaphoreHandle_t xSemaphoreCreateMutex(void);
int xSemaphoreTake(SemaphoreHandle_t, unsigned);
int xSemaphoreGive(SemaphoreHandle_t);
""",
"nvs.h": """#pragma once
#include <stddef.h>
#include "esp_err.h"
typedef int nvs_handle_t;
#define NVS_READONLY 0
#define NVS_READWRITE 1
#define ESP_ERR_NVS_NOT_FOUND 10
#define ESP_ERR_NVS_TYPE_MISMATCH 11
#define ESP_ERR_NVS_INVALID_LENGTH 12
esp_err_t nvs_open(const char *, int, nvs_handle_t *);
esp_err_t nvs_get_blob(nvs_handle_t, const char *, void *, size_t *);
esp_err_t nvs_set_blob(nvs_handle_t, const char *, const void *, size_t);
esp_err_t nvs_commit(nvs_handle_t);
void nvs_close(nvs_handle_t);
""",
}
with tempfile.TemporaryDirectory(prefix="web-security-") as directory:
out = Path(directory)
for name, text in HEADERS.items():
path = out / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
executable = out / "security"
subprocess.run([
"cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-O1", "-g",
"-I", str(out), "-I", str(ROOT / "src"),
str(ROOT / "tests/web_security/security.c"),
"-lmbedx509", "-lmbedcrypto", "-o", str(executable),
], check=True)
subprocess.run([str(executable)], check=True)
symbols = subprocess.check_output(["nm", "-g", str(executable)], text=True)
assert "web_security_show_credentials" not in symbols
assert "web_security_rotate_credentials" not in symbols
assert set(re.findall(r" T (web_security_\w+)$", symbols, re.MULTILINE)) == {
"web_security_init", "web_security_copy_tls_material",
"web_security_get_certificate_metadata", "web_security_rotate_certificate",
"web_security_reset_all",
}
header = (ROOT / "src/web_security.h").read_text()
assert "web_security_credentials_t" not in header
assert "WEB_SECURITY_PASSWORD" not in header
assert "WEB_SECURITY_USERNAME" not in header
console = (ROOT / "src/web_console.c").read_text()
for forbidden in ('"credentials"', "web credentials", "user_database_sync_legacy", "synchronize_migrated", "Password:"):
assert forbidden not in console, forbidden
assert "web_security_reset_all()" in console
assert set(re.findall(r"\b(user_database_\w+)\s*\(", console)) == {
"user_database_get_snapshot",
}
print("PASS exact five-function API and legacy credential/console DB-mutation absence")
+311
View File
@@ -0,0 +1,311 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/random.h>
/* Include, do not extract or replace: all production crypto/storage paths run. */
#include "../../src/web_security.c"
static uint8_t stored[1600], pending[1600];
static size_t stored_size, pending_size;
static int fault, writes, commits, rng_calls, legacy_wipes, groups;
static bool locked, fail_mutex, fail_rng, fail_mac, alternate_mac, watch_publication;
static web_security_blob_t expected_live;
enum { OPEN_RO = 20, OPEN_RW, QUERY, READ, SET, COMMIT, TYPE, SHORT_READ };
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return fail_mutex ? NULL : (void *)1; }
int xSemaphoreTake(SemaphoreHandle_t m, unsigned delay)
{ (void)delay; assert(m && !locked); locked = true; return 1; }
int xSemaphoreGive(SemaphoreHandle_t m)
{ assert(m && locked); locked = false; return 1; }
esp_err_t esp_read_mac(uint8_t *mac, int type)
{
assert(type == ESP_MAC_WIFI_SOFTAP);
const uint8_t fixed[] = {2, 0, 0, 0x12, 0x34, 0x56};
memcpy(mac, fixed, sizeof(fixed));
if (alternate_mac) mac[5] ^= 1;
return fail_mac ? ESP_FAIL : ESP_OK;
}
esp_err_t secure_random_init(void) { return fail_rng ? ESP_FAIL : ESP_OK; }
esp_err_t secure_random_fill(void *out, size_t length)
{
++rng_calls;
if (fail_rng) return ESP_FAIL;
return getrandom(out, length, 0) == (ssize_t)length ? ESP_OK : ESP_FAIL;
}
int secure_random_mbedtls(void *ctx, unsigned char *out, size_t length)
{ (void)ctx; return secure_random_fill(out, length) == ESP_OK ? 0 : -1; }
void secure_wipe(void *data, size_t size)
{
volatile uint8_t *p = data;
for (size_t i = 0; i < size; ++i) p[i] = 0;
if (size == LEGACY_BLOB_SIZE) {
++legacy_wipes;
assert(bytes_are_zero(data, size));
}
}
esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *handle)
{
assert(!strcmp(name, "web_sec"));
if (fault == (mode == NVS_READONLY ? OPEN_RO : OPEN_RW)) return ESP_FAIL;
*handle = mode;
return ESP_OK;
}
esp_err_t nvs_get_blob(nvs_handle_t handle, const char *key, void *data, size_t *size)
{
assert(handle == NVS_READONLY && !strcmp(key, "material"));
if (fault == TYPE) return ESP_ERR_NVS_TYPE_MISMATCH;
if (fault == (data ? READ : QUERY)) return ESP_FAIL;
if (!stored_size) return ESP_ERR_NVS_NOT_FOUND;
if (!data) { *size = stored_size; return ESP_OK; }
assert(*size >= stored_size);
memcpy(data, stored, stored_size);
*size = stored_size - (fault == SHORT_READ ? 1 : 0);
return ESP_OK;
}
esp_err_t nvs_set_blob(nvs_handle_t handle, const char *key, const void *data, size_t size)
{
assert(handle == NVS_READWRITE && !strcmp(key, "material"));
assert(size == 1340 && locked);
++writes;
if (watch_publication) assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
if (fault == SET) return ESP_FAIL;
memcpy(pending, data, size); pending_size = size;
return ESP_OK;
}
esp_err_t nvs_commit(nvs_handle_t handle)
{
assert(handle == NVS_READWRITE && pending_size);
++commits;
if (watch_publication) assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
if (fault == COMMIT) return ESP_FAIL;
memcpy(stored, pending, pending_size); stored_size = pending_size;
return ESP_OK;
}
void nvs_close(nvs_handle_t handle) { (void)handle; pending_size = 0; }
static void boot(void)
{
memset(&s_material, 0, sizeof(s_material));
s_material_ready = false; s_security_mutex = NULL;
s_load_result = WEB_SECURITY_LOAD_STORED;
fault = writes = commits = rng_calls = legacy_wipes = 0;
fail_mutex = fail_rng = fail_mac = alternate_mac = locked = false;
expected_live = s_material; watch_publication = true;
}
static void put16(uint8_t *p, unsigned v) { p[0] = v; p[1] = v >> 8; }
static void legacy(const web_security_blob_t *identity)
{
memset(stored, 0, sizeof(stored)); stored_size = 1392;
stored[0] = 1; put16(stored + 4, 1392);
put16(stored + 8, identity->generation);
put16(stored + 10, identity->generation >> 16);
stored[12] = 5; stored[13] = 24;
put16(stored + 14, identity->private_key_length);
put16(stored + 16, identity->certificate_length);
memcpy(stored + 20, "admin", 5);
memcpy(stored + 36, "Ab09-_Ab09-_Ab09-_Ab09-_", 24);
memcpy(stored + 68, identity->private_key_der, 256);
memcpy(stored + 324, identity->certificate_der, 1024);
memcpy(stored + 1348, identity->certificate_fingerprint, 32);
}
static void group(const char *name) { ++groups; printf("PASS %s\n", name); }
static void rejected(void)
{
uint8_t before[1600]; memcpy(before, stored, sizeof(before));
size_t size = stored_size;
web_security_load_result_t result = (web_security_load_result_t)99;
assert(web_security_init(&result) != ESP_OK);
assert(result == 99 && !s_material_ready);
assert(bytes_are_zero((uint8_t *)&s_material, sizeof(s_material)));
assert(size == stored_size && !memcmp(before, stored, sizeof(before)));
assert(writes == 0 || fault == SET || fault == COMMIT);
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
}
int main(void)
{
boot(); stored_size = 0;
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
web_security_load_result_t result;
assert(web_security_init(&result) == ESP_OK);
assert(result == WEB_SECURITY_LOAD_GENERATED_MISSING);
assert(stored_size == 1340 && writes == 1 && commits == 1);
assert(s_material.generation == 1 && validate_blob(&s_material) == ESP_OK);
web_security_blob_t identity = s_material;
group("fresh TLS-only generation and commit-before-publication");
boot(); assert(web_security_init(&result) == ESP_OK);
assert(result == WEB_SECURITY_LOAD_STORED && !writes && !commits);
assert(!memcmp(&identity, &s_material, sizeof(identity)));
group("stored v2 exact reload without write");
identity.generation = 0x12345678;
boot(); legacy(&identity);
assert(web_security_init(&result) == ESP_OK);
assert(result == WEB_SECURITY_LOAD_MIGRATED_V1 && legacy_wipes == 1);
assert(writes == 1 && commits == 1 && stored_size == 1340);
assert(!memcmp(&identity, &s_material, sizeof(identity)));
assert(!memcmp(stored, &identity, sizeof(identity)));
assert(web_security_init(&result) == ESP_OK && writes == 1);
assert(result == WEB_SECURITY_LOAD_MIGRATED_V1);
group("v1 migration exact key/certificate/fingerprint/generation and wipe");
uint8_t certificate[1024], key[256]; size_t cn, kn;
assert(web_security_copy_tls_material(NULL, 0, &cn, NULL, 0, &kn) == ESP_OK);
memset(certificate, 0xa5, sizeof(certificate));
assert(web_security_copy_tls_material(certificate, sizeof(certificate), &cn,
key, 1, &kn) == ESP_ERR_INVALID_SIZE);
assert(certificate[0] == 0xa5);
assert(web_security_copy_tls_material(certificate, sizeof(certificate), &cn,
key, sizeof(key), &kn) == ESP_OK);
assert(cn == identity.certificate_length && kn == identity.private_key_length);
assert(!memcmp(certificate, identity.certificate_der, cn));
assert(!memcmp(key, identity.private_key_der, kn));
web_security_certificate_metadata_t metadata;
assert(web_security_get_certificate_metadata(&metadata) == ESP_OK);
assert(metadata.material_generation == identity.generation);
assert(!memcmp(metadata.sha256_fingerprint, identity.certificate_fingerprint, 32));
group("public metadata and atomic pair-copy capacity contract");
const int faults[] = {OPEN_RO, QUERY, READ, TYPE, SHORT_READ, OPEN_RW, SET, COMMIT};
for (size_t i = 0; i < sizeof(faults)/sizeof(faults[0]); ++i) {
boot(); legacy(&identity); fault = faults[i]; rejected();
if (fault == READ || fault == SHORT_READ || fault == OPEN_RW ||
fault == SET || fault == COMMIT) assert(legacy_wipes == 1);
fault = 0;
assert(web_security_init(&result) == ESP_OK);
assert(!memcmp(&identity, &s_material, sizeof(identity)));
}
group("migration NVS open/query/read/type/short/set/commit failures and retry");
const size_t corrupt[] = {0, 4, 6, 8, 12, 13, 14, 15, 16, 17, 18,
20, 25, 36, 60, 68, 323, 324, 1347, 1348, 1380};
for (size_t i = 0; i < sizeof(corrupt)/sizeof(corrupt[0]); ++i) {
boot(); legacy(&identity);
if (corrupt[i] == 8) memset(stored + 8, 0, 4);
else stored[corrupt[i]] ^= 0x80;
rejected(); assert(legacy_wipes == 1 && writes == 0);
}
group("21 legacy schema/reserved/credential/length/DER/fingerprint corruptions");
const size_t bad_sizes[] = {1, 1339, 1341, 1391, 1393, 1600};
for (size_t i = 0; i < sizeof(bad_sizes)/sizeof(bad_sizes[0]); ++i) {
boot(); legacy(&identity); stored_size = bad_sizes[i]; rejected();
}
const size_t v2_corrupt[] = {0, 4, 6, 8, 12, 13, 14, 15, 16, 271, 272, 1295, 1296, 1328};
for (size_t i = 0; i < sizeof(v2_corrupt)/sizeof(v2_corrupt[0]); ++i) {
boot(); memcpy(stored, &identity, sizeof(identity)); stored_size = sizeof(identity);
if (v2_corrupt[i] == 8) memset(stored + 8, 0, 4);
else stored[v2_corrupt[i]] ^= 0x80;
rejected();
}
group("unknown sizes and 14 v2 structural/crypto corruptions fail untouched");
/* Recompute fingerprint so signature validation, not just hashing, rejects. */
web_security_blob_t invalid = identity;
invalid.certificate_der[invalid.certificate_length - 1] ^= 1;
assert(mbedtls_sha256(invalid.certificate_der, invalid.certificate_length,
invalid.certificate_fingerprint, 0) == 0);
boot(); legacy(&invalid); rejected();
web_security_blob_t other;
assert(generate_all(&other, 1) == ESP_OK);
invalid = identity;
memcpy(invalid.private_key_der, other.private_key_der, sizeof(invalid.private_key_der));
invalid.private_key_length = other.private_key_length;
boot(); legacy(&invalid); rejected();
boot(); legacy(&identity); fail_mac = true; rejected();
boot(); alternate_mac = true;
assert(generate_all(&invalid, 1) == ESP_OK);
alternate_mac = false; legacy(&invalid); rejected();
group("real signature, mismatched private key and device identity rejection");
for (size_t i = 0; i < 5; ++i) {
boot(); memcpy(stored, &identity, sizeof(identity)); stored_size = sizeof(identity);
fault = faults[i]; rejected(); assert(writes == 0);
}
boot(); memcpy(stored, &identity, sizeof(identity)); stored_size = sizeof(identity);
fault = SHORT_READ; rejected(); assert(writes == 0);
group("stored v2 read failures remain closed without writes");
for (int kind = 0; kind < 3; ++kind) {
boot(); stored_size = 0;
fail_rng = kind == 0; fail_mutex = kind == 1; fail_mac = kind == 2;
rejected(); assert(writes == 0);
}
for (size_t i = 5; i < sizeof(faults)/sizeof(faults[0]); ++i) {
boot(); stored_size = 0; fault = faults[i]; rejected();
}
group("fresh entropy/mutex/MAC/persistence failures do not publish");
boot(); legacy(&identity); assert(web_security_init(NULL) == ESP_OK);
expected_live = s_material;
for (int operation = 0; operation < 2; ++operation) {
for (int f = OPEN_RW; f <= COMMIT; ++f) {
if (f != OPEN_RW && f != SET && f != COMMIT) continue;
uint8_t before[1600]; memcpy(before, stored, sizeof(before));
fault = f;
assert((operation ? web_security_reset_all() : web_security_rotate_certificate()) != ESP_OK);
assert(!memcmp(&expected_live, &s_material, sizeof(s_material)));
assert(!memcmp(before, stored, sizeof(before)));
}
fault = 0; fail_rng = true;
assert((operation ? web_security_reset_all() : web_security_rotate_certificate()) != ESP_OK);
assert(!memcmp(&expected_live, &s_material, sizeof(s_material)));
fail_rng = false;
assert((operation ? web_security_reset_all() : web_security_rotate_certificate()) == ESP_OK);
assert(s_material.generation == expected_live.generation + 1);
assert(memcmp(s_material.certificate_fingerprint, expected_live.certificate_fingerprint, 32));
assert(validate_blob(&s_material) == ESP_OK);
expected_live = s_material;
}
group("rotation/reset transactional failures, identity change and generation increment");
for (int kind = 0; kind < 3; ++kind) {
boot(); legacy(&identity);
if (kind == 0) stored_size = 0;
if (kind == 1) stored[0] = 99;
assert(web_security_reset_all() == ESP_OK);
assert(s_material_ready && s_material.generation == 1 && stored_size == 1340);
assert(validate_blob(&s_material) == ESP_OK);
}
group("explicit reset replaces missing/unknown/valid storage without prior init");
for (int kind = 0; kind < 3; ++kind) {
for (size_t i = 5; i < sizeof(faults)/sizeof(faults[0]); ++i) {
boot(); legacy(&identity);
if (kind == 0) stored_size = 0;
if (kind == 1) stored[0] = 99;
uint8_t before[1600]; memcpy(before, stored, sizeof(before));
size_t size = stored_size;
fault = faults[i];
assert(web_security_reset_all() != ESP_OK);
assert(!s_material_ready && stored_size == size);
assert(!memcmp(stored, before, sizeof(before)));
assert(bytes_are_zero((uint8_t *)&s_material, sizeof(s_material)));
}
}
group("uninitialized reset persistence failures preserve missing/invalid/valid storage");
boot(); memcpy(stored, &identity, sizeof(identity)); stored_size = sizeof(identity);
assert(web_security_init(NULL) == ESP_OK);
s_material.generation = UINT32_MAX; expected_live = s_material;
int old_writes = writes;
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
assert(!memcmp(&expected_live, &s_material, sizeof(s_material)) && writes == old_writes);
boot(); identity.generation = UINT32_MAX; legacy(&identity);
assert(web_security_init(&result) == ESP_OK && s_material.generation == UINT32_MAX);
group("generation exhaustion rejects mutations but preserves migration identity");
boot(); legacy(&identity);
assert(web_security_copy_tls_material(NULL, 0, NULL, NULL, 0, &kn) == ESP_ERR_INVALID_ARG);
assert(web_security_copy_tls_material(NULL, 1, &cn, NULL, 0, &kn) == ESP_ERR_INVALID_ARG);
assert(web_security_copy_tls_material(NULL, 0, &cn, NULL, 0, &kn) == ESP_ERR_INVALID_STATE);
assert(web_security_get_certificate_metadata(NULL) == ESP_ERR_INVALID_ARG);
assert(web_security_get_certificate_metadata(&metadata) == ESP_ERR_INVALID_STATE);
group("public invalid argument/unavailable contracts");
printf("PASS %d production security groups\n", groups);
return 0;
}